Migrate to Vue 3 and Vite

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2022-07-12 10:55:28 +02:00
parent 8f4099ee33
commit ee20e03cc2
464 changed files with 31515 additions and 32758 deletions

View File

@@ -0,0 +1,20 @@
<template>
<Story>
<Variant title="empty">
<InstanceContactLink />
</Variant>
<Variant title="string">
<InstanceContactLink contact="someone" />
</Variant>
<Variant title="email">
<InstanceContactLink contact="someone@somewhere.tld" />
</Variant>
<Variant title="url">
<InstanceContactLink contact="https://somewhere.com" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import InstanceContactLink from "./InstanceContactLink.vue";
</script>

View File

@@ -4,49 +4,49 @@
configLink.text
}}</a>
<span dir="auto" v-else-if="contact">{{ contact }}</span>
<span v-else>{{ $t("contact uninformed") }}</span>
<span v-else>{{ t("contact uninformed") }}</span>
</p>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@Component
export default class InstanceContactLink extends Vue {
@Prop({ required: true, type: String }) contact!: string;
const props = defineProps<{
contact?: string;
}>();
get configLink(): { uri: string; text: string } | null {
if (!this.contact) return null;
if (this.isContactEmail) {
return {
uri: `mailto:${this.contact}`,
text: this.contact,
};
}
if (this.isContactURL) {
return {
uri: this.contact,
text:
InstanceContactLink.urlToHostname(this.contact) ||
(this.$t("Contact") as string),
};
}
const { t } = useI18n({ useScope: "global" });
const configLink = computed((): { uri: string; text: string } | null => {
if (!props.contact) return null;
if (isContactEmail.value) {
return {
uri: `mailto:${props.contact}`,
text: props.contact,
};
}
if (isContactURL.value) {
return {
uri: props.contact,
text: urlToHostname(props.contact) ?? "Contact",
};
}
return null;
});
const isContactEmail = computed((): boolean => {
return (props.contact ?? "").includes("@");
});
const isContactURL = computed((): boolean => {
return (props.contact ?? "").match(/^https?:\/\//g) !== null;
});
const urlToHostname = (url: string): string | null => {
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
get isContactEmail(): boolean {
return this.contact.includes("@");
}
get isContactURL(): boolean {
return this.contact.match(/^https?:\/\//g) !== null;
}
static urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
}
}
};
</script>

View File

@@ -0,0 +1,52 @@
<template>
<Story>
<Variant title="local">
<ActorCard :actor="stateLocal"></ActorCard>
<template #controls>
<HstText v-model="stateLocal.preferredUsername" title="username" />
<HstText v-model="stateLocal.name" title="Name" />
</template>
</Variant>
<Variant title="remote">
<ActorCard :actor="stateRemote"></ActorCard>
<template #controls>
<HstText v-model="stateRemote.preferredUsername" title="username" />
<HstText v-model="stateRemote.name" title="Name" />
<HstText v-model="stateRemote.domain" title="Domain" />
<HstText v-model="avatarUrl" title="Avatar" />
</template>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ActorCard from "./ActorCard.vue";
import { reactive, ref } from "vue";
import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
const avatarUrl = ref<string>(
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
);
const stateLocal = reactive<IActor>({
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
const stateRemote = reactive<IActor>({
name: "Framasoft",
preferredUsername: "framasoft",
avatar: { url: avatarUrl.value, id: "", name: "", alt: "", metadata: {} },
domain: "framapiaf.org",
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
</script>

View File

@@ -1,9 +1,9 @@
<template>
<div
class="bg-white rounded-lg flex space-x-4 items-center"
class="bg-white dark:bg-mbz-purple rounded-lg flex space-x-4 items-center"
:class="{ 'flex-col p-4 shadow-md sm:p-8 pb-10 w-80': !inline }"
>
<div>
<div class="flex pl-2">
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
@@ -13,16 +13,15 @@
height="48"
/>
</figure>
<b-icon
<AccountCircle
v-else
:size="inline ? 'is-medium' : 'is-large'"
icon="account-circle"
:size="inline ? 24 : 48"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div>
<div :class="{ 'text-center': !inline }" class="overflow-hidden w-full">
<h5
class="text-xl font-medium violet-title tracking-tight text-gray-900 whitespace-pre-line line-clamp-2"
class="text-xl font-medium violet-title tracking-tight text-gray-900 dark:text-gray-200 whitespace-pre-line line-clamp-2"
>
{{ displayName(actor) }}
</h5>
@@ -54,9 +53,9 @@
height="48"
/>
</figure>
<b-icon
<o-icon
v-else
size="is-large"
size="large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
@@ -78,29 +77,28 @@
</div>
</div> -->
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Component
export default class ActorCard extends Vue {
@Prop({ required: true, type: Object }) actor!: IActor;
@Prop({ required: false, type: Boolean, default: false }) full!: boolean;
@Prop({ required: false, type: Boolean, default: false }) inline!: boolean;
@Prop({ required: false, type: Boolean, default: false }) popover!: boolean;
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
withDefaults(
defineProps<{
actor: IActor;
full?: boolean;
inline?: boolean;
popover?: boolean;
limit?: boolean;
}>(),
{
full: false,
inline: false,
popover: false,
limit: true,
}
);
</script>
<style scoped>
.only-first-child ::v-deep :not(:first-child) {
.only-first-child :deep(:not(:first-child)) {
display: none;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<Story>
<Variant title="local">
<ActorInline :actor="stateLocal" />
<template #controls>
<HstText v-model="stateLocal.preferredUsername" title="username" />
<HstText v-model="stateLocal.name" title="Name" />
</template>
</Variant>
<Variant title="remote">
<ActorInline :actor="stateRemote" />
<template #controls>
<HstText v-model="stateRemote.preferredUsername" title="username" />
<HstText v-model="stateRemote.name" title="Name" />
<HstText v-model="stateRemote.domain" title="Domain" />
<HstText v-model="avatarUrl" title="Avatar" />
</template>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ActorInline from "./ActorInline.vue";
import { reactive, ref } from "vue";
import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
const avatarUrl = ref<string>(
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
);
const stateLocal = reactive<IActor>({
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
const stateRemote = reactive<IActor>({
name: "Framasoft",
preferredUsername: "framasoft",
avatar: { url: avatarUrl.value, id: "", name: "", alt: "", metadata: {} },
domain: "framapiaf.org",
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
});
</script>

View File

@@ -1,34 +1,37 @@
<template>
<div class="inline-flex items-start">
<div
class="inline-flex items-start bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
>
<div class="flex-none mr-2">
<figure class="image is-48x48" v-if="actor.avatar">
<img class="is-rounded" :src="actor.avatar.url" alt="" />
<figure v-if="actor.avatar">
<img
class="rounded-xl"
:src="actor.avatar.url"
alt=""
width="36"
height="36"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle :size="36" v-else />
</div>
<div class="flex-auto">
<p class="text-base line-clamp-3 md:line-clamp-2 max-w-xl">
<p class="text-lg line-clamp-3 md:line-clamp-2 max-w-xl">
{{ displayName(actor) }}
</p>
<p class="text-sm text-gray-500 truncate">
<p class="text-sm text-gray-500 dark:text-gray-300 truncate">
@{{ usernameWithDomain(actor) }}
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Component
export default class ActorInline extends Vue {
@Prop({ required: true, type: Object }) actor!: IActor;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
defineProps<{
actor: IActor;
}>();
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@@ -42,7 +45,7 @@ div.actor-inline {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
@include margin-right(0.5rem);
// @include margin-right(0.5rem);
}
div.actor-name {
flex-basis: auto;

View File

@@ -1,90 +0,0 @@
<template>
<section>
<h1 class="title">
{{ $t("My identities") }}
</h1>
<ul class="identities">
<li v-for="identity in identities" :key="identity.id">
<router-link
:to="{
name: 'UpdateIdentity',
params: { identityName: identity.preferredUsername },
}"
class="media identity"
v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"
>
<div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url" />
</figure>
</div>
<div class="media-content">
{{ identity.displayName() }}
</div>
</router-link>
</li>
</ul>
<router-link
:to="{ name: 'CreateIdentity' }"
class="button create-identity is-primary"
>
{{ $t("Create a new identity") }}
</router-link>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IDENTITIES } from "../../graphql/actor";
import { IPerson, Person } from "../../types/actor";
@Component({
apollo: {
identities: {
query: IDENTITIES,
update(result) {
return result.identities.map((i: IPerson) => new Person(i));
},
},
},
})
export default class Identities extends Vue {
@Prop({ type: String }) currentIdentityName!: string;
identities: Person[] = [];
errors: string[] = [];
isCurrentIdentity(identity: IPerson): boolean {
return identity.preferredUsername === this.currentIdentityName;
}
}
</script>
<style lang="scss" scoped>
.identities {
border-right: 1px solid grey;
padding: 15px 0;
}
.media.identity {
align-items: center;
font-size: 1.3rem;
padding-bottom: 0;
margin-bottom: 15px;
color: #000;
&.is-current-identity {
background-color: rgba(0, 0, 0, 0.1);
}
}
.title {
margin-bottom: 30px;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<Story>
<Variant :setup-app="setupApp" title="Person">
<div class="p-5">
<PopoverActorCard :actor="baseActor">
<div><b> Popover me !</b></div></PopoverActorCard
>
</div>
</Variant>
<Variant :setup-app="setupApp" title="Group">
<div class="p-5">
<PopoverActorCard :actor="group">
<div><b> Popover me !</b></div></PopoverActorCard
>
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import PopoverActorCard from "./PopoverActorCard.vue";
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
import { ActorType } from "@/types/enums";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
const group = {
...baseActor,
name: "Framasoft",
preferredUsername: "framasoft",
domain: "mobilizon.fr",
avatar: {
...baseActorAvatar,
url: "https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg",
},
};
function setupApp({ app }) {
app.use(FloatingVue);
}
</script>

View File

@@ -1,44 +1,38 @@
<template>
<v-popover
offset="16"
trigger="hover"
<VMenu
:distance="16"
:triggers="['hover']"
class="popover"
:class="{ inline, clickable: actor && actor.type === ActorType.GROUP }"
>
<slot></slot>
<template slot="popover">
<template #popper>
<actor-card :full="true" :actor="actor" :popover="true" />
</template>
</v-popover>
</VMenu>
</template>
<script lang="ts">
<script lang="ts" setup>
import { ActorType } from "@/types/enums";
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor } from "../../types/actor";
import ActorCard from "./ActorCard.vue";
@Component({
components: {
ActorCard,
},
})
export default class PopoverActorCard extends Vue {
@Prop({ required: true, type: Object }) actor!: IActor;
@Prop({ required: false, type: Boolean, default: false }) inline!: boolean;
ActorType = ActorType;
}
withDefaults(
defineProps<{
actor: IActor;
inline?: boolean;
}>(),
{
inline: false,
}
);
</script>
<style lang="scss" scoped>
.inline {
display: inline;
<style lang="scss">
.v-popper__inner {
padding: 0 !important;
background-color: transparent !important;
}
.popover {
cursor: default;
}
.clickable {
cursor: pointer;
.v-popper__arrow-outer {
border-color: $violet-1 !important;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<Story>
<Variant>
<div class="p-5">
<ProfileOnboarding
:current-actor="baseActor"
instance-name="Instance name"
/>
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import ProfileOnboarding from "./ProfileOnboarding.vue";
import { ActorType } from "@/types/enums";
import { IPerson } from "@/types/actor";
const baseActor: IPerson = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: null,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
</script>

View File

@@ -1,71 +1,60 @@
<template>
<div class="section container">
<div class="setting-title">
<h2>{{ $t("Profiles and federation") }}</h2>
</div>
<div>
<p class="content">
{{
$t(
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
)
}}
</p>
<hr role="presentation" />
<p class="content">
<span>
{{
$t(
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
)
}}
</span>
<span
v-if="config"
v-html="
$t(
'This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.',
{
domain,
instanceName: config.name,
}
)
"
/>
</p>
<hr role="presentation" />
<p class="content">
{{
$t(
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
)
}}
</p>
<div class="has-text-centered">
<code>{{ `${currentActor.preferredUsername}@${domain}` }}</code>
</div>
</div>
<div class="">
<h2 class="text-2xl">{{ t("Profiles and federation") }}</h2>
</div>
<p class="my-2">
{{
t(
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want."
)
}}
</p>
<hr role="presentation" />
<p class="my-2">
<span>
{{
t(
"Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere."
)
}}
</span>
<i18n-t
keypath="This instance, {instanceName}, hosts your profile, so remember its name."
>
<template v-slot:instanceName>
<b>{{
t("{instanceName} ({domain})", {
domain,
instanceName,
})
}}</b>
</template>
</i18n-t>
</p>
<hr role="presentation" />
<p class="my-2">
{{
t(
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
)
}}
</p>
<div class="text-center">
<code>{{ `${currentActor?.preferredUsername}@${domain}` }}</code>
</div>
</template>
<script lang="ts">
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CONFIG } from "@/graphql/config";
<script lang="ts" setup>
import { IPerson } from "@/types/actor";
import { IConfig } from "@/types/config.model";
import { Component, Vue } from "vue-property-decorator";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: CONFIG,
currentActor: CURRENT_ACTOR_CLIENT,
},
})
export default class ProfileOnboarding extends Vue {
config!: IConfig;
defineProps<{
currentActor: IPerson;
instanceName: string;
}>();
currentActor!: IPerson;
const { t } = useI18n({ useScope: "global" });
domain = window.location.hostname;
}
const domain = computed(() => window.location.hostname);
</script>

View File

@@ -1,118 +1,117 @@
<template>
<div class="activity-item">
<b-icon :icon="'chat'" :type="iconColor" />
<o-icon :icon="'chat'" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<router-link
v-if="activity.object"
slot="discussion"
:to="{
name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug },
}"
>{{ subjectParams.discussion_title }}</router-link
>
<b v-else slot="discussion">{{ subjectParams.discussion_title }}</b>
<router-link
v-if="activity.object && subjectParams.old_discussion_title"
slot="old_discussion"
:to="{
name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug },
}"
>{{ subjectParams.old_discussion_title }}</router-link
>
<b
v-else-if="subjectParams.old_discussion_title"
slot="old_discussion"
>{{ subjectParams.old_discussion_title }}</b
>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n
<i18n-t :keypath="translation" tag="p">
<template #discussion>
<router-link
v-if="activity.object"
:to="{
name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug },
}"
>{{ subjectParams.discussion_title }}</router-link
>
<b v-else>{{ subjectParams.discussion_title }}</b>
</template>
<template #old_discussion>
<router-link
v-if="activity.object && subjectParams.old_discussion_title"
:to="{
name: RouteName.DISCUSSION,
params: { slug: subjectParams.discussion_slug },
}"
>{{ subjectParams.old_discussion_title }}</router-link
>
<b v-else-if="subjectParams.old_discussion_title">{{
subjectParams.old_discussion_title
}}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></template
></i18n-t
>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityDiscussionSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { formatTimeString } from "@/filters/datetime";
import {
useActivitySubjectParams,
useIsActivityAuthorCurrentActor,
} from "@/composition/activity";
@Component({
components: {
PopoverActorCard,
},
})
export default class DiscussionActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityDiscussionSubject = ActivityDiscussionSubject;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
if (this.isAuthorCurrentActor) {
return "You created the discussion {discussion}.";
}
return "{profile} created the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
if (this.isAuthorCurrentActor) {
return "You replied to the discussion {discussion}.";
}
return "{profile} replied to the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
if (this.isAuthorCurrentActor) {
return "You renamed the discussion from {old_discussion} to {discussion}.";
}
return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
if (this.isAuthorCurrentActor) {
return "You archived the discussion {discussion}.";
}
return "{profile} archived the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
if (this.isAuthorCurrentActor) {
return "You deleted the discussion {discussion}.";
}
return "{profile} deleted the discussion {discussion}.";
default:
return undefined;
}
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
if (isAuthorCurrentActor) {
return "You created the discussion {discussion}.";
}
return "{profile} created the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
if (isAuthorCurrentActor) {
return "You replied to the discussion {discussion}.";
}
return "{profile} replied to the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
if (isAuthorCurrentActor) {
return "You renamed the discussion from {old_discussion} to {discussion}.";
}
return "{profile} renamed the discussion from {old_discussion} to {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
if (isAuthorCurrentActor) {
return "You archived the discussion {discussion}.";
}
return "{profile} archived the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
if (isAuthorCurrentActor) {
return "You deleted the discussion {discussion}.";
}
return "{profile} deleted the discussion {discussion}.";
default:
return undefined;
}
});
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
return "is-success";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
return "is-grey";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
return "is-danger";
default:
return undefined;
}
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
return "is-success";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
return "is-grey";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
return "is-danger";
default:
return undefined;
}
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,107 +1,107 @@
<template>
<div class="activity-item">
<b-icon :icon="'calendar'" :type="iconColor" />
<o-icon :icon="'calendar'" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<router-link
slot="event"
v-if="activity.object"
:to="{
name: RouteName.EVENT,
params: { uuid: subjectParams.event_uuid },
}"
>{{ subjectParams.event_title }}</router-link
>
<b v-else slot="event">{{ subjectParams.event_title }}</b>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n
<i18n-t :keypath="translation" tag="p">
<template #event>
<router-link
v-if="activity.object"
:to="{
name: RouteName.EVENT,
params: { uuid: subjectParams.event_uuid },
}"
>{{ subjectParams.event_title }}</router-link
>
<b v-else>{{ subjectParams.event_title }}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></template
></i18n-t
>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
<small class="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import {
useActivitySubjectParams,
useIsActivityAuthorCurrentActor,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { usernameWithDomain } from "@/types/actor";
import { formatTimeString } from "@/filters/datetime";
import {
ActivityEventCommentSubject,
ActivityEventSubject,
} from "@/types/enums";
import { mixins } from "vue-class-component";
import { Component } from "vue-property-decorator";
import { computed } from "vue";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
@Component({
components: {
PopoverActorCard,
},
})
export default class EventActivityItem extends mixins(ActivityMixin) {
ActivityEventSubject = ActivityEventSubject;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
if (this.isAuthorCurrentActor) {
return "You created the event {event}.";
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
if (isAuthorCurrentActor) {
return "You created the event {event}.";
}
return "The event {event} was created by {profile}.";
case ActivityEventSubject.EVENT_UPDATED:
if (isAuthorCurrentActor) {
return "You updated the event {event}.";
}
return "The event {event} was updated by {profile}.";
case ActivityEventSubject.EVENT_DELETED:
if (isAuthorCurrentActor) {
return "You deleted the event {event}.";
}
return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED:
if (subjectParams.comment_reply_to) {
if (isAuthorCurrentActor) {
return "You replied to a comment on the event {event}.";
}
return "The event {event} was created by {profile}.";
case ActivityEventSubject.EVENT_UPDATED:
if (this.isAuthorCurrentActor) {
return "You updated the event {event}.";
}
return "The event {event} was updated by {profile}.";
case ActivityEventSubject.EVENT_DELETED:
if (this.isAuthorCurrentActor) {
return "You deleted the event {event}.";
}
return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED:
if (this.subjectParams.comment_reply_to) {
if (this.isAuthorCurrentActor) {
return "You replied to a comment on the event {event}.";
}
return "{profile} replied to a comment on the event {event}.";
}
if (this.isAuthorCurrentActor) {
return "You posted a comment on the event {event}.";
}
return "{profile} posted a comment on the event {event}.";
default:
return undefined;
}
return "{profile} replied to a comment on the event {event}.";
}
if (isAuthorCurrentActor) {
return "You posted a comment on the event {event}.";
}
return "{profile} posted a comment on the event {event}.";
default:
return undefined;
}
});
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
case ActivityEventCommentSubject.COMMENT_POSTED:
return "is-success";
case ActivityEventSubject.EVENT_UPDATED:
return "is-grey";
case ActivityEventSubject.EVENT_DELETED:
return "is-danger";
default:
return undefined;
}
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
case ActivityEventCommentSubject.COMMENT_POSTED:
return "is-success";
case ActivityEventSubject.EVENT_UPDATED:
return "is-grey";
case ActivityEventSubject.EVENT_DELETED:
return "is-danger";
default:
return undefined;
}
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,189 +1,176 @@
<template>
<div class="activity-item">
<b-icon :icon="'cog'" :type="iconColor" />
<o-icon :icon="'cog'" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<router-link
v-if="activity.object"
slot="group"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: subjectParams.group_federated_username,
},
}"
>{{ subjectParams.group_name }}</router-link
>
<b v-else slot="post">{{ subjectParams.group_name }}</b>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n
<i18n-t :keypath="translation" tag="p">
<template #group>
<router-link
v-if="activity.object"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: subjectParams.group_federated_username,
},
}"
>{{ subjectParams.group_name }}</router-link
>
<b v-else>{{ subjectParams.group_name }}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></template
></i18n-t
>
<i18n
:path="detail"
<i18n-t
:keypath="detail"
v-for="detail in details"
:key="detail"
tag="p"
class="has-text-grey-dark"
>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
>
<router-link
v-if="activity.object"
slot="group"
:to="{
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
>
</template>
<template #group>
<router-link
v-if="activity.object"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(activity.object) },
params: { preferredUsername: usernameWithDomain(activity.object as IActor) },
}"
>{{ subjectParams.group_name }}</router-link
>
<b v-else slot="post">{{ subjectParams.group_name }}</b>
<b v-if="subjectParams.old_group_name" slot="old_group_name">{{
subjectParams.old_group_name
}}</b>
</i18n>
>{{ subjectParams.group_name }}</router-link
>
<b v-else>{{ subjectParams.group_name }}</b>
</template>
<template #old_group_name>
<b v-if="subjectParams.old_group_name">{{
subjectParams.old_group_name
}}</b>
</template>
</i18n-t>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
import { usernameWithDomain } from "@/types/actor";
<script lang="ts" setup>
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { IActor, IGroup, usernameWithDomain } from "@/types/actor";
import { ActivityGroupSubject, GroupVisibility, Openness } from "@/types/enums";
import { Component } from "vue-property-decorator";
import { computed } from "vue";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
import { formatTimeString } from "@/filters/datetime";
@Component({
components: {
PopoverActorCard,
},
})
export default class GroupActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityGroupSubject = ActivityGroupSubject;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
if (this.isAuthorCurrentActor) {
return "You created the group {group}.";
}
return "{profile} created the group {group}.";
case ActivityGroupSubject.GROUP_UPDATED:
if (this.isAuthorCurrentActor) {
return "You updated the group {group}.";
}
return "{profile} updated the group {group}.";
default:
return undefined;
}
}
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
return "is-success";
case ActivityGroupSubject.GROUP_UPDATED:
return "is-grey";
default:
return undefined;
}
}
const subjectParams = useActivitySubjectParams()(props.activity);
get details(): string[] {
const details = [];
const changes = this.subjectParams.group_changes.split(",");
if (changes.includes("name") && this.subjectParams.old_group_name) {
details.push("{old_group_name} was renamed to {group}.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("visibility") && this.activity.object.visibility) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
switch (this.activity.object.visibility) {
case GroupVisibility.PRIVATE:
details.push("Visibility was set to private.");
break;
case GroupVisibility.PUBLIC:
details.push("Visibility was set to public.");
break;
default:
details.push("Visibility was set to an unknown value.");
break;
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
if (isAuthorCurrentActor) {
return "You created the group {group}.";
}
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("openness") && this.activity.object.openness) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
switch (this.activity.object.openness) {
case Openness.INVITE_ONLY:
details.push("The group can now only be joined with an invite.");
break;
case Openness.MODERATED:
details.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator."
);
break;
case Openness.OPEN:
details.push("The group can now be joined by anyone.");
break;
default:
details.push("Unknown value for the openness setting.");
break;
return "{profile} created the group {group}.";
case ActivityGroupSubject.GROUP_UPDATED:
if (isAuthorCurrentActor) {
return "You updated the group {group}.";
}
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("address") && this.activity.object.physicalAddress) {
details.push("The group's physical address was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("avatar") && this.activity.object.avatar) {
details.push("The group's avatar was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("banner") && this.activity.object.banner) {
details.push("The group's banner was changed.");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (changes.includes("summary") && this.activity.object.summary) {
details.push("The group's short description was changed.");
}
return details;
return "{profile} updated the group {group}.";
default:
return undefined;
}
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
return "is-success";
case ActivityGroupSubject.GROUP_UPDATED:
return "is-grey";
default:
return undefined;
}
});
const group = computed(() => props.activity.object as IGroup);
const details = computed((): string[] => {
const localDetails = [];
const changes = subjectParams.group_changes.split(",");
if (changes.includes("name") && subjectParams.old_group_name) {
localDetails.push("{old_group_name} was renamed to {group}.");
}
if (changes.includes("visibility") && group.value.visibility) {
switch (group.value.visibility) {
case GroupVisibility.PRIVATE:
localDetails.push("Visibility was set to private.");
break;
case GroupVisibility.PUBLIC:
localDetails.push("Visibility was set to public.");
break;
default:
localDetails.push("Visibility was set to an unknown value.");
break;
}
}
if (changes.includes("openness") && group.value.openness) {
switch (group.value.openness) {
case Openness.INVITE_ONLY:
localDetails.push("The group can now only be joined with an invite.");
break;
case Openness.MODERATED:
localDetails.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator."
);
break;
case Openness.OPEN:
localDetails.push("The group can now be joined by anyone.");
break;
default:
localDetails.push("Unknown value for the openness setting.");
break;
}
}
if (changes.includes("address") && group.value.physicalAddress) {
localDetails.push("The group's physical address was changed.");
}
if (changes.includes("avatar") && group.value.avatar) {
localDetails.push("The group's avatar was changed.");
}
if (changes.includes("banner") && group.value.banner) {
localDetails.push("The group's banner was changed.");
}
if (changes.includes("summary") && group.value.summary) {
localDetails.push("The group's short description was changed.");
}
return localDetails;
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,236 +1,232 @@
<template>
<div class="activity-item">
<b-icon :icon="icon" :type="iconColor" />
<o-icon :icon="icon" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<popover-actor-card
v-if="activity.object"
:actor="activity.object.actor"
:inline="true"
slot="member"
>
<b> {{ displayName(activity.object.actor) }}</b></popover-actor-card
>
<b slot="member" v-else>{{
subjectParams.member_actor_federated_username
}}</b>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b> {{ displayName(activity.author) }}</b></popover-actor-card
></i18n
<i18n-t :keypath="translation" tag="p">
<template #member>
<popover-actor-card
v-if="member"
:actor="member.actor"
:inline="true"
>
<b> {{ displayName(member.actor) }}</b></popover-actor-card
>
<b v-else>{{ subjectParams.member_actor_federated_username }}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b> {{ displayName(activity.author) }}</b></popover-actor-card
></template
></i18n-t
>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
<small class="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
import { displayName } from "@/types/actor";
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
export const MEMBER_ROLE_VALUE: Record<string, number> = {
[MemberRole.MEMBER]: 20,
[MemberRole.MODERATOR]: 50,
[MemberRole.ADMINISTRATOR]: 90,
[MemberRole.CREATOR]: 100,
};
</script>
<script lang="ts" setup>
import { displayName } from "@/types/actor";
import { ActivityMemberSubject, MemberRole } from "@/types/enums";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import { formatTimeString } from "@/filters/datetime";
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
useIsActivityObjectCurrentActor,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { IMember } from "@/types/actor/member.model";
@Component({
components: {
PopoverActorCard,
},
})
export default class MemberActivityItem extends mixins(ActivityMixin) {
displayName = displayName;
RouteName = RouteName;
ActivityMemberSubject = ActivityMemberSubject;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
if (this.isAuthorCurrentActor) {
return "You requested to join the group.";
}
return "{member} requested to join the group.";
case ActivityMemberSubject.MEMBER_INVITED:
if (this.isAuthorCurrentActor) {
return "You invited {member}.";
}
return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED:
if (this.isAuthorCurrentActor) {
return "You added the member {member}.";
}
return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (this.isAuthorCurrentActor) {
return "You approved {member}'s membership.";
}
if (this.isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}.";
}
return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED:
if (this.subjectParams.member_role && this.subjectParams.old_role) {
return this.roleUpdate;
}
if (this.isAuthorCurrentActor) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}.";
case ActivityMemberSubject.MEMBER_REMOVED:
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (this.isAuthorCurrentActor) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (this.isAuthorCurrentActor) {
return "You excluded member {member}.";
}
return "{profile} excluded member {member}.";
case ActivityMemberSubject.MEMBER_QUIT:
return "{profile} quit the group.";
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
return "{member} rejected the invitation to join the group.";
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
if (this.isAuthorCurrentActor) {
return "You accepted the invitation to join the group.";
}
return "{member} accepted the invitation to join the group.";
default:
return undefined;
}
}
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
get icon(): string {
switch (this.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "account-multiple-plus";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "account-multiple-minus";
case ActivityMemberSubject.MEMBER_UPDATED:
default:
return "account-multiple";
}
}
const subjectParams = useActivitySubjectParams()(props.activity);
const member = computed(() => props.activity.object as IMember);
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_JOINED:
case ActivityMemberSubject.MEMBER_APPROVED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "is-success";
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_UPDATED:
return "is-grey";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "is-danger";
default:
return undefined;
}
}
const isObjectMemberCurrentActor = useIsActivityObjectCurrentActor()(
props.activity
);
get roleUpdate(): string | undefined {
if (
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.member_role) &&
Object.keys(MEMBER_ROLE_VALUE).includes(this.subjectParams.old_role)
) {
if (
MEMBER_ROLE_VALUE[this.subjectParams.member_role] >
MEMBER_ROLE_VALUE[this.subjectParams.old_role]
) {
switch (this.subjectParams.member_role) {
case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) {
return "You promoted {member} to moderator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to moderator by {profile}.";
}
return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR:
if (this.isAuthorCurrentActor) {
return "You promoted {member} to administrator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to administrator by {profile}.";
}
return "{profile} promoted {member} to administrator.";
default:
if (this.isAuthorCurrentActor) {
return "You promoted the member {member} to an unknown role.";
}
if (this.isObjectMemberCurrentActor) {
return "You were promoted to an unknown role by {profile}.";
}
return "{profile} promoted {member} to an unknown role.";
}
} else {
switch (this.subjectParams.member_role) {
case MemberRole.MODERATOR:
if (this.isAuthorCurrentActor) {
return "You demoted {member} to moderator.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to moderator by {profile}.";
}
return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER:
if (this.isAuthorCurrentActor) {
return "You demoted {member} to simple member.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to simple member by {profile}.";
}
return "{profile} demoted {member} to simple member.";
default:
if (this.isAuthorCurrentActor) {
return "You demoted the member {member} to an unknown role.";
}
if (this.isObjectMemberCurrentActor) {
return "You were demoted to an unknown role by {profile}.";
}
return "{profile} demoted {member} to an unknown role.";
}
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
if (isAuthorCurrentActor) {
return "You requested to join the group.";
}
} else {
if (this.isAuthorCurrentActor) {
return "{member} requested to join the group.";
case ActivityMemberSubject.MEMBER_INVITED:
if (isAuthorCurrentActor) {
return "You invited {member}.";
}
return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED:
if (isAuthorCurrentActor) {
return "You added the member {member}.";
}
return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (isAuthorCurrentActor) {
return "You approved {member}'s membership.";
}
if (isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}.";
}
return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED:
if (subjectParams.member_role && subjectParams.old_role) {
return roleUpdate.value;
}
if (isAuthorCurrentActor) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}";
}
return "{profile} updated the member {member}.";
case ActivityMemberSubject.MEMBER_REMOVED:
if (subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (isAuthorCurrentActor) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (isAuthorCurrentActor) {
return "You excluded member {member}.";
}
return "{profile} excluded member {member}.";
case ActivityMemberSubject.MEMBER_QUIT:
return "{profile} quit the group.";
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
return "{member} rejected the invitation to join the group.";
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
if (isAuthorCurrentActor) {
return "You accepted the invitation to join the group.";
}
return "{member} accepted the invitation to join the group.";
default:
return undefined;
}
});
get isObjectMemberCurrentActor(): boolean {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.activity?.object?.actor?.id === this.currentActor?.id &&
this.currentActor?.id !== undefined
);
const icon = computed((): string => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "account-multiple-plus";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "account-multiple-minus";
case ActivityMemberSubject.MEMBER_UPDATED:
default:
return "account-multiple";
}
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_ADDED:
case ActivityMemberSubject.MEMBER_INVITED:
case ActivityMemberSubject.MEMBER_JOINED:
case ActivityMemberSubject.MEMBER_APPROVED:
case ActivityMemberSubject.MEMBER_ACCEPTED_INVITATION:
return "is-success";
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_UPDATED:
return "is-grey";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "is-danger";
default:
return undefined;
}
});
const roleUpdate = computed((): string | undefined => {
if (
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.member_role) &&
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.old_role)
) {
if (
MEMBER_ROLE_VALUE[subjectParams.member_role] >
MEMBER_ROLE_VALUE[subjectParams.old_role]
) {
switch (subjectParams.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor) {
return "You promoted {member} to moderator.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to moderator by {profile}.";
}
return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR:
if (isAuthorCurrentActor) {
return "You promoted {member} to administrator.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to administrator by {profile}.";
}
return "{profile} promoted {member} to administrator.";
default:
if (isAuthorCurrentActor) {
return "You promoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor) {
return "You were promoted to an unknown role by {profile}.";
}
return "{profile} promoted {member} to an unknown role.";
}
} else {
switch (subjectParams.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor) {
return "You demoted {member} to moderator.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to moderator by {profile}.";
}
return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER:
if (isAuthorCurrentActor) {
return "You demoted {member} to simple member.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to simple member by {profile}.";
}
return "{profile} demoted {member} to simple member.";
default:
if (isAuthorCurrentActor) {
return "You demoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor) {
return "You were demoted to an unknown role by {profile}.";
}
return "{profile} demoted {member} to an unknown role.";
}
}
} else {
if (isAuthorCurrentActor) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}";
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,92 +1,92 @@
<template>
<div class="activity-item">
<b-icon :icon="'bullhorn'" :type="iconColor" />
<o-icon :icon="'bullhorn'" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<router-link
v-if="activity.object"
slot="post"
:to="{
name: RouteName.POST,
params: { slug: subjectParams.post_slug },
}"
>{{ subjectParams.post_title }}</router-link
>
<b v-else slot="post">{{ subjectParams.post_title }}</b>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n
<i18n-t :keypath="translation" tag="p">
<template #post>
<router-link
v-if="activity.object"
:to="{
name: RouteName.POST,
params: { slug: subjectParams.post_slug },
}"
>{{ subjectParams.post_title }}</router-link
>
<b v-else>{{ subjectParams.post_title }}</b>
</template>
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></template
></i18n-t
>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
<small class="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityPostSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
import { formatTimeString } from "@/filters/datetime";
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
@Component({
components: {
PopoverActorCard,
},
})
export default class PostActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ActivityPostSubject = ActivityPostSubject;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityPostSubject.POST_CREATED:
if (this.isAuthorCurrentActor) {
return "You created the post {post}.";
}
return "The post {post} was created by {profile}.";
case ActivityPostSubject.POST_UPDATED:
if (this.isAuthorCurrentActor) {
return "You updated the post {post}.";
}
return "The post {post} was updated by {profile}.";
case ActivityPostSubject.POST_DELETED:
if (this.isAuthorCurrentActor) {
return "You deleted the post {post}.";
}
return "The post {post} was deleted by {profile}.";
default:
return undefined;
}
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
const subjectParams = useActivitySubjectParams()(props.activity);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED:
if (isAuthorCurrentActor) {
return "You created the post {post}.";
}
return "The post {post} was created by {profile}.";
case ActivityPostSubject.POST_UPDATED:
if (isAuthorCurrentActor) {
return "You updated the post {post}.";
}
return "The post {post} was updated by {profile}.";
case ActivityPostSubject.POST_DELETED:
if (isAuthorCurrentActor) {
return "You deleted the post {post}.";
}
return "The post {post} was deleted by {profile}.";
default:
return undefined;
}
});
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityPostSubject.POST_CREATED:
return "is-success";
case ActivityPostSubject.POST_UPDATED:
return "is-grey";
case ActivityPostSubject.POST_DELETED:
return "is-danger";
default:
return undefined;
}
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED:
return "is-success";
case ActivityPostSubject.POST_UPDATED:
return "is-grey";
case ActivityPostSubject.POST_DELETED:
return "is-danger";
default:
return undefined;
}
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,189 +1,193 @@
<template>
<div class="activity-item">
<b-icon :icon="'link'" :type="iconColor" />
<o-icon :icon="'link'" :type="iconColor" />
<div class="subject">
<i18n :path="translation" tag="p">
<router-link v-if="activity.object" slot="resource" :to="path">{{
subjectParams.resource_title
}}</router-link>
<b v-else slot="resource">{{ subjectParams.resource_title }}</b>
<router-link v-if="activity.object" slot="new_path" :to="path">{{
parentDirectory
}}</router-link>
<b v-else slot="new_path">{{ parentDirectory }}</b>
<router-link
v-if="activity.object && subjectParams.old_resource_title"
slot="old_resource_title"
:to="path"
>{{ subjectParams.old_resource_title }}</router-link
>
<b
v-else-if="subjectParams.old_resource_title"
slot="old_resource_title"
>{{ subjectParams.old_resource_title }}</b
>
<i18n-t :keypath="translation" tag="p">
<template #resource>
<router-link v-if="activity.object" :to="path">{{
subjectParams.resource_title
}}</router-link>
<b v-else>{{ subjectParams.resource_title }}</b>
</template>
<template #new_path>
<router-link v-if="activity.object" :to="path">{{
parentDirectory
}}</router-link>
<b v-else>{{ parentDirectory }}</b>
</template>
<template #old_resource_title>
<router-link
v-if="activity.object && subjectParams.old_resource_title"
:to="path"
>{{ subjectParams.old_resource_title }}</router-link
>
<b v-else-if="subjectParams.old_resource_title">{{
subjectParams.old_resource_title
}}</b>
</template>
<popover-actor-card
:actor="activity.author"
:inline="true"
slot="profile"
>
<b>
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n
<template #profile>
<popover-actor-card :actor="activity.author" :inline="true">
<b>
{{
$t("{'@'}{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></template
></i18n-t
>
<small class="has-text-grey-dark activity-date">{{
activity.insertedAt | formatTimeString
<small class="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityResourceSubject } from "@/types/enums";
import { Component } from "vue-property-decorator";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActivityMixin from "../../mixins/activity";
import { mixins } from "vue-class-component";
import { Location } from "vue-router";
import { formatTimeString } from "@/filters/datetime";
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { IResource } from "@/types/resource";
@Component({
components: {
PopoverActorCard,
},
})
export default class ResourceActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
const props = defineProps<{
activity: IActivity;
}>();
get translation(): string | undefined {
switch (this.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (this.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) {
return "You created the folder {resource}.";
const isAuthorCurrentActor = useIsActivityAuthorCurrentActor()(props.activity);
const subjectParams = useActivitySubjectParams()(props.activity);
const resource = computed(() => props.activity.object as IResource);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You created the folder {resource}.";
}
return "{profile} created the folder {resource}.";
}
if (isAuthorCurrentActor) {
return "You created the resource {resource}.";
}
return "{profile} created the resource {resource}.";
case ActivityResourceSubject.RESOURCE_MOVED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (parentDirectory.value === null) {
if (isAuthorCurrentActor) {
return "You moved the folder {resource} to the root folder.";
}
return "{profile} created the folder {resource}.";
return "{profile} moved the folder {resource} to the root folder.";
}
if (this.isAuthorCurrentActor) {
return "You created the resource {resource}.";
if (isAuthorCurrentActor) {
return "You moved the folder {resource} into {new_path}.";
}
return "{profile} created the resource {resource}.";
case ActivityResourceSubject.RESOURCE_MOVED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (this.activity?.object?.type === "folder") {
if (this.parentDirectory === null) {
if (this.isAuthorCurrentActor) {
return "You moved the folder {resource} to the root folder.";
}
return "{profile} moved the folder {resource} to the root folder.";
}
if (this.isAuthorCurrentActor) {
return "You moved the folder {resource} into {new_path}.";
}
return "{profile} moved the folder {resource} into {new_path}.";
return "{profile} moved the folder {resource} into {new_path}.";
}
if (parentDirectory.value === null) {
if (isAuthorCurrentActor) {
return "You moved the resource {resource} to the root folder.";
}
if (this.parentDirectory === null) {
if (this.isAuthorCurrentActor) {
return "You moved the resource {resource} to the root folder.";
}
return "{profile} moved the resource {resource} to the root folder.";
return "{profile} moved the resource {resource} to the root folder.";
}
if (isAuthorCurrentActor) {
return "You moved the resource {resource} into {new_path}.";
}
return "{profile} moved the resource {resource} into {new_path}.";
case ActivityResourceSubject.RESOURCE_UPDATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You renamed the folder from {old_resource_title} to {resource}.";
}
if (this.isAuthorCurrentActor) {
return "You moved the resource {resource} into {new_path}.";
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
}
if (isAuthorCurrentActor) {
return "You renamed the resource from {old_resource_title} to {resource}.";
}
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
case ActivityResourceSubject.RESOURCE_DELETED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (props.activity?.object?.type === "folder") {
if (isAuthorCurrentActor) {
return "You deleted the folder {resource}.";
}
return "{profile} moved the resource {resource} into {new_path}.";
case ActivityResourceSubject.RESOURCE_UPDATED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (this.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) {
return "You renamed the folder from {old_resource_title} to {resource}.";
}
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
}
if (this.isAuthorCurrentActor) {
return "You renamed the resource from {old_resource_title} to {resource}.";
}
return "{profile} renamed the resource from {old_resource_title} to {resource}.";
case ActivityResourceSubject.RESOURCE_DELETED:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (this.activity?.object?.type === "folder") {
if (this.isAuthorCurrentActor) {
return "You deleted the folder {resource}.";
}
return "{profile} deleted the folder {resource}.";
}
if (this.isAuthorCurrentActor) {
return "You deleted the resource {resource}.";
}
return "{profile} deleted the resource {resource}.";
default:
return undefined;
}
return "{profile} deleted the folder {resource}.";
}
if (isAuthorCurrentActor) {
return "You deleted the resource {resource}.";
}
return "{profile} deleted the resource {resource}.";
default:
return undefined;
}
});
get iconColor(): string | undefined {
switch (this.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
return "is-success";
case ActivityResourceSubject.RESOURCE_MOVED:
case ActivityResourceSubject.RESOURCE_UPDATED:
return "is-grey";
case ActivityResourceSubject.RESOURCE_DELETED:
return "is-danger";
default:
return undefined;
}
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
return "is-success";
case ActivityResourceSubject.RESOURCE_MOVED:
case ActivityResourceSubject.RESOURCE_UPDATED:
return "is-grey";
case ActivityResourceSubject.RESOURCE_DELETED:
return "is-danger";
default:
return undefined;
}
});
get path(): Location {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const path = this.parentPath(this.activity?.object?.path);
if (path === "") {
return {
name: RouteName.RESOURCE_FOLDER_ROOT,
params: {
preferredUsername: usernameWithDomain(this.activity.group),
},
};
}
const path = computed(() => {
const localPath = parentPath(resource.value?.path);
if (localPath === "") {
return {
name: RouteName.RESOURCE_FOLDER,
name: RouteName.RESOURCE_FOLDER_ROOT,
params: {
path,
preferredUsername: usernameWithDomain(this.activity.group),
preferredUsername: usernameWithDomain(props.activity.group),
},
};
}
return {
name: RouteName.RESOURCE_FOLDER,
params: {
path: localPath,
preferredUsername: usernameWithDomain(props.activity.group),
},
};
});
get parentDirectory(): string | undefined | null {
if (this.subjectParams.resource_path) {
const parentPath = this.parentPath(this.subjectParams.resource_path);
const directory = parentPath.split("/");
const res = directory.pop();
res === "" ? null : res;
}
return null;
}
const parentPath = (parent: string | undefined): string | undefined => {
if (!parent) return undefined;
const localPath = parent.split("/");
localPath.pop();
return localPath.join("/").replace(/^\//, "");
};
parentPath(parent: string): string {
const path = parent.split("/");
path.pop();
return path.join("/").replace(/^\//, "");
const parentDirectory = computed((): string | undefined | null => {
if (subjectParams.resource_path) {
const parentPathResult = parentPath(subjectParams.resource_path);
const directory = parentPathResult?.split("/");
const res = directory?.pop();
res === "" ? null : res;
}
}
return null;
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";

View File

@@ -1,13 +1,13 @@
<template>
<div class="activity-item">
<span>
<b-skeleton circle width="32px" height="32px"></b-skeleton>
<o-skeleton circle width="32px" height="32px"></o-skeleton>
</span>
<div class="subject">
<div class="content">
<div class="prose dark:prose-invert">
<p>
<b-skeleton active></b-skeleton>
<b-skeleton active class="datetime"></b-skeleton>
<o-skeleton active></o-skeleton>
<o-skeleton active class="datetime"></o-skeleton>
</p>
</div>
</div>

View File

@@ -5,7 +5,6 @@
height: 2em;
box-sizing: border-box;
border-radius: 50%;
background: #fff;
border: 2px solid;
z-index: 2;
flex-shrink: 0;

View File

@@ -0,0 +1,31 @@
<template>
<Story>
<Variant title="Basic">
<AddressInfo :address="address" />
</Variant>
<Variant title="Basic with timezone">
<AddressInfo
:address="address"
:show-timezone="true"
:user-timezone="'Europe/Berlin'"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { reactive } from "vue";
import AddressInfo from "./AddressInfo.vue";
const address = reactive<IAddress>({
description: "Locaux Motiv",
street: "10 Rue Jangot",
locality: "Lyon",
postalCode: "69007",
region: "Auvergne Rhône-Alpes",
country: "France",
type: "",
timezone: "Europe/Dublin",
});
</script>

View File

@@ -1,22 +1,22 @@
<template>
<address dir="auto">
<b-icon
<o-icon
v-if="showIcon"
:icon="address.poiInfos.poiIcon.icon"
:icon="poiInfos?.poiIcon.icon"
size="is-medium"
class="icon"
/>
<p>
<span
class="addressDescription"
:title="address.poiInfos.name"
v-if="address.poiInfos.name"
:title="poiInfos.name"
v-if="poiInfos?.name"
>
{{ address.poiInfos.name }}
{{ poiInfos.name }}
</span>
<br v-if="address.poiInfos.name" />
<span class="has-text-grey-dark">
{{ address.poiInfos.alternativeName }}
<br v-if="poiInfos?.name" />
<span>
{{ poiInfos?.alternativeName }}
</span>
<br />
<small
@@ -25,7 +25,6 @@
longShortTimezoneNamesDifferent &&
timezoneLongNameValid
"
class="has-text-grey-dark"
>
🌐
{{
@@ -35,72 +34,75 @@
})
}}
</small>
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark">
<small v-else-if="userTimezoneDifferent" class="">
🌐 {{ timezoneShortName }}
</small>
</p>
</address>
</template>
<script lang="ts">
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { addressToPoiInfos, IAddress } from "@/types/address.model";
import { computed } from "vue";
@Component
export default class AddressInfo extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
address!: IAddress;
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean;
@Prop({ required: false, default: false, type: Boolean })
showTimezone!: boolean;
@Prop({ required: false, type: String }) userTimezone!: string;
get userTimezoneDifferent(): boolean {
return (
this.userTimezone != undefined &&
this.address.timezone != undefined &&
this.userTimezone !== this.address.timezone
);
const props = withDefaults(
defineProps<{
address: IAddress;
showIcon?: boolean;
showTimezone?: boolean;
userTimezone?: string;
}>(),
{
showIcon: false,
showTimezone: false,
}
);
get longShortTimezoneNamesDifferent(): boolean {
return (
this.timezoneLongName != undefined &&
this.timezoneShortName != undefined &&
this.timezoneLongName !== this.timezoneShortName
);
}
const poiInfos = computed(() => addressToPoiInfos(props.address));
get timezoneLongName(): string | undefined {
return this.timezoneName("long");
}
const userTimezoneDifferent = computed((): boolean => {
return (
props.userTimezone != undefined &&
props.address.timezone != undefined &&
props.userTimezone !== props.address.timezone
);
});
get timezoneShortName(): string | undefined {
return this.timezoneName("short");
}
const longShortTimezoneNamesDifferent = computed((): boolean => {
return (
timezoneLongName.value != undefined &&
timezoneShortName.value != undefined &&
timezoneLongName.value !== timezoneShortName.value
);
});
get timezoneLongNameValid(): boolean {
return (
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/)
);
}
const timezoneLongName = computed((): string | undefined => {
return timezoneName("long");
});
private timezoneName(format: "long" | "short"): string | undefined {
return this.extractTimezone(
new Intl.DateTimeFormat(undefined, {
timeZoneName: format,
timeZone: this.address.timezone,
}).formatToParts()
);
}
const timezoneShortName = computed((): string | undefined => {
return timezoneName("short");
});
private extractTimezone(
parts: Intl.DateTimeFormatPart[]
): string | undefined {
return parts.find((part) => part.type === "timeZoneName")?.value;
}
}
const timezoneLongNameValid = computed((): boolean => {
return (
timezoneLongName.value != undefined && !timezoneLongName.value.match(/UTC/)
);
});
const timezoneName = (format: "long" | "short"): string | undefined => {
return extractTimezone(
new Intl.DateTimeFormat(undefined, {
timeZoneName: format,
timeZone: props.address.timezone,
}).formatToParts()
);
};
const extractTimezone = (
parts: Intl.DateTimeFormatPart[]
): string | undefined => {
return parts.find((part) => part.type === "timeZoneName")?.value;
};
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;

View File

@@ -0,0 +1,27 @@
<template>
<Story>
<Variant title="with locality">
<InlineAddress :physicalAddress="address" />
</Variant>
<Variant title="without locality">
<InlineAddress :physicalAddress="{ ...address, locality: null }" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { reactive } from "vue";
import InlineAddress from "./InlineAddress.vue";
const address = reactive<IAddress>({
description: "Locaux Motiv",
street: "10 Rue Jangot",
locality: "Lyon",
postalCode: "69007",
region: "Auvergne Rhône-Alpes",
country: "France",
type: "",
timezone: "Europe/Dublin",
});
</script>

View File

@@ -1,13 +1,14 @@
<template>
<div
class="truncate"
class="truncate flex gap-1"
dir="auto"
:title="
isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}`
: physicalAddress.description
"
>
<b-icon icon="map-marker" />
<MapMarker />
<span v-if="physicalAddress.locality">
{{ physicalAddress.locality }}
</span>
@@ -16,21 +17,19 @@
</span>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Prop, Vue, Component } from "vue-property-decorator";
import MapMarker from "vue-material-design-icons/MapMarker.vue";
import { computed } from "vue";
@Component
export default class InlineAddress extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
physicalAddress!: IAddress;
const props = defineProps<{
physicalAddress: IAddress;
}>();
get isDescriptionDifferentFromLocality(): boolean {
return (
this.physicalAddress?.description !== this.physicalAddress?.locality &&
this.physicalAddress?.description !== undefined
);
}
}
const isDescriptionDifferentFromLocality = computed<boolean>(() => {
return (
props.physicalAddress?.description !== props.physicalAddress?.locality &&
props.physicalAddress?.description !== undefined
);
});
</script>

View File

@@ -0,0 +1,29 @@
<template>
<Story>
<Variant title="Basic">
<section class="flex flex-wrap gap-3 md:gap-5">
<CategoryCard :category="category" />
</section>
</Variant>
<Variant title="Details">
<section class="flex flex-wrap gap-3 md:gap-5">
<CategoryCard
:category="{ ...category, key: 'OUTDOORS_ADVENTURE' }"
:with-details="true"
/>
</section>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { CategoryStatsModel } from "@/types/stats.model";
import { reactive } from "vue";
import CategoryCard from "./CategoryCard.vue";
const category = reactive<CategoryStatsModel>({
key: "PHOTOGRAPHY",
number: 5,
label: "Hello",
});
</script>

View File

@@ -0,0 +1,78 @@
<template>
<router-link
:to="{
name: 'SEARCH',
query: {
eventCategory: category.key,
contentType: 'EVENTS',
radius: undefined,
},
}"
class="max-w-xs rounded-lg overflow-hidden bg-center bg-no-repeat bg-cover shadow-lg relative group"
>
<picture
v-if="categoriesWithPictures.includes(category.key)"
class="brightness-50"
>
<source
:srcset="`/img/categories/${category.key.toLowerCase()}.jpg 2x, /img/categories/${category.key.toLowerCase()}.jpg`"
media="(min-width: 1000px)"
/>
<source
:srcset="`/img/categories/${category.key.toLowerCase()}.jpg 2x, /img/categories/${category.key.toLowerCase()}-small.jpg`"
media="(min-width: 300px)"
/>
<img
class="w-full h-36 w-36 md:h-52 md:w-52 object-cover"
:src="`/img/categories/${category.key.toLowerCase()}.jpg`"
:srcset="`/img/categories/${category.key.toLowerCase()}-small.jpg `"
alt=""
/>
</picture>
<p
v-else
class="h-36 w-36 md:h-52 md:w-52 brightness-75"
:class="randomGradient()"
/>
<div class="px-3 py-1 absolute left-0 bottom-0">
<h2
class="group-hover:text-slate-200 font-semibold text-white tracking-tight text-xl mb-3"
>
{{ category.label }}
</h2>
</div>
<span
v-if="withDetails"
class="absolute z-10 inline-flex items-center px-3 py-1 text-xs font-semibold text-white bg-black rounded-full right-2 top-2"
>
{{
t(
"{count} events",
{
count: category.number.toString(),
},
category.number
)
}}
</span>
</router-link>
</template>
<script lang="ts" setup>
import { categoriesWithPictures } from "./constants";
import { randomGradient } from "../../utils/graphics";
import { CategoryStatsModel } from "../../types/stats.model";
import { useI18n } from "vue-i18n";
withDefaults(
defineProps<{
category: CategoryStatsModel;
withDetails?: boolean;
}>(),
{
withDetails: false,
}
);
const { t } = useI18n({ useScope: "global" });
</script>

View File

@@ -0,0 +1,296 @@
export const eventCategories = (t) => {
return [
{
id: "ARTS",
icon: "palette",
},
{
id: "BOOK_CLUBS",
icon: "favourite-book",
},
{
id: "BUSINESS",
},
{
id: "CAUSES",
},
{
id: "COMEDY",
},
{
id: "CRAFTS",
},
{
id: "FOOD_DRINK",
},
{
id: "HEALTH",
},
{
id: "MUSIC",
},
{
id: "AUTO_BOAT_AIR",
},
{
id: "COMMUNITY",
},
{
id: "FAMILY_EDUCATION",
},
{
id: "FASHION_BEAUTY",
},
{
id: "FILM_MEDIA",
},
{
id: "GAMES",
},
{
id: "LANGUAGE_CULTURE",
},
{
id: "LEARNING",
},
{
id: "LGBTQ",
},
{
id: "MOVEMENTS_POLITICS",
},
{
id: "NETWORKING",
},
{
id: "PARTY",
},
{
id: "PERFORMING_VISUAL_ARTS",
},
{
id: "PETS",
},
{
id: "PHOTOGRAPHY",
},
{
id: "OUTDOORS_ADVENTURE",
},
{
id: "SPIRITUALITY_RELIGION_BELIEFS",
},
{
id: "SCIENCE_TECH",
},
{
id: "SPORTS",
},
{
id: "THEATRE",
},
{
id: "MEETING",
},
];
};
export const eventCategoryLabel = (category: string, t): string | undefined => {
return eventCategories(t).find(({ id }) => id === category)?.label;
};
export type CategoryPictureLicencingElement = { name: string; url: string };
export type CategoryPictureLicencing = {
author: CategoryPictureLicencingElement;
source: CategoryPictureLicencingElement;
license?: CategoryPictureLicencingElement;
};
export const categoriesPicturesLicences: Record<
string,
CategoryPictureLicencing
> = {
THEATRE: {
author: {
name: "David Joyce",
url: "https://www.flickr.com/photos/deapeajay/",
},
source: {
name: "Flickr",
url: "https://www.flickr.com/photos/30815420@N00/2213310171",
},
license: {
name: "CC BY-SA",
url: "https://creativecommons.org/licenses/by-sa/2.0/",
},
},
SPORTS: {
author: {
name: "Md Mahdi",
url: "https://unsplash.com/@mahdi17",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/lQpFRPrepQ8",
},
},
MUSIC: {
author: {
name: "Michael Starkie",
url: "https://unsplash.com/@starkie_pics",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/YjtevpXFHQY",
},
},
ARTS: {
author: {
name: "RhondaK Native Florida Folk Artist",
url: "https://unsplash.com/@rhondak",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_Yc7OtfFn-0",
},
},
SPIRITUALITY_RELIGION_BELIEFS: {
author: {
name: "The Dancing Rain",
url: "https://unsplash.com/@thedancingrain",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_KPuV9qSSlU",
},
},
MOVEMENTS_POLITICS: {
author: {
name: "Kyle Fiori",
url: "https://unsplash.com/@navy99",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/moytQ7vzhAM",
},
},
PARTY: {
author: {
name: "Amy Shamblen",
url: "https://unsplash.com/@amyshamblen",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/pJ_DCj9KswI",
},
},
BUSINESS: {
author: {
name: "Simone Hutsch",
url: "https://unsplash.com/@heysupersimi",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/6-c8GV2MBmg",
},
},
FILM_MEDIA: {
author: {
name: "Dan Senior",
url: "https://unsplash.com/@dansenior",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/ENtn4fH8C3g",
},
},
PHOTOGRAPHY: {
author: {
name: "Nathyn Masters",
url: "https://unsplash.com/@nathynmasters",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/k3oSs0hWOPo",
},
},
HEALTH: {
author: {
name: "Derek Finch",
url: "https://unsplash.com/@drugwatcher",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/Gi8Q8IfpxdY",
},
},
GAMES: {
author: {
name: "Randy Fath",
url: "https://unsplash.com/@randyfath",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/_EoxKxrDL20",
},
},
OUTDOORS_ADVENTURE: {
author: {
name: "Davide Sacchet",
url: "https://unsplash.com/@davide_sak_",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/RYN-kov1lTY",
},
},
FOOD_DRINK: {
author: {
name: "sina piryae",
url: "https://unsplash.com/@sinapiryae",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/bBzjWthTqb8",
},
},
CRAFTS: {
author: {
name: "rocknwool",
url: "https://unsplash.com/@rocknwool",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/Jcb5O26G08A",
},
},
LGBTQ: {
author: {
name: "analuisa gamboa",
url: "https://unsplash.com/@anigmb",
},
source: {
name: "Unsplash",
url: "https://unsplash.com/photos/HsraPtCtRCM",
},
},
};
export const categoriesWithPictures = [
"SPORTS",
"THEATRE",
"MUSIC",
"ARTS",
"MOVEMENTS_POLITICS",
"SPIRITUALITY_RELIGION_BELIEFS",
"PARTY",
"BUSINESS",
"FILM_MEDIA",
"PHOTOGRAPHY",
"HEALTH",
"GAMES",
"OUTDOORS_ADVENTURE",
"FOOD_DRINK",
"CRAFTS",
"LGBTQ",
];

View File

@@ -0,0 +1,177 @@
<template>
<Story :setup-app="setupApp">
<Variant title="Basic">
<Comment
:comment="comment"
:event="event"
:currentActor="baseActor"
@create-comment="hstEvent('Create comment', $event)"
@delete-comment="hstEvent('Delete comment', $event)"
@report-comment="hstEvent('Report comment', $event)"
/>
</Variant>
<Variant title="Announcement">
<Comment
:comment="{ ...comment, isAnnouncement: true }"
:event="event"
:currentActor="baseActor"
@create-comment="hstEvent('Create comment', $event)"
@delete-comment="hstEvent('Delete comment', $event)"
@report-comment="hstEvent('Report comment', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import { IComment } from "@/types/comment.model";
import {
ActorType,
CommentModeration,
EventJoinOptions,
EventStatus,
EventVisibility,
} from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { reactive } from "vue";
import Comment from "./Comment.vue";
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
import { hstEvent } from "histoire/client";
function setupApp({ app }) {
app.use(FloatingVue);
}
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
id: "598",
};
const baseEvent: IEvent = {
uuid: "",
title: "A very interesting event",
description: "Things happen",
beginsOn: new Date(),
endsOn: new Date(),
physicalAddress: {
description: "Somewhere",
street: "",
locality: "",
region: "",
country: "",
type: "",
postalCode: "",
},
picture: {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
},
url: "",
local: true,
slug: "",
publishAt: new Date(),
status: EventStatus.CONFIRMED,
visibility: EventVisibility.PUBLIC,
joinOptions: EventJoinOptions.FREE,
draft: false,
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 0,
moderator: 0,
administrator: 0,
going: 0,
},
participants: { total: 0, elements: [] },
relatedEvents: [],
tags: [{ slug: "something", title: "Something" }],
attributedTo: undefined,
organizerActor: {
...baseActor,
name: "Hello",
avatar: {
...baseActorAvatar,
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
},
},
comments: [],
options: {
maximumAttendeeCapacity: 0,
remainingAttendeeCapacity: 0,
showRemainingAttendeeCapacity: false,
anonymousParticipation: false,
hideOrganizerWhenGroupEvent: false,
offers: [],
participationConditions: [],
attendees: [],
program: "",
commentModeration: CommentModeration.ALLOW_ALL,
showParticipationPrice: false,
showStartTime: false,
showEndTime: false,
timezone: null,
isOnline: false,
},
metadata: [],
contacts: [],
language: "en",
category: "hello",
};
const event = reactive<IEvent>(baseEvent);
const comment = reactive<IComment>({
text: "hello",
local: true,
actor: baseActor,
totalReplies: 5,
replies: [
{
text: "a reply!",
id: "90",
actor: baseActor,
updatedAt: new Date(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
{
text: "a reply to another reply!",
id: "92",
actor: baseActor,
updatedAt: new Date(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
],
isAnnouncement: false,
updatedAt: new Date(),
url: "http://somewhere.tld",
});
</script>

View File

@@ -2,347 +2,348 @@
<li
:class="{
reply: comment.inReplyToComment,
announcement: comment.isAnnouncement,
selected: commentSelected,
'bg-purple-2': comment.isAnnouncement,
'bg-violet-1': commentSelected,
'shadow-none': !rootComment,
}"
class="comment-element"
class="mbz-card p-2"
>
<article class="media" :id="commentId" dir="auto">
<popover-actor-card
:actor="comment.actor"
:inline="true"
v-if="comment.actor"
>
<figure
class="image is-32x32 media-left"
v-if="!comment.deletedAt && comment.actor.avatar"
>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else icon="account-circle" />
</popover-actor-card>
<div v-else class="media-left">
<figure
class="image is-32x32"
v-if="!comment.deletedAt && comment.actor.avatar"
>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
</div>
<div class="media-content">
<div class="content">
<span class="first-line" v-if="!comment.deletedAt" dir="auto">
<strong :class="{ organizer: commentFromOrganizer }">{{
comment.actor.name
}}</strong>
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small>
</span>
<a v-else class="comment-link" :href="commentURL">
<span>{{ $t("[deleted]") }}</span>
<article :id="commentId" dir="auto">
<div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1" v-if="actorComment">
<popover-actor-card
:actor="actorComment"
:inline="true"
v-if="!comment.deletedAt && actorComment.avatar"
>
<figure>
<img
class="rounded-xl"
:src="actorComment.avatar.url"
alt=""
width="24"
height="24"
/>
</figure>
</popover-actor-card>
<AccountCircle v-else />
<strong
v-if="!comment.deletedAt"
dir="auto"
:class="{ organizer: commentFromOrganizer }"
>{{ actorComment?.name }}</strong
>
</div>
<a v-else :href="commentURL">
<span>{{ t("[deleted]") }}</span>
</a>
<a class="comment-link" :href="commentURL">
<small>{{
<a :href="commentURL">
<small v-if="comment.updatedAt">{{
formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale,
locale: dateFnsLocale,
addSuffix: true,
})
}}</small>
</a>
<span class="icons" v-if="!comment.deletedAt">
<div v-if="!comment.deletedAt" class="flex">
<button
v-if="comment.actor.id === currentActor.id"
v-if="actorComment?.id === currentActor?.id"
@click="deleteComment"
>
<b-icon icon="delete" size="is-small" aria-hidden="true" />
<span class="visually-hidden">{{ $t("Delete") }}</span>
<Delete :size="16" />
<span class="sr-only">{{ t("Delete") }}</span>
</button>
<button @click="reportModal()">
<b-icon icon="alert" size="is-small" />
<span class="visually-hidden">{{ $t("Report") }}</span>
<button @click="reportModal">
<Alert :size="16" />
<span class="sr-only">{{ t("Report") }}</span>
</button>
</span>
<br />
<div
v-if="!comment.deletedAt"
v-html="comment.text"
dir="auto"
:lang="comment.language"
/>
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
<div class="load-replies" v-if="comment.totalReplies">
<p v-if="!showReplies" @click="fetchReplies">
<b-icon icon="chevron-down" class="reply-btn" />
<span class="reply-btn">{{
$tc("View a reply", comment.totalReplies, {
totalReplies: comment.totalReplies,
})
}}</span>
</p>
<p
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
>
<b-icon icon="chevron-up" class="reply-btn" />
<span class="reply-btn">{{ $t("Hide replies") }}</span>
</p>
</div>
</div>
<div
v-if="!comment.deletedAt"
v-html="comment.text"
dir="auto"
:lang="comment.language"
/>
<div v-else>{{ t("[This comment has been deleted]") }}</div>
<div class="" v-if="comment.totalReplies">
<p
v-if="!showReplies"
@click="showReplies = true"
class="flex cursor-pointer"
>
<ChevronDown />
<span>{{
t(
"View a reply",
{
totalReplies: comment.totalReplies,
},
comment.totalReplies
)
}}</span>
</p>
<p
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
class="flex cursor-pointer"
>
<ChevronUp />
<span>{{ t("Hide replies") }}</span>
</p>
</div>
<nav
class="reply-action level is-mobile"
v-if="
currentActor.id &&
currentActor?.id &&
event.options.commentModeration !== CommentModeration.CLOSED &&
!comment.deletedAt
"
@click="createReplyToComment()"
class="flex gap-1 cursor-pointer"
>
<div class="level-left">
<span
style="cursor: pointer"
class="level-item reply-btn"
@click="createReplyToComment()"
>
<span class="icon is-small">
<b-icon icon="reply" />
</span>
<span>{{ $t("Reply") }}</span>
</span>
</div>
<Reply />
<span>{{ t("Reply") }}</span>
</nav>
</div>
</article>
<form
class="reply"
@submit.prevent="replyToComment"
v-if="currentActor.id"
v-if="currentActor?.id"
v-show="replyTo"
>
<article class="media reply">
<figure class="media-left" v-if="currentActor.avatar">
<p class="image is-48x48">
<img :src="currentActor.avatar.url" alt="" />
</p>
<article class="flex gap-2">
<figure v-if="currentActor?.avatar" class="mt-4">
<img
:src="currentActor?.avatar.url"
alt=""
width="48"
height="48"
class="rounded-md"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<span class="first-line">
<strong>{{ currentActor.name }}</strong>
<small dir="ltr">@{{ currentActor.preferredUsername }}</small>
</span>
<br />
<span class="editor-line">
<editor
class="editor"
ref="commentEditor"
v-model="newComment.text"
mode="comment"
:aria-label="$t('Comment body')"
/>
<b-button
:disabled="newComment.text.trim().length === 0"
native-type="submit"
type="is-primary"
>{{ $t("Post a reply") }}</b-button
>
</span>
<AccountCircle v-else :size="48" />
<div class="flex-1">
<div class="flex gap-1 items-center">
<strong>{{ currentActor?.name }}</strong>
<small dir="ltr">@{{ currentActor?.preferredUsername }}</small>
</div>
<div class="flex flex-col gap-2">
<editor
ref="commentEditor"
v-model="newComment.text"
mode="comment"
:current-actor="currentActor"
:aria-label="t('Comment body')"
class="flex-1"
/>
<o-button
:disabled="newComment.text.trim().length === 0"
native-type="submit"
variant="primary"
class="self-end"
>{{ t("Post a reply") }}</o-button
>
</div>
</div>
</article>
</form>
<div class="replies">
<div class="left">
<div class="vertical-border" @click="showReplies = false" />
<div>
<div>
<div @click="showReplies = false" />
</div>
<transition-group
name="comment-replies"
v-if="showReplies"
class="comment-replies"
tag="ul"
class="flex flex-col gap-2"
>
<comment
class="reply"
<Comment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:event="event"
@create-comment="$emit('create-comment', $event)"
@delete-comment="$emit('delete-comment', $event)"
:currentActor="currentActor"
:rootComment="false"
@create-comment="emit('create-comment', $event)"
@delete-comment="emit('delete-comment', $event)"
@report-comment="emit('report-comment', $event)"
/>
</transition-group>
</div>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
<script lang="ts" setup>
import EditorComponent from "@/components/Editor.vue";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns";
import { CommentModeration } from "@/types/enums";
import { CommentModel, IComment } from "../../types/comment.model";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
import { IPerson } from "../../types/actor";
import { IEvent } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import {
computed,
defineAsyncComponent,
inject,
onMounted,
ref,
nextTick,
} from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import Alert from "vue-material-design-icons/Alert.vue";
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
import Reply from "vue-material-design-icons/Reply.vue";
@Component({
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
},
components: {
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
PopoverActorCard,
},
})
export default class Comment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
@Prop({ required: true, type: Object }) event!: IEvent;
const props = withDefaults(
defineProps<{
comment: IComment;
event: IEvent;
currentActor: IPerson;
rootComment?: boolean;
}>(),
{ rootComment: true }
);
// Hack because Vue only exports it's own interface.
// See https://github.com/kaorun343/vue-property-decorator/issues/257
@Ref() readonly commentEditor!: EditorComponent & {
replyToComment: (comment: IComment) => void;
focus: () => void;
};
const emit = defineEmits([
"create-comment",
"delete-comment",
"report-comment",
]);
currentActor!: IPerson;
const commentEditor = ref<typeof EditorComponent | null>(null);
newComment: IComment = new CommentModel();
// Hack because Vue only exports it's own interface.
// See https://github.com/kaorun343/vue-property-decorator/issues/257
// @Ref() readonly commentEditor!: EditorComponent & {
// replyToComment: (comment: IComment) => void;
// focus: () => void;
// };
replyTo = false;
const newComment = ref<IComment>(new CommentModel());
const replyTo = ref(false);
const showReplies = ref(false);
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
showReplies = false;
CommentModeration = CommentModeration;
usernameWithDomain = usernameWithDomain;
formatDistanceToNow = formatDistanceToNow;
async mounted(): Promise<void> {
const { hash } = this.$route;
if (hash.includes(`#comment-${this.comment.uuid}`)) {
this.fetchReplies();
}
onMounted(() => {
if (route?.hash.includes(`#comment-${props.comment.uuid}`)) {
showReplies.value = true;
}
});
async createReplyToComment(): Promise<void> {
if (this.replyTo) {
this.replyTo = false;
this.newComment = new CommentModel();
return;
}
this.replyTo = true;
if (this.comment.actor) {
this.commentEditor.replyToComment(this.comment.actor);
await this.$nextTick; // wait for the mention to be injected
this.commentEditor.focus();
}
const createReplyToComment = async (): Promise<void> => {
if (replyTo.value) {
replyTo.value = false;
newComment.value = new CommentModel();
return;
}
replyTo.value = true;
if (props.comment.actor) {
commentEditor.value?.replyToComment(props.comment.actor);
await nextTick(); // wait for the mention to be injected
commentEditor.value?.focus();
}
};
replyToComment(): void {
this.newComment.inReplyToComment = this.comment;
this.newComment.originComment = this.comment.originComment || this.comment;
this.newComment.actor = this.currentActor;
this.$emit("create-comment", this.newComment);
this.newComment = new CommentModel();
this.replyTo = false;
this.showReplies = true;
}
const replyToComment = (): void => {
newComment.value.inReplyToComment = props.comment;
newComment.value.originComment = props.comment.originComment ?? props.comment;
newComment.value.actor = props.currentActor;
console.log(newComment.value);
emit("create-comment", newComment.value);
newComment.value = new CommentModel();
replyTo.value = false;
showReplies.value = true;
};
deleteComment(): void {
this.$emit("delete-comment", this.comment);
this.showReplies = false;
}
const deleteComment = (): void => {
emit("delete-comment", props.comment);
showReplies.value = false;
};
fetchReplies(): void {
this.showReplies = true;
}
const commentSelected = computed((): boolean => {
return `#${commentId.value}` === route?.hash;
});
get commentSelected(): boolean {
return `#${this.commentId}` === this.$route.hash;
}
const commentFromOrganizer = computed((): boolean => {
const organizerId =
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && props.comment?.actor?.id === organizerId;
});
get commentFromOrganizer(): boolean {
const organizerId =
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
return organizerId !== undefined && this.comment?.actor?.id === organizerId;
}
const commentId = computed((): string => {
if (props.comment.originComment)
return `comment-${props.comment.originComment.uuid}-${props.comment.uuid}`;
return `comment-${props.comment.uuid}`;
});
get commentId(): string {
if (this.comment.originComment)
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
return `comment-${this.comment.uuid}`;
}
const commentURL = computed((): string => {
if (!props.comment.local && props.comment.url) return props.comment.url;
return `#${commentId.value}`;
});
get commentURL(): string {
if (!this.comment.local && this.comment.url) return this.comment.url;
return `#${this.commentId}`;
}
const reportModal = (): void => {
if (!props.comment.actor) return;
emit("report-comment", props.comment);
// this.$buefy.modal.open({
// component: ReportModal,
// props: {
// title: t("Report this comment"),
// comment: props.comment,
// onConfirm: reportComment,
// outsideDomain: props.comment.actor?.domain,
// },
// // https://github.com/buefy/buefy/pull/3589
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// closeButtonAriaLabel: this.t("Close"),
// });
};
reportModal(): void {
if (!this.comment.actor) return;
this.$buefy.modal.open({
parent: this,
component: ReportModal,
props: {
title: this.$t("Report this comment"),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
},
// https://github.com/buefy/buefy/pull/3589
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
closeButtonAriaLabel: this.$t("Close"),
});
}
// const reportComment = async (
// content: string,
// forward: boolean
// ): Promise<void> => {
// try {
// if (!props.comment.actor) return;
async reportComment(content: string, forward: boolean): Promise<void> {
try {
if (!this.comment.actor) return;
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reportedId: this.comment.actor.id,
commentsIds: [this.comment.id],
content,
forward,
},
});
this.$buefy.notification.open({
message: this.$t("Comment from @{username} reported", {
username: this.comment.actor.preferredUsername,
}) as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
}
// const { onError, onDone } = useMutation(CREATE_REPORT, () => ({
// variables: {
// eventId: props.event.id,
// reportedId: props.comment.actor?.id,
// commentsIds: [props.comment.id],
// content,
// forward,
// },
// }));
// // this.$buefy.notification.open({
// // message: this.t("Comment from @{username} reported", {
// // username: this.comment.actor.preferredUsername,
// // }) as string,
// // type: "is-success",
// // position: "is-bottom-right",
// // duration: 5000,
// // });
// } catch (e: any) {
// if (e.message) {
// // Snackbar.open({
// // message: e.message,
// // type: "is-danger",
// // position: "is-bottom",
// // });
// }
// }
// };
const actorComment = computed(() => props.comment.actor);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@@ -364,9 +365,9 @@ form.reply {
padding: 0 6px;
}
& > small {
@include margin-left(0.3rem);
}
// & > small {
// @include margin-left(0.3rem);
// }
}
.editor-line {
@@ -375,15 +376,15 @@ form.reply {
.editor {
flex: 1;
@include padding-right(10px);
// @include padding-right(10px);
margin-bottom: 0;
}
}
a.comment-link {
text-decoration: none;
@include margin-left(5px);
color: $text;
// @include margin-left(5px);
color: text;
&:hover {
text-decoration: underline;
}
@@ -425,9 +426,9 @@ a.comment-link {
}
}
.media-left {
@include margin-right(5px);
}
// .media-left {
// @include margin-right(5px);
// }
}
.root-comment .replies {
@@ -437,7 +438,7 @@ a.comment-link {
display: flex;
flex-direction: column;
align-items: center;
@include margin-right(10px);
// @include margin-right(10px);
.vertical-border {
width: 3px;
@@ -528,9 +529,9 @@ article {
transform-origin: center top;
}
.reply-action .icon {
@include padding-right(0.4rem);
}
// .reply-action .icon {
// @include padding-right(0.4rem);
// }
.visually-hidden {
display: none;

View File

@@ -1,69 +1,62 @@
<template>
<div>
<form
class="new-comment"
class=""
v-if="isAbleToComment"
@submit.prevent="createCommentForEvent(newComment)"
@keyup.ctrl.enter="createCommentForEvent(newComment)"
>
<b-notification
<o-notification
v-if="isEventOrganiser && !areCommentsClosed"
:closable="false"
>{{ $t("Comments are closed for everybody else.") }}</b-notification
>{{ t("Comments are closed for everybody else.") }}</o-notification
>
<article class="media">
<figure class="media-left" v-if="newComment.actor">
<article class="flex flex-wrap items-start gap-2">
<figure class="" v-if="newComment.actor">
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
</figure>
<div class="media-content">
<div class="field">
<div class="field">
<p class="control">
<editor
ref="commenteditor"
mode="comment"
v-model="newComment.text"
:aria-label="$t('Comment body')"
/>
</p>
<p class="help is-danger" v-if="emptyCommentError">
{{ $t("Comment text can't be empty") }}
<div class="flex-1">
<div class="flex flex-col gap-2">
<div class="editor-wrapper">
<Editor
ref="commenteditor"
v-if="currentActor"
:currentActor="currentActor"
mode="comment"
v-model="newComment.text"
:aria-label="t('Comment body')"
/>
<p class="" v-if="emptyCommentError">
{{ t("Comment text can't be empty") }}
</p>
</div>
<div class="field notify-participants" v-if="isEventOrganiser">
<b-switch
<div class="" v-if="isEventOrganiser">
<o-switch
aria-labelledby="notify-participants-toggle"
v-model="newComment.isAnnouncement"
>{{ $t("Notify participants") }}</b-switch
>{{ t("Notify participants") }}</o-switch
>
</div>
</div>
</div>
<div class="send-comment">
<b-button
native-type="submit"
type="is-primary"
class="comment-button-submit"
icon-left="send"
>{{ $t("Send") }}</b-button
>
<div class="">
<o-button native-type="submit" variant="primary" icon-left="send">{{
t("Send")
}}</o-button>
</div>
</article>
</form>
<b-notification v-else-if="isConnected" :closable="false">{{
$t("The organiser has chosen to close comments.")
}}</b-notification>
<p
v-if="$apollo.queries.comments.loading"
class="loading has-text-centered"
>
{{ $t("Loading comments…") }}
<o-notification v-else-if="isConnected" :closable="false">{{
t("The organiser has chosen to close comments.")
}}</o-notification>
<p v-if="commentsLoading" class="text-center">
{{ t("Loading comments") }}
</p>
<transition-group tag="div" name="comment-empty-list" v-else>
<transition-group
key="list"
name="comment-list"
v-if="filteredOrderedComments.length"
v-if="filteredOrderedComments.length && currentActor"
class="comment-list"
tag="ul"
>
@@ -71,21 +64,26 @@
class="root-comment"
:comment="comment"
:event="event"
:currentActor="currentActor"
v-for="comment in filteredOrderedComments"
:key="comment.id"
@create-comment="createCommentForEvent"
@delete-comment="deleteComment"
@delete-comment="
deleteComment({
commentId: comment.id as string,
originCommentId: comment.originComment?.id,
})
"
/>
</transition-group>
<empty-content v-else icon="comment" key="no-comments" :inline="true">
<span>{{ $t("No comments yet") }}</span>
<span>{{ t("No comments yet") }}</span>
</empty-content>
</transition-group>
</div>
</template>
<script lang="ts">
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import Comment from "@/components/Comment/Comment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { CommentModeration } from "@/types/enums";
@@ -95,328 +93,338 @@ import {
DELETE_COMMENT,
COMMENTS_THREADS_WITH_REPLIES,
} from "../../graphql/comment";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { IEvent } from "../../types/event.model";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
import { IPerson } from "@/types/actor";
import { AbsintheGraphQLError } from "@/types/errors.model";
import { useI18n } from "vue-i18n";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
comments: {
const { currentActor } = useCurrentActorClient();
const { result: commentsResult, loading: commentsLoading } = useQuery<{
event: Pick<IEvent, "id" | "uuid" | "comments">;
}>(
COMMENTS_THREADS_WITH_REPLIES,
() => ({ eventUUID: props.event?.uuid }),
() => ({ enabled: props.event?.uuid !== undefined })
);
const comments = computed(() => commentsResult.value?.event.comments ?? []);
const props = defineProps<{
event: IEvent;
newComment?: IComment;
}>();
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const newComment = ref<IComment>(props.newComment ?? new CommentModel());
const emptyCommentError = ref(false);
const { t } = useI18n({ useScope: "global" });
watch(currentActor, () => {
newComment.value.actor = currentActor.value as IPerson;
});
watch(newComment, (newCommentUpdated: IComment) => {
if (emptyCommentError.value) {
emptyCommentError.value = ["", "<p></p>"].includes(newCommentUpdated.text);
}
});
const {
mutate: createCommentForEventMutation,
onDone: createCommentForEventMutationDone,
onError: createCommentForEventMutationError,
} = useMutation<
{ createComment: IComment },
{
eventId: string;
text: string;
inReplyToCommentId?: string;
isAnnouncement?: boolean;
originCommentId?: string | undefined;
}
>(CREATE_COMMENT_FROM_EVENT, () => ({
update: (
store: ApolloCache<InMemoryCache>,
{ data }: FetchResult,
{ variables }
) => {
if (data == null) return;
// comments are attached to the event, so we can pass it to replies later
const newCommentLocal = { ...data.createComment, event: props.event };
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables() {
return {
eventUUID: this.event.uuid,
};
variables: {
eventUUID: props.event?.uuid,
},
update: (data) => data.event.comments,
skip() {
return !this.event.uuid;
});
if (!commentThreadsData) return;
const { event } = commentThreadsData;
const oldComments = [...event.comments];
// if it's no a root comment, we first need to find
// existing replies and add the new reply to it
if (variables?.originCommentId !== undefined) {
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === variables.originCommentId
);
const parentComment = oldComments[parentCommentIndex];
// replace the root comment with has the updated list of replies in the thread list
oldComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: [...parentComment.replies, newCommentLocal],
});
} else {
// otherwise it's simply a new thread and we add it to the list
oldComments.push(newCommentLocal);
}
// finally we save the thread list
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
data: {
event: {
...event,
comments: oldComments,
},
},
variables: {
eventUUID: props.event?.uuid,
},
});
},
}));
createCommentForEventMutationDone(() => {
// and reset the new comment field
newComment.value = new CommentModel();
});
const notifier = inject<Notifier>("notifier");
createCommentForEventMutationError((errors) => {
console.error(errors);
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
const error = errors.graphQLErrors[0] as AbsintheGraphQLError;
if (error.field !== "text" && error.message[0] !== "can't be blank") {
notifier?.error(error.message);
}
}
});
const createCommentForEvent = (comment: IComment) => {
emptyCommentError.value = ["", "<p></p>"].includes(comment.text);
if (emptyCommentError.value) return;
if (!comment.actor) return;
if (!props.event?.id) return;
createCommentForEventMutation({
eventId: props.event?.id,
text: comment.text,
inReplyToCommentId: comment.inReplyToComment?.id,
isAnnouncement: comment.isAnnouncement,
originCommentId: comment.originComment?.id,
});
};
const { mutate: deleteComment, onError: deleteCommentMutationError } =
useMutation<
{ deleteComment: { id: string } },
{ commentId: string; originCommentId?: string }
>(DELETE_COMMENT, () => ({
update: (
store: ApolloCache<InMemoryCache>,
{ data }: FetchResult,
{ variables }
) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: props.event?.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
let updatedComments: IComment[] = [...event.comments];
if (variables?.originCommentId) {
// we have deleted a reply to a thread
const parentCommentIndex = updatedComments.findIndex(
(oldComment) => oldComment.id === variables.originCommentId
);
const parentComment = updatedComments[parentCommentIndex];
const updatedReplies = parentComment.replies.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
updatedComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: updatedReplies,
totalReplies: parentComment.totalReplies - 1,
});
console.log("updatedComments", updatedComments);
} else {
// we have deleted a thread itself
updatedComments = updatedComments.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
}
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: props.event?.uuid,
},
data: {
event: {
...event,
comments: updatedComments,
},
},
});
},
},
components: {
Comment,
IdentityPickerWrapper,
EmptyContent,
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class CommentTree extends Vue {
@Prop({ required: false, type: Object }) event!: IEvent;
}));
newComment: IComment = new CommentModel();
currentActor!: IPerson;
comments: IComment[] = [];
CommentModeration = CommentModeration;
emptyCommentError = false;
@Watch("currentActor")
watchCurrentActor(currentActor: IPerson): void {
this.newComment.actor = currentActor;
deleteCommentMutationError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
@Watch("newComment", { deep: true })
resetEmptyCommentError(newComment: IComment): void {
if (this.emptyCommentError) {
this.emptyCommentError = ["", "<p></p>"].includes(newComment.text);
}
}
async createCommentForEvent(comment: IComment): Promise<void> {
this.emptyCommentError = ["", "<p></p>"].includes(comment.text);
if (this.emptyCommentError) return;
try {
if (!comment.actor) return;
await this.$apollo.mutate({
mutation: CREATE_COMMENT_FROM_EVENT,
variables: {
eventId: this.event.id,
text: comment.text,
inReplyToCommentId: comment.inReplyToComment
? comment.inReplyToComment.id
: null,
isAnnouncement: comment.isAnnouncement,
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
// comments are attached to the event, so we can pass it to replies later
const newComment = { ...data.createComment, event: this.event };
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentThreadsData) return;
const { event } = commentThreadsData;
const oldComments = [...event.comments];
// if it's no a root comment, we first need to find
// existing replies and add the new reply to it
if (comment.originComment !== undefined) {
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
// replace the root comment with has the updated list of replies in the thread list
oldComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: [...parentComment.replies, newComment],
});
} else {
// otherwise it's simply a new thread and we add it to the list
oldComments.push(newComment);
}
// finally we save the thread list
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
data: {
event: {
...event,
comments: oldComments,
},
},
variables: {
eventUUID: this.event.uuid,
},
});
},
});
// and reset the new comment field
this.newComment = new CommentModel();
} catch (errors: any) {
console.error(errors);
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
const error = errors.graphQLErrors[0];
if (error.field !== "text" && error.message[0] !== "can't be blank") {
this.$notifier.error(error.message);
}
const orderedComments = computed((): IComment[] => {
return comments.value
.filter((comment: IComment) => comment.inReplyToComment == null)
.sort((a: IComment, b: IComment) => {
if (a.isAnnouncement !== b.isAnnouncement) {
return (
(b.isAnnouncement === true ? 1 : 0) -
(a.isAnnouncement === true ? 1 : 0)
);
}
}
}
async deleteComment(comment: IComment): Promise<void> {
try {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
let updatedComments: IComment[] = [...event.comments];
if (comment.originComment) {
// we have deleted a reply to a thread
const { originComment } = comment;
const parentCommentIndex = updatedComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = updatedComments[parentCommentIndex];
const updatedReplies = parentComment.replies.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
updatedComments.splice(parentCommentIndex, 1, {
...parentComment,
replies: updatedReplies,
totalReplies: parentComment.totalReplies - 1,
});
console.log("updatedComments", updatedComments);
} else {
// we have deleted a thread itself
updatedComments = updatedComments.map((reply) => {
if (reply.id === deletedCommentId) {
return {
...reply,
deletedAt: new Date().toString(),
};
}
return reply;
});
}
store.writeQuery({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: this.event.uuid,
},
data: {
event: {
...event,
comments: updatedComments,
},
},
});
},
});
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
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()
);
}
}
}
return 0;
});
});
get orderedComments(): IComment[] {
return this.comments
.filter((comment) => comment.inReplyToComment == null)
.sort((a, b) => {
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()
);
}
return 0;
});
}
const filteredOrderedComments = computed((): IComment[] => {
return orderedComments.value.filter(
(comment) => !comment.deletedAt || comment.totalReplies > 0
);
});
get filteredOrderedComments(): IComment[] {
return this.orderedComments.filter(
(comment) => !comment.deletedAt || comment.totalReplies > 0
);
}
const isEventOrganiser = computed((): boolean => {
const organizerId =
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && currentActor.value?.id === organizerId;
});
get isEventOrganiser(): boolean {
const organizerId =
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
return organizerId !== undefined && this.currentActor?.id === organizerId;
}
const areCommentsClosed = computed((): boolean => {
return (
currentActor.value?.id !== undefined &&
props.event?.options.commentModeration !== CommentModeration.CLOSED
);
});
get areCommentsClosed(): boolean {
return (
this.currentActor.id !== undefined &&
this.event.options.commentModeration !== CommentModeration.CLOSED
);
const isAbleToComment = computed((): boolean => {
if (isConnected.value) {
return areCommentsClosed.value || isEventOrganiser.value;
}
return false;
});
get isAbleToComment(): boolean {
if (this.isConnected) {
return this.areCommentsClosed || this.isEventOrganiser;
}
return false;
}
get isConnected(): boolean {
return this.currentActor?.id != undefined;
}
}
const isConnected = computed((): boolean => {
return currentActor.value?.id != undefined;
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@import "~bulma/sass/utilities/mixins.sass";
form.new-comment {
padding-bottom: 1rem;
// @use "@/styles/_mixins" as *;
// // @import "node_modules/bulma/sass/utilities/mixins.sass";
// form.new-comment {
// padding-bottom: 1rem;
.media {
flex-wrap: wrap;
justify-content: center;
.media-left {
@include mobile {
@include margin-right(0.5rem);
@include margin-left(0.5rem);
}
}
// .media {
// flex-wrap: wrap;
// justify-content: center;
// // .media-left {
// // @include >mobile {
// // @include margin-right(0.5rem);
// // @include margin-left(0.5rem);
// // }
// // }
.media-content {
display: flex;
align-items: center;
align-content: center;
width: min-content;
// .media-content {
// display: flex;
// align-items: center;
// align-content: center;
// width: min-content;
.field {
flex: 1;
@include padding-right(10px);
margin-bottom: 0;
// .field {
// flex: 1;
// // @include padding-right(10px);
// margin-bottom: 0;
&.notify-participants {
margin-top: 0.5rem;
}
}
}
}
}
// &.notify-participants {
// margin-top: 0.5rem;
// }
// }
// }
// }
// }
.no-comments {
display: flex;
flex-direction: column;
// .no-comments {
// display: flex;
// flex-direction: column;
span {
text-align: center;
margin-bottom: 10px;
}
// span {
// text-align: center;
// margin-bottom: 10px;
// }
img {
max-width: 250px;
align-self: center;
}
}
// img {
// max-width: 250px;
// align-self: center;
// }
// }
ul.comment-list li {
margin-bottom: 16px;
}
// ul.comment-list li {
// margin-bottom: 16px;
// }
.comment-list-enter-active,
.comment-list-leave-active,
@@ -447,11 +455,11 @@ ul.comment-list li {
transform-origin: center top;
}
/*.comment-empty-list-enter-active {*/
/* transition: opacity .5s;*/
/*}*/
// .comment-empty-list-enter-active {
// transition: opacity .5s;
// }
/*.comment-empty-list-enter {*/
/* opacity: 0;*/
/*}*/
// .comment-empty-list-enter {
// opacity: 0;
// }
</style>

View File

@@ -0,0 +1,49 @@
<template>
<Story>
<Variant title="Basic">
<DiscussionComment v-model="comment" :currentActor="baseActor" />
</Variant>
<Variant title="Deleted comment">
<DiscussionComment v-model="deletedComment" :currentActor="baseActor" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IPerson } from "@/types/actor";
import { IComment } from "@/types/comment.model";
import { ActorType } from "@/types/enums";
import { reactive } from "vue";
import DiscussionComment from "./DiscussionComment.vue";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IPerson = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
id: "598",
};
const comment = reactive<IComment>({
text: "Hello there",
publishedAt: new Date().toString(),
actor: baseActor,
});
const deletedComment = reactive<IComment>({
...comment,
deletedAt: new Date().toString(),
});
</script>

View File

@@ -1,23 +1,26 @@
<template>
<article class="comment">
<div class="avatar">
<figure
class="image is-48x48"
v-if="comment.actor && comment.actor.avatar"
>
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
<article class="flex gap-1">
<div class="">
<figure class="" v-if="comment.actor && comment.actor.avatar">
<img
class="rounded-xl"
:src="comment.actor.avatar.url"
alt=""
:width="48"
:height="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle :size="48" v-else />
</div>
<div class="body">
<div class="meta" dir="auto">
<span
class="first-line name"
<div class="mb-2 pt-1 flex-1">
<div class="flex items-center gap-1" dir="auto">
<div
class="flex flex-1 flex-col"
v-if="comment.actor && !comment.deletedAt"
>
<strong>{{ comment.actor.name }}</strong>
<strong v-if="comment.actor.name">{{ comment.actor.name }}</strong>
<small>@{{ usernameWithDomain(comment.actor) }}</small>
</span>
</div>
<span v-else class="name comment-link has-text-grey">
{{ $t("[deleted]") }}
</span>
@@ -26,39 +29,44 @@
v-if="
comment.actor &&
!comment.deletedAt &&
comment.actor.id === currentActor.id
comment.actor.id === currentActor?.id
"
>
<b-dropdown aria-role="list">
<b-icon slot="trigger" role="button" icon="dots-horizontal" />
<o-dropdown aria-role="list">
<template #trigger>
<o-icon role="button" icon="dots-horizontal" />
</template>
<b-dropdown-item
v-if="comment.actor.id === currentActor.id"
<o-dropdown-item
v-if="comment.actor?.id === currentActor?.id"
@click="toggleEditMode"
aria-role="menuitem"
>
<b-icon icon="pencil"></b-icon>
<o-icon icon="pencil"></o-icon>
{{ $t("Edit") }}
</b-dropdown-item>
<b-dropdown-item
v-if="comment.actor.id === currentActor.id"
@click="$emit('delete-comment', comment)"
</o-dropdown-item>
<o-dropdown-item
v-if="comment.actor?.id === currentActor?.id"
@click="emit('deleteComment', comment)"
aria-role="menuitem"
>
<b-icon icon="delete"></b-icon>
<o-icon icon="delete"></o-icon>
{{ $t("Delete") }}
</b-dropdown-item>
<!-- <b-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
<b-icon icon="flag" />
</o-dropdown-item>
<!-- <o-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
<o-icon icon="flag" />
{{ $t("Report") }}
</b-dropdown-item> -->
</b-dropdown>
</o-dropdown-item> -->
</o-dropdown>
</span>
<div class="post-infos">
<span :title="comment.insertedAt | formatDateTimeString">
<div class="self-center">
<span
:title="formatDateTimeString(comment.updatedAt?.toString())"
v-if="comment.updatedAt"
>
{{
formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale,
formatDistanceToNow(new Date(comment.updatedAt?.toString()), {
locale: dateFnsLocale,
}) || $t("Right now")
}}</span
>
@@ -69,20 +77,24 @@
class="text-wrapper"
dir="auto"
>
<div class="description-content" v-html="comment.text"></div>
<div
class="prose md:prose-lg lg:prose-xl dark:prose-invert"
v-html="comment.text"
></div>
<p
class="text-sm"
v-if="
comment.insertedAt &&
comment.updatedAt &&
new Date(comment.insertedAt).getTime() !==
new Date(comment.updatedAt).getTime()
"
:title="comment.updatedAt | formatDateTimeString"
:title="formatDateTimeString(comment.updatedAt.toString())"
>
{{
$t("Edited {ago}", {
ago: formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale,
locale: dateFnsLocale,
}),
})
}}
@@ -92,66 +104,66 @@
{{ $t("[This comment has been deleted by it's author]") }}
</div>
<form v-else class="edition" @submit.prevent="updateComment">
<editor v-model="updatedComment" :aria-label="$t('Comment body')" />
<div class="buttons">
<b-button
<Editor
v-model="updatedComment"
:aria-label="$t('Comment body')"
:current-actor="currentActor"
/>
<div class="flex gap-2 mt-2">
<o-button
native-type="submit"
:disabled="['<p></p>', '', comment.text].includes(updatedComment)"
type="is-primary"
>{{ $t("Update") }}</b-button
variant="primary"
>{{ $t("Update") }}</o-button
>
<b-button native-type="button" @click="toggleEditMode">{{
<o-button native-type="button" @click="toggleEditMode">{{
$t("Cancel")
}}</b-button>
}}</o-button>
</div>
</form>
</div>
</article>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { formatDistanceToNow } from "date-fns";
import { IComment } from "../../types/comment.model";
import { usernameWithDomain, IPerson } from "../../types/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
import { computed, defineAsyncComponent, inject, ref } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import type { Locale } from "date-fns";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
},
components: {
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class DiscussionComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
editMode = false;
const props = defineProps<{
modelValue: IComment;
currentActor: IPerson;
}>();
updatedComment = "";
const emit = defineEmits(["update:modelValue", "deleteComment"]);
currentActor!: IPerson;
const comment = computed(() => props.modelValue);
usernameWithDomain = usernameWithDomain;
const editMode = ref(false);
formatDistanceToNow = formatDistanceToNow;
const updatedComment = ref("");
// isReportModalActive: boolean = false;
const dateFnsLocale = inject<Locale>("dateFnsLocale");
toggleEditMode(): void {
this.updatedComment = this.comment.text;
this.editMode = !this.editMode;
}
// isReportModalActive: boolean = false;
updateComment(): void {
this.$emit("update-comment", {
...this.comment,
text: this.updatedComment,
});
this.toggleEditMode();
}
}
const toggleEditMode = (): void => {
updatedComment.value = comment.value.text;
editMode.value = !editMode.value;
};
const updateComment = (): void => {
emit("update:modelValue", {
...comment.value,
text: updatedComment.value,
});
toggleEditMode();
};
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@@ -170,7 +182,7 @@ article.comment {
padding: 0 1rem 0.3em;
.name {
@include margin-right(auto);
// @include margin-right(auto);
flex: 1 1 auto;
overflow: hidden;
@@ -200,33 +212,33 @@ article.comment {
div.description-content {
padding-bottom: 0.3rem;
::v-deep h1 {
:deep(h1) {
font-size: 2rem;
}
::v-deep h2 {
:deep(h2) {
font-size: 1.5rem;
}
::v-deep h3 {
:deep(h3) {
font-size: 1.25rem;
}
::v-deep ul {
:deep(ul) {
list-style-type: disc;
}
::v-deep li {
:deep(li) {
margin: 10px auto 10px 2rem;
}
::v-deep blockquote {
:deep(blockquote) {
border-left: 0.2em solid #333;
display: block;
@include padding-left(1em);
// @include padding-left(1em);
}
::v-deep p {
:deep(p) {
margin: 10px auto;
a {

View File

@@ -0,0 +1,33 @@
<template>
<Story>
<Variant title="Basic">
<DiscussionListItem :discussion="discussion" />
</Variant>
<Variant title="Deleted comment">
<DiscussionListItem :discussion="discussionWithDeletedComment" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IDiscussion } from "@/types/discussions";
import { reactive } from "vue";
import DiscussionListItem from "./DiscussionListItem.vue";
const discussion = reactive<IDiscussion>({
title: "A discussion",
comments: { total: 5, elements: [] },
insertedAt: new Date().toString(),
updatedAt: new Date().toString(),
deletedAt: null,
lastComment: { text: "Hello there", publishedAt: new Date().toString() },
});
const discussionWithDeletedComment = reactive<IDiscussion>({
...discussion,
lastComment: {
...discussion.lastComment,
deletedAt: new Date().toString(),
},
});
</script>

View File

@@ -1,126 +1,93 @@
<template>
<router-link
class="discussion-minimalist-card-wrapper"
class="flex gap-1 w-full items-center p-2 border-b-stone-200 border-b"
dir="auto"
:to="{
name: RouteName.DISCUSSION,
params: { slug: discussion.slug, id: discussion.id },
}"
>
<div class="media-left">
<div class="">
<figure
class="image is-32x32"
class=""
v-if="
discussion.lastComment.actor && discussion.lastComment.actor.avatar
discussion.lastComment?.actor && discussion.lastComment.actor.avatar
"
>
<img
class="is-rounded"
class="rounded-xl"
:src="discussion.lastComment.actor.avatar.url"
alt
alt=""
width="32"
height="32"
/>
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
<account-circle :size="32" v-else />
</div>
<div class="title-info-wrapper">
<div class="title-and-date">
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
<span
class="has-text-grey-dark"
:title="actualDate | formatDateTimeString"
>
{{
formatDistanceToNowStrict(new Date(actualDate), {
locale: $dateFnsLocale,
}) || $t("Right now")
}}</span
<div class="flex-1">
<div class="flex items-center">
<p class="text-violet-3 dark:text-white text-lg font-semibold flex-1">
{{ discussion.title }}
</p>
<span class="" :title="formatDateTimeString(actualDate)">
{{ distanceToNow }}</span
>
</div>
<div
class="ellipsis has-text-grey-dark"
class="line-clamp-2"
dir="auto"
v-if="!discussion.lastComment.deletedAt"
v-if="!discussion.lastComment?.deletedAt"
>
{{ htmlTextEllipsis }}
</div>
<div v-else class="has-text-grey-dark">
{{ $t("[This comment has been deleted]") }}
<div v-else class="">
{{ t("[This comment has been deleted]") }}
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { formatDistanceToNowStrict } from "date-fns";
import { IDiscussion } from "../../types/discussions";
import RouteName from "../../router/name";
import { IDiscussion } from "@/types/discussions";
import RouteName from "@/router/name";
import { computed, inject } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import type { Locale } from "date-fns";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { useI18n } from "vue-i18n";
@Component
export default class DiscussionListItem extends Vue {
@Prop({ required: true, type: Object }) discussion!: IDiscussion;
const props = defineProps<{
discussion: IDiscussion;
}>();
RouteName = RouteName;
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
formatDistanceToNowStrict = formatDistanceToNowStrict;
const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
get htmlTextEllipsis(): string {
const element = document.createElement("div");
if (this.discussion.lastComment && this.discussion.lastComment.text) {
element.innerHTML = this.discussion.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
const htmlTextEllipsis = computed((): string => {
const element = document.createElement("div");
if (props.discussion.lastComment && props.discussion.lastComment.text) {
element.innerHTML = props.discussion.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
});
get actualDate(): string | Date | undefined {
if (
this.discussion.updatedAt === this.discussion.insertedAt &&
this.discussion.lastComment
) {
return this.discussion.lastComment.publishedAt;
}
return this.discussion.updatedAt;
const actualDate = computed((): string => {
if (
props.discussion.updatedAt === props.discussion.insertedAt &&
props.discussion.lastComment?.publishedAt
) {
return props.discussion.lastComment.publishedAt;
}
}
return props.discussion.updatedAt;
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.discussion-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.calendar-icon {
@include margin-right(1rem);
}
.title-info-wrapper {
flex: 2;
.title-and-date {
display: flex;
align-items: center;
.discussion-minimalist-title {
color: #3c376e;
font-family: Roboto, Helvetica, Arial, serif;
font-size: 19px;
font-weight: 600;
flex: 1;
}
}
div.ellipsis {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 15px;
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="editor">
<div v-if="editor !== null">
<div
class="editor"
:class="{ short_mode: isShortMode, comment_mode: isCommentMode }"
@@ -14,64 +14,64 @@
<button
class="menubar__button"
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
@click="editor?.chain().focus().toggleBold().run()"
type="button"
:title="$t('Bold')"
>
<b-icon icon="format-bold" />
<o-icon icon="format-bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
@click="editor?.chain().focus().toggleItalic().run()"
type="button"
:title="$t('Italic')"
>
<b-icon icon="format-italic" />
<o-icon icon="format-italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
@click="editor?.chain().focus().toggleUnderline().run()"
type="button"
:title="$t('Underline')"
>
<b-icon icon="format-underline" />
<o-icon icon="format-underline" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
@click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
type="button"
:title="$t('Heading Level 1')"
>
<b-icon icon="format-header-1" />
<o-icon icon="format-header-1" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
@click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
type="button"
:title="$t('Heading Level 2')"
>
<b-icon icon="format-header-2" />
<o-icon icon="format-header-2" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
@click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
type="button"
:title="$t('Heading Level 3')"
>
<b-icon icon="format-header-3" />
<o-icon icon="format-header-3" />
</button>
<button
@@ -81,17 +81,17 @@
type="button"
:title="$t('Add link')"
>
<b-icon icon="link" />
<o-icon icon="link" />
</button>
<button
v-if="editor.isActive('link')"
class="menubar__button"
@click="editor.chain().focus().unsetLink().run()"
@click="editor?.chain().focus().unsetLink().run()"
type="button"
:title="$t('Remove link')"
>
<b-icon icon="link-off" />
<o-icon icon="link-off" />
</button>
<button
@@ -101,60 +101,60 @@
type="button"
:title="$t('Add picture')"
>
<b-icon icon="image" />
<o-icon icon="image" />
</button>
<button
class="menubar__button"
v-if="!isBasicMode"
:class="{ 'is-active': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
@click="editor?.chain().focus().toggleBulletList().run()"
type="button"
:title="$t('Bullet list')"
>
<b-icon icon="format-list-bulleted" />
<o-icon icon="format-list-bulleted" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
:class="{ 'is-active': editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
@click="editor?.chain().focus().toggleOrderedList().run()"
type="button"
:title="$t('Ordered list')"
>
<b-icon icon="format-list-numbered" />
<o-icon icon="format-list-numbered" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
:class="{ 'is-active': editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
@click="editor?.chain().focus().toggleBlockquote().run()"
type="button"
:title="$t('Quote')"
>
<b-icon icon="format-quote-close" />
<o-icon icon="format-quote-close" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
@click="editor.chain().focus().undo().run()"
@click="editor?.chain().focus().undo().run()"
type="button"
:title="$t('Undo')"
>
<b-icon icon="undo" />
<o-icon icon="undo" />
</button>
<button
v-if="!isBasicMode"
class="menubar__button"
@click="editor.chain().focus().redo().run()"
@click="editor?.chain().focus().redo().run()"
type="button"
:title="$t('Redo')"
>
<b-icon icon="redo" />
<o-icon icon="redo" />
</button>
</div>
@@ -167,34 +167,33 @@
<button
class="menububble__button"
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
@click="editor?.chain().focus().toggleBold().run()"
type="button"
:title="$t('Bold')"
>
<b-icon icon="format-bold" />
<o-icon icon="format-bold" />
<span class="visually-hidden">{{ $t("Bold") }}</span>
</button>
<button
class="menububble__button"
:class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
@click="editor?.chain().focus().toggleItalic().run()"
type="button"
:title="$t('Italic')"
>
<b-icon icon="format-italic" />
<o-icon icon="format-italic" />
<span class="visually-hidden">{{ $t("Italic") }}</span>
</button>
</bubble-menu>
<editor-content class="editor__content" :editor="editor" />
<editor-content class="editor__content" :editor="editor" v-if="editor" />
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-2";
<script lang="ts" setup>
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
import Blockquote from "@tiptap/extension-blockquote";
import BulletList from "@tiptap/extension-bullet-list";
import Heading from "@tiptap/extension-heading";
@@ -211,7 +210,6 @@ import { IActor, IPerson, usernameWithDomain } from "../types/actor";
import CustomImage from "./Editor/Image";
import { UPLOAD_MEDIA } from "../graphql/upload";
import { listenFileUpload } from "../utils/upload";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import Mention from "@tiptap/extension-mention";
import MentionOptions from "./Editor/Mention";
import OrderedList from "@tiptap/extension-ordered-list";
@@ -219,190 +217,204 @@ import ListItem from "@tiptap/extension-list-item";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import { AutoDir } from "./Editor/Autodir";
import sanitizeHtml from "sanitize-html";
// import sanitizeHtml from "sanitize-html";
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
import { Notifier } from "@/plugins/notifier";
@Component({
components: { EditorContent, BubbleMenu },
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
const props = withDefaults(
defineProps<{
modelValue: string;
mode?: string;
maxSize?: number;
ariaLabel?: string;
currentActor: IPerson;
}>(),
{
mode: "description",
maxSize: 100_000_000,
}
);
const emit = defineEmits(["update:modelValue"]);
const editor = ref<Editor | null>(null);
const isDescriptionMode = computed((): boolean => {
return props.mode === "description" || isBasicMode.value;
});
const isCommentMode = computed((): boolean => {
return props.mode === "comment";
});
const isShortMode = computed((): boolean => {
return isBasicMode.value;
});
const isBasicMode = computed((): boolean => {
return props.mode === "basic";
});
const insertMention = (obj: { range: any; attrs: any }) => {
console.log("initialize Mention");
};
const observer = ref<MutationObserver | null>(null);
onMounted(() => {
editor.value = new Editor({
editorProps: {
attributes: {
"aria-multiline": isShortMode.value.toString(),
"aria-label": props.ariaLabel ?? "",
role: "textbox",
},
transformPastedHTML: transformPastedHTML,
},
},
})
export default class EditorComponent extends Vue {
@Prop({ required: true }) value!: string;
extensions: [
Blockquote,
BulletList,
Heading,
Document,
Paragraph,
Text,
OrderedList,
ListItem,
Mention.configure(MentionOptions),
CustomImage,
AutoDir,
Underline,
Bold,
Italic,
Strike,
Dropcursor,
Gapcursor,
History,
Link.configure({
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
}),
],
injectCSS: false,
content: props.modelValue,
onUpdate: () => {
emit("update:modelValue", editor.value?.getHTML());
},
});
});
@Prop({ required: false, default: "description" }) mode!: string;
@Prop({ required: false, default: 100_000_000 }) maxSize!: number;
@Prop({ required: false }) ariaLabel!: string;
currentActor!: IPerson;
editor: Editor | null = null;
get isDescriptionMode(): boolean {
return this.mode === "description" || this.isBasicMode;
}
get isCommentMode(): boolean {
return this.mode === "comment";
}
get isShortMode(): boolean {
return this.isBasicMode;
}
get isBasicMode(): boolean {
return this.mode === "basic";
}
// eslint-disable-next-line
insertMention(obj: { range: any; attrs: any }) {
console.log("initialize Mention");
}
observer!: MutationObserver | null;
mounted(): void {
this.editor = new Editor({
editorProps: {
attributes: {
"aria-multiline": this.isShortMode.toString(),
"aria-label": this.ariaLabel,
role: "textbox",
},
transformPastedHTML: this.transformPastedHTML,
},
extensions: [
Blockquote,
BulletList,
Heading,
Document,
Paragraph,
Text,
OrderedList,
ListItem,
Mention.configure(MentionOptions),
CustomImage,
AutoDir,
Underline,
Bold,
Italic,
Strike,
Dropcursor,
Gapcursor,
History,
Link.configure({
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer ugc" },
}),
],
injectCSS: false,
content: this.value,
onUpdate: () => {
this.$emit("input", this.editor?.getHTML());
},
});
}
transformPastedHTML(html: string): string {
// When using comment mode, limit to acceptable tags
if (this.isCommentMode) {
return sanitizeHtml(html, {
allowedTags: ["b", "i", "em", "strong", "a"],
allowedAttributes: {
a: ["href", "rel", "target"],
},
});
}
const transformPastedHTML = (html: string): string => {
// When using comment mode, limit to acceptable tags
if (isCommentMode.value) {
// return sanitizeHtml(html, {
// allowedTags: ["b", "i", "em", "strong", "a"],
// allowedAttributes: {
// a: ["href", "rel", "target"],
// },
// });
return html;
}
return html;
};
@Watch("value")
onValueChanged(val: string): void {
if (!this.editor) return;
if (val !== this.editor.getHTML()) {
this.editor.commands.setContent(val, false);
}
const value = computed(() => props.modelValue);
watch(value, (val: string) => {
if (!editor.value) return;
if (val !== editor.value.getHTML()) {
editor.value.commands.setContent(val, false);
}
});
/**
* Show a popup to get the link from the URL
*/
showLinkMenu(): void {
this.$buefy.dialog.prompt({
message: this.$t("Enter the link URL") as string,
inputAttrs: {
type: "url",
},
trapFocus: true,
onConfirm: (value) => {
if (!this.editor) return undefined;
this.editor.chain().focus().setLink({ href: value }).run();
},
});
}
const dialog = inject<Dialog>("dialog");
const { t } = useI18n({ useScope: "global" });
/**
* Show a file prompt, upload picture and insert it into editor
*/
async showImagePrompt(): Promise<void> {
const image = await listenFileUpload();
try {
const { data } = await this.$apollo.mutate({
mutation: UPLOAD_MEDIA,
variables: {
file: image,
name: image.name,
},
});
if (data.uploadMedia && data.uploadMedia.url && this.editor) {
this.editor
.chain()
.focus()
.setImage({
src: data.uploadMedia.url,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
"data-media-id": data.uploadMedia.id,
})
.run();
}
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
/**
* Show a popup to get the link from the URL
*/
const showLinkMenu = (): void => {
dialog?.prompt({
message: t("Enter the link URL"),
inputAttrs: {
type: "url",
},
onConfirm: (prompt: string) => {
if (!editor.value) return;
editor.value.chain().focus().setLink({ href: prompt }).run();
},
});
};
/**
* We use this to programatically insert an actor mention when creating a reply to comment
*/
replyToComment(actor: IActor): void {
if (!this.editor) return;
this.editor
const {
mutate: uploadMediaMutation,
onDone: uploadMediaDone,
onError: uploadMediaError,
} = useMutation(UPLOAD_MEDIA);
/**
* Show a file prompt, upload picture and insert it into editor
*/
const showImagePrompt = async (): Promise<void> => {
const image = await listenFileUpload();
uploadMediaMutation({
file: image,
name: image.name,
});
};
uploadMediaDone(({ data }) => {
if (data.uploadMedia && data.uploadMedia.url && editor.value) {
editor.value
.chain()
.focus()
.insertContent({
type: "mention",
attrs: {
id: usernameWithDomain(actor),
},
.setImage({
src: data.uploadMedia.url,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
"data-media-id": data.uploadMedia.id,
})
.insertContent(" ")
.run();
}
});
focus(): void {
this.editor?.chain().focus("end");
}
const notifier = inject<Notifier>("notifier");
beforeDestroy(): void {
this.editor?.destroy();
uploadMediaError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
}
});
/**
* We use this to programatically insert an actor mention when creating a reply to comment
*/
const replyToComment = (actor: IActor): void => {
if (!editor.value) return;
editor.value
.chain()
.focus()
.insertContent({
type: "mention",
attrs: {
id: usernameWithDomain(actor),
},
})
.insertContent(" ")
.run();
};
const focus = (): void => {
editor.value?.chain().focus("end");
};
defineExpose({ replyToComment, focus });
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<style lang="scss">
@use "@/styles/_mixins" as *;
@@ -422,7 +434,7 @@ $color-white: #eee;
border: 0;
color: $color-black;
padding: 0.2rem 0.5rem;
@include margin-right(0.2rem);
// @include margin-right(0.2rem);
border-radius: 3px;
cursor: pointer;
@@ -492,10 +504,10 @@ $color-white: #eee;
font-size: 1.25em;
}
ul,
ol {
@include padding-left(1rem);
}
// ul,
// ol {
// @include padding-left(1rem);
// }
ul {
list-style-type: disc;
@@ -510,7 +522,7 @@ $color-white: #eee;
blockquote {
border-left: 3px solid rgba($color-black, 0.1);
color: rgba($color-black, 0.8);
@include padding-left(0.8rem);
// @include padding-left(0.8rem);
font-style: italic;
p {

View File

@@ -1,10 +1,9 @@
import { UPLOAD_MEDIA } from "@/graphql/upload";
import apolloProvider from "@/vue-apollo";
import { ApolloClient } from "@apollo/client/core/ApolloClient";
import { apolloClient } from "@/vue-apollo";
import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Image from "@tiptap/extension-image";
import { NormalizedCacheObject } from "@apollo/client/cache";
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
/* eslint-disable class-methods-use-this */
@@ -60,21 +59,25 @@ const CustomImage = Image.extend({
top: realEvent.clientY,
});
if (!coordinates) return false;
const client =
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
try {
images.forEach(async (image) => {
const { data } = await client.mutate({
mutation: UPLOAD_MEDIA,
variables: {
file: image,
name: image.name,
},
});
images.forEach((image) => {
const { onDone, onError } = provideApolloClient(apolloClient)(
() =>
useMutation<{ uploadMedia: { url: string; id: string } }>(
UPLOAD_MEDIA,
() => ({
variables: {
file: image,
name: image.name,
},
})
)
);
onDone(({ data }) => {
const node = schema.nodes.image.create({
src: data.uploadMedia.url,
"data-media-id": data.uploadMedia.id,
src: data?.uploadMedia.url,
"data-media-id": data?.uploadMedia.id,
});
const transaction = view.state.tr.insert(
coordinates.pos,
@@ -82,11 +85,13 @@ const CustomImage = Image.extend({
);
view.dispatch(transaction);
});
return true;
} catch (error) {
console.error(error);
return false;
}
onError((error) => {
console.error(error);
return false;
});
});
return true;
},
},
},

View File

@@ -1,27 +1,38 @@
import { SEARCH_PERSONS } from "@/graphql/search";
import { VueRenderer } from "@tiptap/vue-2";
import { VueRenderer } from "@tiptap/vue-3";
import tippy from "tippy.js";
import MentionList from "./MentionList.vue";
import { ApolloClient } from "@apollo/client/core/ApolloClient";
import apolloProvider from "@/vue-apollo";
import { apolloClient } from "@/vue-apollo";
import { IPerson } from "@/types/actor";
import pDebounce from "p-debounce";
import { NormalizedCacheObject } from "@apollo/client/cache/inmemory/types";
import { MentionOptions } from "@tiptap/extension-mention";
import { Editor } from "@tiptap/core";
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
import { onError } from "@apollo/client/link/error";
const client =
apolloProvider.defaultClient as ApolloClient<NormalizedCacheObject>;
const fetchItems = (query: string): Promise<IPerson[]> => {
return new Promise((resolve, reject) => {
const { onResult } = provideApolloClient(apolloClient)(() => {
return useQuery<{ searchPersons: Paginate<IPerson> }>(
SEARCH_PERSONS,
() => ({
variables: {
searchText: query,
},
})
);
});
const fetchItems = async (query: string): Promise<IPerson[]> => {
const result = await client.query({
query: SEARCH_PERSONS,
variables: {
searchText: query,
},
onResult(({ data }) => {
resolve(data.searchPersons.elements);
});
onError(reject);
});
// TipTap doesn't handle async for onFilter, hence the following line.
return result.data.searchPersons.elements;
// // TipTap doesn't handle async for onFilter, hence the following line.
// return result.data.searchPersons.elements;
};
const debouncedFetchItems = pDebounce(fetchItems, 200);
@@ -53,7 +64,6 @@ const mentionOptions: MentionOptions = {
return {
onStart: (props: any) => {
component = new VueRenderer(MentionList, {
parent: this,
propsData: props,
});

View File

@@ -12,70 +12,64 @@
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from "vue-property-decorator";
import { displayName, usernameWithDomain } from "@/types/actor/actor.model";
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor/actor.model";
import { IPerson } from "@/types/actor";
import ActorInline from "../../components/Account/ActorInline.vue";
import { ref, watch } from "vue";
@Component({
components: {
ActorInline,
},
})
export default class MentionList extends Vue {
@Prop({ type: Array, required: true }) items!: Array<IPerson>;
@Prop({ type: Function, required: true }) command!: any;
const props = defineProps<{
items: IPerson[];
command: ({ id }: { id: string }) => {};
}>();
selectedIndex = 0;
// @Prop({ type: Function, required: true }) command!: any;
displayName = displayName;
const selectedIndex = ref(0);
@Watch("items")
watchItems(): void {
this.selectedIndex = 0;
watch(props.items, () => {
selectedIndex.value = 0;
});
const onKeyDown = ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
onKeyDown({ event }: { event: KeyboardEvent }): boolean {
if (event.key === "ArrowUp") {
this.upHandler();
return true;
}
if (event.key === "ArrowDown") {
this.downHandler();
return true;
}
if (event.key === "Enter") {
this.enterHandler();
return true;
}
return false;
if (event.key === "ArrowDown") {
downHandler();
return true;
}
upHandler(): void {
this.selectedIndex =
(this.selectedIndex + this.items.length - 1) % this.items.length;
if (event.key === "Enter") {
enterHandler();
return true;
}
downHandler(): void {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
}
return false;
};
enterHandler(): void {
this.selectItem(this.selectedIndex);
}
const upHandler = (): void => {
selectedIndex.value =
(selectedIndex.value + props.items.length - 1) % props.items.length;
};
selectItem(index: number): void {
const item = this.items[index];
const downHandler = (): void => {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
};
if (item) {
this.command({ id: usernameWithDomain(item) });
}
const enterHandler = (): void => {
selectItem(selectedIndex.value);
};
const selectItem = (index: number): void => {
const item = props.items[index];
if (item) {
props.command({ id: usernameWithDomain(item) });
}
}
};
</script>
<style lang="scss" scoped>

View File

@@ -1,345 +0,0 @@
<template>
<div class="container section" id="error-wrapper">
<div class="column">
<section>
<div class="picture-wrapper">
<picture>
<source
srcset="
/img/pics/error-480w.webp 1x,
/img/pics/error-1024w.webp 2x
"
type="image/webp"
/>
<source
srcset="/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x"
type="image/jpeg"
/>
<img
:src="`/img/pics/error-480w.jpg`"
alt=""
width="480"
height="312"
loading="lazy"
/>
</picture>
</div>
<b-message type="is-danger" class="is-size-5">
<h1>
{{
$t(
"An error has occured. Sorry about that. You may try to reload the page."
)
}}
</h1>
</b-message>
</section>
<b-loading v-if="$apollo.loading" :active.sync="$apollo.loading" />
<section v-else>
<h2 class="is-size-5">{{ $t("What can I do to help?") }}</h2>
<p class="content">
<i18n
tag="span"
path="{instanceName} is an instance of {mobilizon_link}, a free software built with the community."
>
<b slot="instanceName">{{ config.name }}</b>
<a slot="mobilizon_link" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
</i18n>
<span v-if="sentryEnabled && sentryReady">
{{
$t(
"We collect your feedback and the error information in order to improve this service."
)
}}</span
>
<span v-else>
{{
$t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
</span>
</p>
<form
v-if="sentryEnabled && sentryReady && !submittedFeedback"
@submit.prevent="sendErrorToSentry"
>
<b-field :label="$t('What happened?')" label-for="what-happened">
<b-input
v-model="feedback"
type="textarea"
id="what-happened"
:placeholder="$t(`I've clicked on X, then on Y`)"
/>
</b-field>
<b-button icon-left="send" native-type="submit" type="is-primary">{{
$t("Send feedback")
}}</b-button>
<p class="content">
{{
$t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
</form>
<b-message type="is-danger" v-else-if="feedbackError">
<p>
{{
$t(
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway."
)
}}
</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<b-message type="is-success" v-else-if="submittedFeedback">
<p>{{ $t("Thanks a lot, your feedback was submitted!") }}</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<div
class="content"
v-if="!(sentryEnabled && sentryReady) || submittedFeedback"
>
<p v-if="submittedFeedback">{{ $t("You may also:") }}</p>
<ul>
<li>
<a
href="https://framacolibri.org/c/mobilizon/39"
target="_blank"
>{{ $t("Open a topic on our forum") }}</a
>
</li>
<li>
<a
href="https://framagit.org/framasoft/mobilizon/-/issues/"
target="_blank"
>{{
$t("Open an issue on our bug tracker (advanced users)")
}}</a
>
</li>
</ul>
</div>
<p class="content" v-if="!sentryEnabled">
{{
$t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
<details>
<summary class="is-size-5">{{ $t("Technical details") }}</summary>
<p>{{ $t("Error message") }}</p>
<pre>{{ error }}</pre>
<p>{{ $t("Error stacktrace") }}</p>
<pre>{{ error.stack }}</pre>
</details>
<p v-if="!sentryEnabled">
{{
$t(
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
)
}}
</p>
<div class="buttons" v-if="!sentryEnabled">
<b-tooltip
:label="tooltipConfig.label"
:type="tooltipConfig.type"
:active="copied !== false"
always
>
<b-button
@click="copyErrorToClipboard"
@keyup.enter="copyErrorToClipboard"
>{{ $t("Copy details to clipboard") }}</b-button
>
</b-tooltip>
</div>
</section>
</div>
</div>
</template>
<script lang="ts">
import { CONFIG } from "@/graphql/config";
import { checkProviderConfig, convertConfig } from "@/services/statistics";
import { IAnalyticsConfig, IConfig } from "@/types/config.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import { LOGGED_USER } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { ISentryConfiguration } from "@/types/analytics/sentry.model";
import { submitFeedback } from "@/services/statistics/sentry";
import RouteName from "@/router/name";
@Component({
apollo: {
config: CONFIG,
loggedUser: LOGGED_USER,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.$t("Error") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class ErrorComponent extends Vue {
@Prop({ required: true, type: Error }) error!: Error;
copied: "success" | "error" | false = false;
config!: IConfig;
feedback = "";
submittedFeedback = false;
feedbackError = false;
loggedUser!: IUser;
RouteName = RouteName;
async copyErrorToClipboard(): Promise<void> {
try {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(this.fullErrorString);
} else {
this.fallbackCopyTextToClipboard(this.fullErrorString);
}
this.copied = "success";
setTimeout(() => {
this.copied = false;
}, 2000);
} catch (e) {
this.copied = "error";
console.error("Unable to copy to clipboard");
console.error(e);
}
}
get fullErrorString(): string {
return `${this.error.name}: ${this.error.message}\n\n${this.error.stack}`;
}
get tooltipConfig(): { label: string | null; type: string | null } {
if (this.copied === "success")
return {
label: this.$t("Error details copied!") as string,
type: "is-success",
};
if (this.copied === "error")
return {
label: this.$t("Unable to copy to clipboard") as string,
type: "is-danger",
};
return { label: null, type: "is-primary" };
}
private fallbackCopyTextToClipboard(text: string): void {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
}
get sentryEnabled(): boolean {
return this.sentryProvider?.enabled === true;
}
get sentryProvider(): IAnalyticsConfig | undefined {
return this.config && checkProviderConfig(this.config, "sentry");
}
get sentryConfig(): ISentryConfiguration | undefined {
if (this.sentryProvider?.configuration) {
return convertConfig(
this.sentryProvider?.configuration
) as ISentryConfiguration;
}
return undefined;
}
get sentryReady() {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
return eventId && dsn && organization && project && host;
}
async sendErrorToSentry() {
try {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
const endpoint = `https://${host}/api/0/projects/${organization}/${project}/user-feedback/`;
if (eventId && dsn && this.sentryReady) {
await submitFeedback(endpoint, dsn, {
event_id: eventId,
name:
this.loggedUser?.defaultActor?.preferredUsername || "Unknown user",
email: this.loggedUser?.email || "unknown@email.org",
comments: this.feedback,
});
this.submittedFeedback = true;
}
} catch (error) {
console.error(error);
this.feedbackError = true;
}
}
}
</script>
<style lang="scss" scoped>
#error-wrapper {
width: 100%;
background: $white;
section {
margin-bottom: 2rem;
}
.picture-wrapper {
text-align: center;
}
details {
summary:hover {
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="container mx-auto" id="error-wrapper">
<div class="">
<section>
<div class="text-center">
<picture>
<source
:srcset="`/img/pics/error-480w.webp 1x, /img/pics/error-1024w.webp 2x`"
type="image/webp"
/>
<source
:srcset="`/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x`"
type="image/jpeg"
/>
<img
:src="`/img/pics/error-480w.jpg`"
alt=""
width="480"
height="312"
loading="lazy"
/>
</picture>
</div>
<o-notification variant="danger" class="">
<h1>
{{
t(
"An error has occured. Sorry about that. You may try to reload the page."
)
}}
</h1>
</o-notification>
</section>
<o-loading v-if="loading" v-model:active="loading" />
<section v-else>
<h2 class="">{{ t("What can I do to help?") }}</h2>
<p class="prose dark:prose-invert">
<i18n-t
tag="span"
keypath="{instanceName} is an instance of {mobilizon_link}, a free software built with the community."
>
<template v-slot:instanceName>
<b>{{ config?.name }}</b>
</template>
<template v-slot:mobilizon_link>
<a href="https://joinmobilizon.org">{{ t("Mobilizon") }}</a>
</template>
</i18n-t>
<span v-if="sentryEnabled">
{{
t(
"We collect your feedback and the error information in order to improve this service."
)
}}</span
>
<span v-else>
{{
t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
</span>
</p>
<SentryFeedback />
<p class="prose dark:prose-invert" v-if="!sentryEnabled">
{{
t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
<details>
<summary class="is-size-5">{{ t("Technical details") }}</summary>
<p>{{ t("Error message") }}</p>
<pre>{{ error }}</pre>
<p>{{ t("Error stacktrace") }}</p>
<pre>{{ error.stack }}</pre>
</details>
<p v-if="!sentryEnabled">
{{
t(
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
)
}}
</p>
<div class="buttons" v-if="!sentryEnabled">
<o-tooltip
:label="tooltipConfig.label"
:type="tooltipConfig.type"
:active="copied !== false"
always
>
<o-button
@click="copyErrorToClipboard"
@keyup.enter="copyErrorToClipboard"
>{{ t("Copy details to clipboard") }}</o-button
>
</o-tooltip>
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { checkProviderConfig } from "@/services/statistics";
import { IAnalyticsConfig } from "@/types/config.model";
import { computed, defineAsyncComponent, ref } from "vue";
import { useQueryLoading } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { useAnalytics } from "@/composition/apollo/config";
const SentryFeedback = defineAsyncComponent(
() => import("./Feedback/SentryFeedback.vue")
);
const { analytics } = useAnalytics();
const loading = useQueryLoading();
const props = defineProps<{
error: Error;
}>();
const copied = ref<"success" | "error" | false>(false);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Error")),
});
const copyErrorToClipboard = async (): Promise<void> => {
try {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(fullErrorString.value);
} else {
fallbackCopyTextToClipboard(fullErrorString.value);
}
copied.value = "success";
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (e) {
copied.value = "error";
console.error("Unable to copy to clipboard");
console.error(e);
}
};
const fullErrorString = computed((): string => {
return `${props.error.name}: ${props.error.message}\n\n${props.error.stack}`;
});
const tooltipConfig = computed(
(): { label: string | null; variant: string | null } => {
if (copied.value === "success")
return {
label: t("Error details copied!") as string,
variant: "success",
};
if (copied.value === "error")
return {
label: t("Unable to copy to clipboard") as string,
variant: "danger",
};
return { label: null, variant: "primary" };
}
);
const fallbackCopyTextToClipboard = (text: string): void => {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
};
const sentryEnabled = computed((): boolean => {
return sentryProvider.value?.enabled === true;
});
const sentryProvider = computed((): IAnalyticsConfig | undefined => {
return checkProviderConfig(analytics.value ?? [], "sentry");
});
</script>
<style lang="scss" scoped>
#error-wrapper {
width: 100%;
background: $white;
section {
margin-bottom: 2rem;
}
.picture-wrapper {
text-align: center;
}
details {
summary:hover {
cursor: pointer;
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="address-autocomplete">
<b-field expanded>
<b-autocomplete
<!-- <o-field expanded>
<o-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
@@ -15,31 +15,31 @@
dir="auto"
>
<template #default="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" />
<o-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b
><br />
<small>{{ option.poiInfos.alternativeName }}</small>
</template>
</b-autocomplete>
</b-field>
<b-field
</o-autocomplete>
</o-field>
<o-field
v-if="canDoGeoLocation"
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<b-button
<o-button
type="is-text"
v-if="!gettingLocation"
icon-right="target"
@click="locateMe"
@keyup.enter="locateMe"
>{{ $t("Use my location") }}</b-button
>{{ $t("Use my location") }}</o-button
>
<span v-else>{{ $t("Getting location") }}</span>
</b-field>
</o-field> -->
<!--
<div v-if="selected && selected.geom" class="control">
<b-checkbox @input="togglemap" />
<o-checkbox @input="togglemap" />
<label class="label">{{ $t("Show map") }}</label>
</div>
@@ -59,16 +59,14 @@
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import { Prop, Watch, Vue } from "vue-property-decorator";
import { Address, IAddress } from "../../types/address.model";
import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
// import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
@Component({
inheritAttrs: false,
})
export default class AddressAutoComplete extends Mixins(
AddressAutoCompleteMixin
) {
// @Component({
// inheritAttrs: false,
// })
export default class AddressAutoComplete extends Vue {
@Prop({ required: false, default: false }) type!: string | false;
@Prop({ required: false, default: true, type: Boolean })
doGeoLocation!: boolean;
@@ -103,7 +101,7 @@ export default class AddressAutoComplete extends Mixins(
updateSelected(option: IAddress): void {
if (option == null) return;
this.selected = option;
this.$emit("input", this.selected);
// this.$emit("input", this.selected);
}
resetPopup(): void {

View File

@@ -0,0 +1,14 @@
<template>
<Story>
<Variant title="new">
<DateCalendarIcon :date="new Date().toString()" />
</Variant>
<Variant title="small">
<DateCalendarIcon :date="new Date().toString()" :small="true" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import DateCalendarIcon from "./DateCalendarIcon.vue";
</script>

View File

@@ -1,71 +1,51 @@
<docs>
### Example
```vue
<DateCalendarIcon date="2019-10-05T18:41:11.720Z" />
```
```vue
<DateCalendarIcon
:date="new Date()"
/>
```
</docs>
<template>
<div
class="datetime-container"
class="datetime-container flex flex-col rounded-lg text-center justify-center overflow-hidden items-stretch bg-white dark:bg-gray-700 text-violet-3 dark:text-white"
:class="{ small }"
:style="`--small: ${smallStyle}`"
>
<div class="datetime-container-header" />
<div class="datetime-container-content">
<time :datetime="dateObj.toISOString()" class="day">{{ day }}</time>
<time :datetime="dateObj.toISOString()" class="month">{{ month }}</time>
<time :datetime="dateObj.toISOString()" class="day block font-semibold">{{
day
}}</time>
<time
:datetime="dateObj.toISOString()"
class="month font-semibold block uppercase py-1 px-0"
>{{ month }}</time
>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { computed } from "vue";
@Component
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;
const props = withDefaults(
defineProps<{
date: string;
small?: boolean;
}>(),
{ small: false }
);
get dateObj(): Date {
return new Date(this.$props.date);
}
const dateObj = computed<Date>(() => new Date(props.date));
get month(): string {
return this.dateObj.toLocaleString(undefined, { month: "short" });
}
const month = computed<string>(() =>
dateObj.value.toLocaleString(undefined, { month: "short" })
);
get day(): string {
return this.dateObj.toLocaleString(undefined, { day: "numeric" });
}
get smallStyle(): string {
return this.small ? "1.2" : "2";
}
}
const day = computed<string>(() =>
dateObj.value.toLocaleString(undefined, { day: "numeric" })
);
const smallStyle = computed<string>(() => (props.small ? "1.2" : "2"));
</script>
<style lang="scss" scoped>
div.datetime-container {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
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));
@@ -76,15 +56,9 @@ div.datetime-container {
}
time {
display: block;
font-weight: 600;
color: $violet-3;
&.month {
padding: 2px 0;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
&.day {

View File

@@ -1,34 +1,16 @@
<template>
<div class="banner-container">
<div class="flex justify-center h-80">
<lazy-image-wrapper :picture="picture" />
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IMedia } from "@/types/media.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
@Component({
components: {
LazyImageWrapper,
},
})
export default class EventBanner extends Vue {
@Prop({ default: null, type: Object as PropType<IMedia> })
picture!: IMedia | null;
}
withDefaults(
defineProps<{
picture: IMedia | null;
}>(),
{ picture: 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>

View File

@@ -0,0 +1,145 @@
<template>
<Story title="EventCard">
<Variant title="default">
<EventCard :event="event" />
</Variant>
<Variant title="long">
<EventCard :event="longEvent" />
</Variant>
<Variant title="tentative">
<EventCard :event="tentativeEvent" />
</Variant>
<Variant title="cancelled">
<EventCard :event="cancelledEvent" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import {
ActorType,
CommentModeration,
EventJoinOptions,
EventStatus,
EventVisibility,
} from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { reactive } from "vue";
import EventCard from "./EventCard.vue";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
const baseEvent: IEvent = {
uuid: "",
title: "A very interesting event",
description: "Things happen",
beginsOn: new Date(),
endsOn: new Date(),
physicalAddress: {
description: "Somewhere",
street: "",
locality: "",
region: "",
country: "",
type: "",
postalCode: "",
},
picture: {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
},
url: "",
local: true,
slug: "",
publishAt: new Date(),
status: EventStatus.CONFIRMED,
visibility: EventVisibility.PUBLIC,
joinOptions: EventJoinOptions.FREE,
draft: false,
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 0,
moderator: 0,
administrator: 0,
going: 0,
},
participants: { total: 0, elements: [] },
relatedEvents: [],
tags: [{ slug: "something", title: "Something" }],
attributedTo: undefined,
organizerActor: {
...baseActor,
name: "Hello",
avatar: {
...baseActorAvatar,
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
},
},
comments: [],
options: {
maximumAttendeeCapacity: 0,
remainingAttendeeCapacity: 0,
showRemainingAttendeeCapacity: false,
anonymousParticipation: false,
hideOrganizerWhenGroupEvent: false,
offers: [],
participationConditions: [],
attendees: [],
program: "",
commentModeration: CommentModeration.ALLOW_ALL,
showParticipationPrice: false,
showStartTime: false,
showEndTime: false,
timezone: null,
isOnline: false,
},
metadata: [],
contacts: [],
language: "en",
category: "hello",
};
const event = reactive<IEvent>(baseEvent);
const longEvent = reactive<IEvent>({
...baseEvent,
title:
"A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so.",
});
const tentativeEvent = reactive<IEvent>({
...baseEvent,
status: EventStatus.TENTATIVE,
});
const cancelledEvent = reactive<IEvent>({
...baseEvent,
status: EventStatus.CANCELLED,
});
</script>

View File

@@ -1,81 +1,85 @@
<template>
<router-link
class="card"
class="mbz-card max-w-xs"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<div class="card-image">
<figure class="image is-16by9">
<div class="bg-secondary">
<figure class="block relative pt-40">
<lazy-image-wrapper
:picture="event.picture"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
/>
<div
class="tag-container"
class="absolute top-3 right-0 ltr:-mr-1 rtl:-ml-1 z-10 max-w-xs no-underline flex flex-col gap-1"
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
>
<b-tag type="is-info" v-if="event.status === EventStatus.TENTATIVE">
<mobilizon-tag
variant="info"
v-if="event.status === EventStatus.TENTATIVE"
>
{{ $t("Tentative") }}
</b-tag>
<b-tag type="is-danger" v-if="event.status === EventStatus.CANCELLED">
</mobilizon-tag>
<mobilizon-tag
variant="danger"
v-if="event.status === EventStatus.CANCELLED"
>
{{ $t("Cancelled") }}
</b-tag>
</mobilizon-tag>
<router-link
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
v-for="tag in (event.tags || []).slice(0, 3)"
:key="tag.slug"
>
<b-tag type="is-light" dir="auto">{{ tag.title }}</b-tag>
<mobilizon-tag dir="auto">{{ tag.title }}</mobilizon-tag>
</router-link>
</div>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
<div class="h-full p-2">
<div class="relative flex flex-col h-full">
<div class="-mt-3 h-0 flex mb-3 ltr:ml-0 rtl:mr-0 items-end self-start">
<date-calendar-icon
:small="true"
v-if="!mergedOptions.hideDate"
:date="event.beginsOn"
:date="event.beginsOn.toString()"
/>
</div>
<div class="media-content">
<div class="flex-1 w-full flex flex-col justify-between">
<h3
class="event-title"
class="text-lg leading-5 line-clamp-3 font-bold"
:title="event.title"
dir="auto"
:lang="event.language"
>
{{ event.title }}
</h3>
<div class="content-end">
<div class="event-organizer" dir="auto">
<figure
class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
>
<div class="pt-3">
<div class="flex items-center" dir="auto">
<figure class="" v-if="actorAvatarURL">
<img
class="is-rounded"
:src="organizer(event).avatar.url"
class="rounded-xl"
:src="actorAvatarURL"
alt=""
width="24"
height="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
<account-circle v-else />
<span class="text-sm font-semibold ltr:pl-2 rtl:pr-2">
{{ organizerDisplayName(event) }}
</span>
</div>
<inline-address
dir="auto"
v-if="event.physicalAddress"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
class="flex items-center text-sm"
dir="auto"
v-else-if="event.options && event.options.isOnline"
>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
<o-icon icon="video" />
<span class="ltr:pl-2 rtl:pr-2">{{ $t("Online") }}</span>
</div>
</div>
</div>
@@ -84,189 +88,44 @@
</router-link>
</template>
<script lang="ts">
<script lang="ts" setup>
import {
IEvent,
IEventCardOptions,
organizerDisplayName,
organizer,
organizerAvatarUrl,
} 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 { EventStatus } from "@/types/enums";
import RouteName from "../../router/name";
import InlineAddress from "@/components/Address/InlineAddress.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
InlineAddress,
},
})
export default class EventCard extends Vue {
@Prop({ required: true }) event!: IEvent;
import { computed } from "vue";
import MobilizonTag from "../Tag.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Prop({ required: false }) options!: IEventCardOptions;
const props = defineProps<{ event: IEvent; options?: IEventCardOptions }>();
const defaultOptions: IEventCardOptions = {
hideDate: false,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
};
ParticipantRole = ParticipantRole;
const mergedOptions = computed<IEventCardOptions>(() => ({
...defaultOptions,
...props.options,
}));
EventStatus = EventStatus;
// const actor = computed<Actor>(() => {
// return Object.assign(
// new Person(),
// props.event.organizerActor ?? mergedOptions.value.organizerActor
// );
// });
RouteName = RouteName;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
defaultOptions: IEventCardOptions = {
hideDate: false,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
memberofGroup: false,
};
get mergedOptions(): IEventCardOptions {
return { ...this.defaultOptions, ...this.options };
}
get actor(): Actor {
return Object.assign(
new Person(),
this.event.organizerActor || this.mergedOptions.organizerActor
);
}
}
const actorAvatarURL = computed<string | null>(() =>
organizerAvatarUrl(props.event)
);
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@use "@/styles/_event-card";
a.card {
display: block;
background: $secondary;
color: #3c376e;
&:hover {
// box-shadow: 0 0 5px 0 rgba(0, 0, 0, 1);
transform: scale(1.01, 1.01);
&:after {
opacity: 1;
}
}
border-radius: 5px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
&:after {
content: "";
border-radius: 5px;
position: absolute;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
}
div.tag-container {
position: absolute;
top: 10px;
right: 0;
@include margin-right(-3px);
z-index: 10;
max-width: 40%;
a {
text-decoration: none;
}
span.tag {
margin: 5px auto;
text-overflow: ellipsis;
overflow: hidden;
display: block;
font-size: 0.9em;
line-height: 1.75em;
&:not(.is-info, .is-danger) {
background-color: #e6e4f4;
color: $violet-3;
}
&.is-info {
color: $violet-3;
}
}
}
div.card-image {
background: $secondary;
figure.image {
background-size: cover;
background-position: center;
}
}
.card-content {
height: 100%;
padding: 0.5rem;
& > .media {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
& > .media-left {
margin-top: -15px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 15px;
@include margin-left(0);
}
& > .media-content {
flex: 1;
width: 100%;
overflow-x: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
}
}
.event-title {
font-size: 18px;
line-height: 24px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: bold;
}
.content-end {
padding-top: 8px;
}
.event-subtitle {
font-size: 0.85rem;
}
.organizer-name {
font-size: 14px;
}
}
}
</style>

View File

@@ -1,35 +1,16 @@
<docs>
#### Give a translated and localized text that give the starting and ending datetime for an event.
##### Start date with no ending
```vue
<EventFullDate beginsOn="2015-10-06T18:41:11.720Z" />
```
##### Start date with an ending the same day
```vue
<EventFullDate beginsOn="2015-10-06T18:41:11.720Z" endsOn="2015-10-06T20:41:11.720Z" />
```
##### Start date with an ending on a different day
```vue
<EventFullDate beginsOn="2015-10-06T18:41:11.720Z" endsOn="2032-10-06T18:41:11.720Z" />
```
</docs>
<template>
<p v-if="!endsOn">
<span>{{
formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
}}</span>
<br />
<b-switch
size="is-small"
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</o-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && showEndTime">
<span>{{
@@ -40,13 +21,13 @@
})
}}</span>
<br />
<b-switch
size="is-small"
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</o-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && !showEndTime">
{{
@@ -74,13 +55,13 @@
}}
</span>
<br />
<b-switch
size="is-small"
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ multipleTimeZones }}
</b-switch>
</o-switch>
</p>
<p v-else-if="endsOn && showStartTime">
<span>
@@ -93,120 +74,117 @@
}}
</span>
<br />
<b-switch
size="is-small"
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</o-switch>
</p>
<p v-else-if="endsOn">
{{
$t("From the {startDate} to the {endDate}", {
t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn),
endDate: formatDate(endsOn),
})
}}
</p>
</template>
<script lang="ts">
<script lang="ts" setup>
import {
formatDateString,
formatDateTimeString,
formatTimeString,
} from "@/filters/datetime";
import { getTimezoneOffset } from "date-fns-tz";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
@Component
export default class EventFullDate extends Vue {
@Prop({ required: true }) beginsOn!: string;
@Prop({ required: false }) endsOn!: string;
@Prop({ required: false, default: true }) showStartTime!: boolean;
@Prop({ required: false, default: true }) showEndTime!: boolean;
@Prop({ required: false }) timezone!: string;
@Prop({ required: false }) userTimezone!: string;
showLocalTimezone = true;
get timezoneToShow(): string {
if (this.showLocalTimezone) {
return this.timezone;
}
return this.userActualTimezone;
const props = withDefaults(
defineProps<{
beginsOn: string;
endsOn?: string;
showStartTime?: boolean;
showEndTime?: boolean;
timezone?: string;
userTimezone?: string;
}>(),
{
showStartTime: true,
showEndTime: true,
}
);
get userActualTimezone(): string {
if (this.userTimezone) {
return this.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
const { t } = useI18n({ useScope: "global" });
const showLocalTimezone = ref(true);
const timezoneToShow = computed((): string | undefined => {
if (showLocalTimezone.value) {
return props.timezone;
}
return userActualTimezone.value;
});
formatDate(value: Date): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateString(value);
const userActualTimezone = computed((): string => {
if (props.userTimezone) {
return props.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
});
formatTime(value: Date, timezone: string): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatTimeString(value, timezone || undefined);
}
const formatDate = (value: string): string | undefined => {
return formatDateString(value);
};
formatDateTimeString(
value: Date,
timezone: string,
showTime: boolean
): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateTimeString(
value,
timezone,
showTime
);
}
const formatTime = (
value: string,
timezone: string | undefined = undefined
): string | undefined => {
return formatTimeString(value, timezone ?? "Etc/UTC");
};
isSameDay(): boolean {
const sameDay =
this.beginsOnDate.toDateString() === new Date(this.endsOn).toDateString();
return this.endsOn !== undefined && sameDay;
}
const isSameDay = (): boolean => {
if (!props.endsOn) return false;
return (
beginsOnDate.value.toDateString() === new Date(props.endsOn).toDateString()
);
};
get beginsOnDate(): Date {
return new Date(this.beginsOn);
}
const beginsOnDate = computed((): Date => {
return new Date(props.beginsOn);
});
get differentFromUserTimezone(): boolean {
return (
!!this.timezone &&
!!this.userActualTimezone &&
getTimezoneOffset(this.timezone, this.beginsOnDate) !==
getTimezoneOffset(this.userActualTimezone, this.beginsOnDate) &&
this.timezone !== this.userActualTimezone
);
}
const differentFromUserTimezone = computed((): boolean => {
return (
!!props.timezone &&
!!userActualTimezone.value &&
getTimezoneOffset(props.timezone, beginsOnDate.value) !==
getTimezoneOffset(userActualTimezone.value, beginsOnDate.value) &&
props.timezone !== userActualTimezone.value
);
});
get singleTimeZone(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Time in your timezone ({timezone})", {
timezone: this.timezoneToShow,
const singleTimeZone = computed((): string => {
if (showLocalTimezone.value) {
return t("Local time ({timezone})", {
timezone: timezoneToShow,
}) as string;
}
return t("Time in your timezone ({timezone})", {
timezone: timezoneToShow,
}) as string;
});
get multipleTimeZones(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Times in your timezone ({timezone})", {
timezone: this.timezoneToShow,
const multipleTimeZones = computed((): string => {
if (showLocalTimezone.value) {
return t("Local time ({timezone})", {
timezone: timezoneToShow,
}) as string;
}
}
return t("Times in your timezone ({timezone})", {
timezone: timezoneToShow,
}) as string;
});
</script>

View File

@@ -0,0 +1,143 @@
<template>
<Story title="EventListViewCard">
<Variant title="default">
<EventListViewCard :event="baseEvent" />
</Variant>
<Variant title="long">
<EventListViewCard :event="longEvent" />
</Variant>
<!-- <Variant title="tentative">
<EventListViewCard :event="tentativeEvent" />
</Variant>
<Variant title="cancelled">
<EventListViewCard :event="cancelledEvent" />
</Variant> -->
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import {
ActorType,
CommentModeration,
EventJoinOptions,
EventStatus,
EventVisibility,
} from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { reactive } from "vue";
import EventListViewCard from "./EventListViewCard.vue";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const baseActor: IActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
type: ActorType.PERSON,
};
const baseEvent: IEvent = {
uuid: "",
title: "A very interesting event",
description: "Things happen",
beginsOn: new Date().toISOString(),
endsOn: new Date().toISOString(),
physicalAddress: {
description: "Somewhere",
street: "",
locality: "",
region: "",
country: "",
type: "",
postalCode: "",
},
picture: {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
},
url: "",
local: true,
slug: "",
publishAt: new Date().toISOString(),
status: EventStatus.CONFIRMED,
visibility: EventVisibility.PUBLIC,
joinOptions: EventJoinOptions.FREE,
draft: false,
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 0,
moderator: 0,
administrator: 0,
going: 0,
},
participants: { total: 0, elements: [] },
relatedEvents: [],
tags: [{ slug: "something", title: "Something" }],
attributedTo: undefined,
organizerActor: {
...baseActor,
name: "Hello",
avatar: {
...baseActorAvatar,
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
},
},
comments: [],
options: {
maximumAttendeeCapacity: 0,
remainingAttendeeCapacity: 0,
showRemainingAttendeeCapacity: false,
anonymousParticipation: false,
hideOrganizerWhenGroupEvent: false,
offers: [],
participationConditions: [],
attendees: [],
program: "",
commentModeration: CommentModeration.ALLOW_ALL,
showParticipationPrice: false,
showStartTime: false,
showEndTime: false,
timezone: null,
isOnline: false,
},
metadata: [],
contacts: [],
language: "en",
category: "hello",
};
const longEvent = reactive<IEvent>({
...baseEvent,
title:
"A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so.",
});
// const tentativeEvent = reactive<IEvent>({
// ...baseEvent,
// status: EventStatus.TENTATIVE,
// });
// const cancelledEvent = reactive<IEvent>({
// ...baseEvent,
// status: EventStatus.CANCELLED,
// });
</script>

View File

@@ -1,169 +1,83 @@
<template>
<article class="box">
<div class="columns">
<div class="content column">
<div class="title-wrapper">
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" :small="true" />
</div>
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<h2 class="title">{{ event.title }}</h2>
</router-link>
</div>
<div class="participation-actor has-text-grey-dark">
<span v-if="event.physicalAddress && event.physicalAddress.locality">
{{ event.physicalAddress.locality }}
</span>
<span v-if="event.attributedTo && options.memberofGroup">
{{
$t("Created by {name}", {
name: usernameWithDomain(event.organizerActor),
})
}}
</span>
<span v-else-if="options.memberofGroup">
{{
$t("Organized by {name}", {
name: usernameWithDomain(event.organizerActor),
})
}}
</span>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon
icon="earth"
v-if="event.visibility === EventVisibility.PUBLIC"
/>
<b-icon
icon="link"
v-if="event.visibility === EventVisibility.UNLISTED"
/>
<b-icon
icon="lock"
v-if="event.visibility === EventVisibility.PRIVATE"
/>
</span>
<span class="column is-narrow participant-stats">
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$t("{approved} / {total} seats", {
approved: event.participantStats.participant,
total: event.options.maximumAttendeeCapacity,
})
}}
</span>
<span v-else>
{{
$tc(
"{count} participants",
event.participantStats.participant,
{
count: event.participantStats.participant,
}
)
}}
</span>
</span>
</div>
<article
class="bg-white dark:bg-gray-700 dark:text-white dark:hover:text-white rounded-lg shadow-md max-w-3xl p-2"
>
<div class="flex gap-2">
<div class="">
<date-calendar-icon :date="event.beginsOn.toString()" :small="true" />
</div>
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<h2 class="text-2xl line-clamp-2">{{ event.title }}</h2>
</router-link>
</div>
<div class="">
<span v-if="event.physicalAddress && event.physicalAddress.locality">
{{ event.physicalAddress.locality }}
</span>
<span v-if="event.attributedTo">
{{
$t("Created by {name}", {
name: displayName(event.attributedTo),
})
}}
</span>
<span v-else>
{{
$t("Organized by {name}", {
name: displayName(event.organizerActor),
})
}}
</span>
</div>
<div class="flex gap-1">
<span>
<Earth v-if="event.visibility === EventVisibility.PUBLIC" />
<Link v-if="event.visibility === EventVisibility.UNLISTED" />
<Lock v-if="event.visibility === EventVisibility.PRIVATE" />
</span>
<span>
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$t("{approved} / {total} seats", {
approved: event.participantStats.participant,
total: event.options.maximumAttendeeCapacity,
})
}}
</span>
<span v-else>
{{
$t(
"{count} participants",
{
count: event.participantStats.participant,
},
event.participantStats.participant
)
}}
</span>
</span>
</div>
</article>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventCardOptions, IEvent } from "@/types/event.model";
import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { IPerson, usernameWithDomain } from "@/types/actor";
import { mixins } from "vue-class-component";
import ActorMixin from "@/mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import EventMixin from "@/mixins/event";
import { EventVisibility, ParticipantRole } from "@/types/enums";
import { displayName } from "@/types/actor";
import { EventVisibility } from "@/types/enums";
import RouteName from "../../router/name";
import Earth from "vue-material-design-icons/Earth.vue";
import Link from "vue-material-design-icons/Link.vue";
import Lock from "vue-material-design-icons/Lock.vue";
const defaultOptions: IEventCardOptions = {
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
memberofGroup: false,
};
@Component({
components: {
DateCalendarIcon,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
},
})
export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
/**
* The participation associated
*/
@Prop({ required: true }) event!: IEvent;
/**
* Options are merged with default options
*/
@Prop({ required: false, default: () => defaultOptions })
options!: IEventCardOptions;
currentActor!: IPerson;
ParticipantRole = ParticipantRole;
EventVisibility = EventVisibility;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
withDefaults(defineProps<{ event: IEvent; options?: IEventCardOptions }>(), {
options: (): IEventCardOptions => ({
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
}),
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
article.box {
div.content {
padding: 5px;
.participation-actor span,
.participant-stats span {
padding: 0 5px;
button {
height: auto;
padding-top: 0;
}
}
div.title-wrapper {
display: flex;
align-items: center;
div.date-component {
flex: 0;
@include margin-right(16px);
}
.title {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 400;
line-height: 1em;
font-size: 1.6em;
padding-bottom: 5px;
margin: auto 0;
}
}
}
}
</style>

View File

@@ -6,6 +6,7 @@
<div class="modal-card-body">
<section class="map">
<map-leaflet
v-if="physicalAddress?.geom"
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
@@ -15,7 +16,7 @@
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<p class="address" v-if="physicalAddress?.fullName">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
@@ -66,11 +67,10 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { Address, IAddress } from "@/types/address.model";
import { RoutingTransportationType, RoutingType } from "@/types/enums";
import { PropType } from "vue";
import { Component, Vue, Prop } from "vue-property-decorator";
import { computed } from "vue";
const RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
@@ -87,77 +87,73 @@ const RoutingParamType = {
},
};
@Component({
components: {
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
},
})
export default class EventMap extends Vue {
@Prop({ type: Object as PropType<IAddress> }) address!: IAddress;
@Prop({ type: String }) routingType!: RoutingType;
const MapLeaflet = import("../../components/Map.vue");
get physicalAddress(): Address | null {
if (!this.address) return null;
const props = defineProps<{
address: IAddress;
routingType: RoutingType;
}>();
return new Address(this.address);
}
const physicalAddress = computed((): Address | null => {
if (!props.address) return null;
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
/**
* build urls to routing map
*/
if (!RoutingParamType[this.routingType][transportationType]) {
return;
}
return new Address(props.address);
});
const urlGeometry = geometry.split(";").reverse().join(",");
const makeNavigationPath = (
transportationType: RoutingTransportationType
): string | undefined => {
const geometry = physicalAddress.value?.geom;
if (geometry) {
/**
* build urls to routing map
*/
if (!RoutingParamType[props.routingType][transportationType]) {
return;
}
switch (this.routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}#map=14/${bboxX}/${bboxY}`;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (props.routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
RoutingParamType[props.routingType][transportationType]
}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
RoutingParamType[props.routingType][transportationType]
}#map=14/${bboxX}/${bboxY}`;
}
}
}
};
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
const addressLinkToRouteByCar = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.CAR);
});
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
const addressLinkToRouteByBike = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.BIKE);
});
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
const addressLinkToRouteByFeet = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.FOOT);
});
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
}
const addressLinkToRouteByTransit = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.TRANSIT);
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.modal-card-head {
justify-content: flex-end;
button.delete {
@include margin-right(1rem);
}
// button.delete {
// @include margin-right(1rem);
// }
}
section.map {

View File

@@ -1,9 +1,10 @@
<template>
<div>
<h2>{{ title }}</h2>
<div class="eventMetadataBlock">
<h2 class="text-2xl">{{ title }}</h2>
<div class="flex items-center mb-3 gap-1 eventMetadataBlock">
<slot name="icon"></slot>
<!-- Custom icons -->
<span
<!-- <span
class="icon is-medium"
v-if="icon && icon.substring(0, 7) === 'mz:icon'"
>
@@ -13,30 +14,19 @@
height="32"
/>
</span>
<b-icon v-else-if="icon" :icon="icon" size="is-medium" />
<div class="content-wrapper" :class="{ 'padding-left': icon }">
<o-icon v-else-if="icon" :icon="icon" size="is-medium" /> -->
<div class="content-wrapper">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class EventMetadataBlock extends Vue {
@Prop({ required: false, type: String }) icon!: string;
@Prop({ required: true, type: String }) title!: string;
}
<script lang="ts" setup>
defineProps<{
title: string;
}>();
</script>
<style lang="scss" scoped>
h2 {
font-size: 1.8rem;
font-weight: 500;
color: $violet;
}
div.eventMetadataBlock {
display: flex;
align-items: center;

View File

@@ -1,142 +1,143 @@
<template>
<div class="card card-content">
<div class="media">
<div class="media-left">
<div
class="block p-4 bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"
>
<div class="flex flex-wrap gap-1 w-full items-center">
<div class="">
<img
v-if="
metadataItem.icon && metadataItem.icon.substring(0, 7) === 'mz:icon'
modelValue.icon && modelValue.icon.substring(0, 7) === 'mz:icon'
"
:src="`/img/${metadataItem.icon.substring(8)}_monochrome.svg`"
:src="`/img/${modelValue.icon.substring(8)}_monochrome.svg`"
width="24"
height="24"
alt=""
/>
<b-icon v-else-if="metadataItem.icon" :icon="metadataItem.icon" />
<b-icon v-else icon="help-circle" />
<o-icon
v-else-if="modelValue.icon"
:icon="modelValue.icon"
customSize="24"
/>
<o-icon v-else icon="help-circle" customSize="24" />
</div>
<div class="media-content">
<b>{{ metadataItem.title || metadataItem.label }}</b>
<div class="flex-1">
<b>{{ modelValue.title || modelValue.label }}</b>
<br />
<small>
{{ metadataItem.description }}
{{ modelValue.description }}
</small>
<div
v-if="
metadataItem.type === EventMetadataType.STRING &&
metadataItem.keyType === EventMetadataKeyType.CHOICE &&
metadataItem.choices
modelValue.type === EventMetadataType.STRING &&
modelValue.keyType === EventMetadataKeyType.CHOICE &&
modelValue.choices
"
>
<b-field v-for="(value, key) in metadataItem.choices" :key="key">
<b-radio v-model="metadataItemValue" :native-value="key">{{
<o-field v-for="(value, key) in modelValue.choices" :key="key">
<o-radio v-model="metadataItemValue" :native-value="key">{{
value
}}</b-radio>
</b-field>
}}</o-radio>
</o-field>
</div>
<b-field
<o-field
v-else-if="
metadataItem.type === EventMetadataType.STRING &&
metadataItem.keyType == EventMetadataKeyType.URL
modelValue.type === EventMetadataType.STRING &&
modelValue.keyType == EventMetadataKeyType.URL
"
>
<b-input
<o-input
@blur="validatePattern"
ref="urlInput"
type="url"
:pattern="
metadataItem.pattern ? metadataItem.pattern.source : undefined
modelValue.pattern ? modelValue.pattern.source : undefined
"
:validation-message="$t(`This URL doesn't seem to be valid`)"
required
v-model="metadataItemValue"
:placeholder="metadataItem.placeholder"
:placeholder="modelValue.placeholder"
/>
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.STRING">
<b-input
</o-field>
<o-field v-else-if="modelValue.type === EventMetadataType.STRING">
<o-input
v-model="metadataItemValue"
:placeholder="metadataItem.placeholder"
:placeholder="modelValue.placeholder"
/>
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.INTEGER">
<b-numberinput v-model="metadataItemValue" />
</b-field>
<b-field v-else-if="metadataItem.type === EventMetadataType.BOOLEAN">
<b-checkbox v-model="metadataItemValue">
</o-field>
<o-field v-else-if="modelValue.type === EventMetadataType.INTEGER">
<o-input type="number" v-model="metadataItemValue" />
</o-field>
<o-field v-else-if="modelValue.type === EventMetadataType.BOOLEAN">
<o-checkbox v-model="metadataItemValue">
{{
metadataItemValue === "true"
? metadataItem.choices["true"]
: metadataItem.choices["false"]
? modelValue?.choices?.true
: modelValue?.choices?.false
}}
</b-checkbox>
</b-field>
</o-checkbox>
</o-field>
</div>
<b-button
<o-button
icon-left="close"
@click="$emit('removeItem', metadataItem.key)"
@click="$emit('removeItem', modelValue.key)"
/>
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
import { computed, ref } from "vue";
@Component
export default class EventMetadataItem extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
value!: IEventMetadataDescription;
const props = defineProps<{
modelValue: IEventMetadataDescription;
}>();
EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType;
const emit = defineEmits(["update:modelValue", "removeItem"]);
@Ref("urlInput") readonly urlInput!: any;
const urlInput = ref<any>(null);
get metadataItem(): IEventMetadataDescription {
return this.value;
}
get metadataItemValue(): string {
return this.metadataItem.value;
}
set metadataItemValue(value: string) {
if (this.validate(value)) {
this.$emit("input", { ...this.metadataItem, value: value.toString() });
const metadataItemValue = computed({
get(): string {
return props.modelValue.value;
},
set(value: string) {
if (validate(value)) {
emit("update:modelValue", {
...props.modelValue,
value: value.toString(),
});
}
}
},
});
validatePattern(): void {
this.urlInput.checkHtml5Validity();
}
const validatePattern = (): void => {
urlInput.value?.checkHtml5Validity();
};
private validate(value: string): boolean {
if (this.metadataItem.keyType === EventMetadataKeyType.URL) {
try {
const url = new URL(value);
if (!["http:", "https:", "mailto:"].includes(url.protocol))
return false;
if (this.metadataItem.pattern) {
return value.match(this.metadataItem.pattern) !== null;
}
} catch {
return false;
const validate = (value: string): boolean => {
if (props.modelValue.keyType === EventMetadataKeyType.URL) {
try {
const url = new URL(value);
if (!["http:", "https:", "mailto:"].includes(url.protocol)) return false;
if (props.modelValue.pattern) {
return value.match(props.modelValue.pattern) !== null;
}
} catch {
return false;
}
return true;
}
}
return true;
};
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.card .media {
align-items: center;
& > button {
@include margin-left(1rem);
}
// & > button {
// @include margin-left(1rem);
// }
}
</style>

View File

@@ -3,18 +3,18 @@
<div class="mb-4">
<div v-for="(item, index) in metadata" :key="item.key" class="my-2">
<event-metadata-item
:value="metadata[index]"
@input="updateSingleMetadata"
:modelValue="metadata[index]"
@update:modelValue="updateSingleMetadata"
@removeItem="removeItem"
/>
</div>
</div>
<b-field
<o-field
grouped
:label="$t('Find or add an element')"
label-for="event-metadata-autocomplete"
>
<b-autocomplete
<o-autocomplete
expanded
:clear-on-select="true"
v-model="search"
@@ -25,12 +25,12 @@
open-on-focus
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
id="event-metadata-autocomplete"
@select="(option) => addElement(option)"
@select="addElement"
dir="auto"
>
<template slot-scope="props">
<div class="media">
<div class="media-left">
<template v-slot="props">
<div class="dark:bg-violet-3 p-1 flex items-center gap-1">
<div class="">
<img
v-if="
props.option.icon &&
@@ -41,10 +41,10 @@
height="24"
alt=""
/>
<b-icon v-else-if="props.option.icon" :icon="props.option.icon" />
<b-icon v-else icon="help-circle" />
<o-icon v-else-if="props.option.icon" :icon="props.option.icon" />
<o-icon v-else icon="help-circle" />
</div>
<div class="media-content">
<div class="">
<b>{{ props.option.label }}</b>
<br />
<small>
@@ -56,14 +56,14 @@
<template #empty>{{
$t("No results for {search}", { search })
}}</template>
</b-autocomplete>
</o-autocomplete>
<p class="control">
<b-button @click="showNewElementModal = true">
<o-button @click="showNewElementModal = true">
{{ $t("Add new…") }}
</b-button>
</o-button>
</p>
</b-field>
<b-modal
</o-field>
<o-modal
has-modal-card
v-model="showNewElementModal"
:close-button-aria-label="$t('Close')"
@@ -78,147 +78,142 @@
</header>
<div class="modal-card-body">
<form @submit="addNewElement">
<b-field :label="$t('Element title')">
<b-input v-model="newElement.title" />
</b-field>
<b-field :label="$t('Element value')">
<b-input v-model="newElement.value" />
</b-field>
<b-button type="is-primary" native-type="submit">{{
<o-field :label="$t('Element title')">
<o-input v-model="newElement.title" />
</o-field>
<o-field :label="$t('Element value')">
<o-input v-model="newElement.value" />
</o-field>
<o-button variant="primary" native-type="submit">{{
$t("Add")
}}</b-button>
}}</o-button>
</form>
</div>
</div>
</b-modal>
</o-modal>
</section>
</template>
<script lang="ts">
import {
IEventMetadata,
IEventMetadataDescription,
} from "@/types/event-metadata";
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import cloneDeep from "lodash/cloneDeep";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed, reactive, ref } from "vue";
import EventMetadataItem from "./EventMetadataItem.vue";
import { eventMetaDataList } from "../../services/EventMetadata";
import { EventMetadataCategories, EventMetadataType } from "@/types/enums";
import { useI18n } from "vue-i18n";
type GroupedIEventMetadata = Array<{
category: string;
items: IEventMetadata[];
items: IEventMetadataDescription[];
}>;
@Component({
components: {
EventMetadataItem,
},
})
export default class EventMetadataList extends Vue {
@Prop({ type: Array as PropType<Array<IEventMetadata>>, required: true })
value!: IEventMetadata[];
const props = defineProps<{
modelValue: IEventMetadataDescription[];
}>();
newElement = {
title: "",
value: "",
};
const emit = defineEmits(["update:modelValue"]);
search = "";
const newElement = reactive({
title: "",
value: "",
});
data: IEventMetadataDescription[] = eventMetaDataList;
const { t } = useI18n({ useScope: "global" });
showNewElementModal = false;
const search = ref("");
get metadata(): IEventMetadata[] {
return this.value.map((val) => {
const def = this.data.find((dat) => dat.key === val.key);
const data: IEventMetadataDescription[] = eventMetaDataList;
const showNewElementModal = ref(false);
const metadata = computed({
get(): IEventMetadataDescription[] {
return props.modelValue.map((val) => {
const def = data.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
}) as any[];
}
set metadata(metadata: IEventMetadata[]) {
this.$emit(
"input",
},
set(metadata: IEventMetadataDescription[]) {
emit(
"update:modelValue",
metadata.filter((elem) => elem)
);
}
},
});
localizedCategories: Record<EventMetadataCategories, string> = {
[EventMetadataCategories.ACCESSIBILITY]: this.$t("Accessibility") as string,
[EventMetadataCategories.LIVE]: this.$t("Live") as string,
[EventMetadataCategories.REPLAY]: this.$t("Replay") as string,
[EventMetadataCategories.TOOLS]: this.$t("Tools") as string,
[EventMetadataCategories.SOCIAL]: this.$t("Social") as string,
[EventMetadataCategories.DETAILS]: this.$t("Details") as string,
[EventMetadataCategories.BOOKING]: this.$t("Booking") as string,
[EventMetadataCategories.VIDEO_CONFERENCE]: this.$t(
"Video Conference"
) as string,
};
const localizedCategories: Record<EventMetadataCategories, string> = {
[EventMetadataCategories.ACCESSIBILITY]: t("Accessibility") as string,
[EventMetadataCategories.LIVE]: t("Live") as string,
[EventMetadataCategories.REPLAY]: t("Replay") as string,
[EventMetadataCategories.TOOLS]: t("Tools") as string,
[EventMetadataCategories.SOCIAL]: t("Social") as string,
[EventMetadataCategories.DETAILS]: t("Details") as string,
[EventMetadataCategories.BOOKING]: t("Booking") as string,
[EventMetadataCategories.VIDEO_CONFERENCE]: t("Video Conference") as string,
};
get filteredDataArray(): GroupedIEventMetadata {
return this.data
.filter((option) => {
return (
option.label
.toString()
.toLowerCase()
.indexOf(this.search.toLowerCase()) >= 0
);
})
.filter(({ key }) => {
return !this.metadata.map(({ key: key2 }) => key2).includes(key);
})
.reduce(
(acc: GroupedIEventMetadata, current: IEventMetadataDescription) => {
const group = acc.find(
(elem) =>
elem.category === this.localizedCategories[current.category]
);
if (group) {
group.items.push(current);
} else {
acc.push({
category: this.localizedCategories[current.category],
items: [current],
});
}
return acc;
},
[]
const filteredDataArray = computed((): GroupedIEventMetadata => {
return data
.filter((option) => {
return (
option.label
.toString()
.toLowerCase()
.indexOf(search.value.toLowerCase()) >= 0
);
}
})
.filter(({ key }) => {
return !metadata.value.map(({ key: key2 }) => key2).includes(key);
})
.reduce(
(acc: GroupedIEventMetadata, current: IEventMetadataDescription) => {
const group = acc.find(
(elem) => elem.category === localizedCategories[current.category]
);
if (group) {
group.items.push(current);
} else {
acc.push({
category: localizedCategories[current.category],
items: [current],
});
}
return acc;
},
[]
);
});
updateSingleMetadata(element: IEventMetadataDescription): void {
const metadataClone = cloneDeep(this.metadata);
const index = metadataClone.findIndex((elem) => elem.key === element.key);
metadataClone.splice(index, 1, element);
this.$emit("input", metadataClone);
}
const updateSingleMetadata = (element: IEventMetadataDescription): void => {
const metadataClone = cloneDeep(metadata.value);
const index = metadataClone.findIndex((elem) => elem.key === element.key);
metadataClone.splice(index, 1, element);
emit("update:modelValue", metadataClone);
};
removeItem(itemKey: string): void {
const metadataClone = cloneDeep(this.metadata);
const index = metadataClone.findIndex((elem) => elem.key === itemKey);
metadataClone.splice(index, 1);
this.$emit("input", metadataClone);
}
const removeItem = (itemKey: string): void => {
const metadataClone = cloneDeep(metadata.value);
const index = metadataClone.findIndex((elem) => elem.key === itemKey);
metadataClone.splice(index, 1);
emit("update:modelValue", metadataClone);
};
addElement(element: IEventMetadata): void {
this.metadata = [...this.metadata, element];
}
const addElement = (element: IEventMetadataDescription): void => {
metadata.value = [...metadata.value, element];
};
addNewElement(e: Event): void {
e.preventDefault();
this.addElement({
...this.newElement,
type: EventMetadataType.STRING,
key: `mz:plain:${(Math.random() + 1).toString(36).substring(7)}`,
});
this.showNewElementModal = false;
}
}
const addNewElement = (e: Event): void => {
e.preventDefault();
addElement({
...newElement,
type: EventMetadataType.STRING,
key: `mz:plain:${(Math.random() + 1).toString(36).substring(7)}`,
category: EventMetadataCategories.DETAILS,
label: "",
});
showNewElementModal.value = false;
};
</script>

View File

@@ -9,25 +9,25 @@
<span v-if="!physicalAddress">{{ $t("No address defined") }}</span>
<div class="address" v-if="physicalAddress">
<address-info :address="physicalAddress" />
<b-button
<o-button
type="is-text"
class="map-show-button"
@click="$emit('showMapModal', true)"
v-if="physicalAddress.geom"
>
{{ $t("Show map") }}
</b-button>
</o-button>
</div>
</div>
</event-metadata-block>
<event-metadata-block :title="$t('Date and time')" icon="calendar">
<event-full-date
:beginsOn="event.beginsOn"
:beginsOn="event.beginsOn.toString()"
:show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime"
:timezone="event.options.timezone"
:timezone="event.options.timezone ?? undefined"
:userTimezone="userTimezone"
:endsOn="event.endsOn"
:endsOn="event.endsOn?.toString()"
/>
</event-metadata-block>
<event-metadata-block
@@ -52,7 +52,11 @@
:inline="true"
/>
</router-link>
<actor-card v-else :actor="event.organizerActor" :inline="true" />
<actor-card
v-else-if="event.organizerActor"
:actor="event.organizerActor"
:inline="true"
/>
<actor-card
:inline="true"
:actor="contact"
@@ -129,107 +133,84 @@
</event-metadata-block>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { Address } from "@/types/address.model";
import { IConfig } from "@/types/config.model";
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed } from "vue";
import RouteName from "../../router/name";
import { usernameWithDomain } from "../../types/actor";
import EventMetadataBlock from "./EventMetadataBlock.vue";
import EventFullDate from "./EventFullDate.vue";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import {
IEventMetadata,
IEventMetadataDescription,
} from "@/types/event-metadata";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";
import { IUser } from "@/types/current-user.model";
@Component({
components: {
EventMetadataBlock,
EventFullDate,
PopoverActorCard,
ActorCard,
AddressInfo,
},
})
export default class EventMetadataSidebar extends Vue {
@Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent;
@Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig;
@Prop({ required: true }) user!: IUser | undefined;
@Prop({ required: false, default: false }) showMap!: boolean;
const props = withDefaults(
defineProps<{
event: IEvent;
user: IUser | undefined;
showMap?: boolean;
}>(),
{ showMap: false }
);
RouteName = RouteName;
const physicalAddress = computed((): Address | null => {
if (!props.event.physicalAddress) return null;
usernameWithDomain = usernameWithDomain;
return new Address(props.event.physicalAddress);
});
eventMetaDataList = eventMetaDataList;
const extraMetadata = computed((): IEventMetadataDescription[] => {
return props.event.metadata.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
});
});
EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType;
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
const urlToHostname = (url: string | undefined): string | null => {
if (!url) return null;
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
};
get extraMetadata(): IEventMetadata[] {
return this.event.metadata.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
});
const simpleURL = (url: string): string | null => {
try {
const uri = new URL(url);
return `${removeWWW(uri.hostname)}${uri.pathname}${uri.search}${uri.hash}`;
} catch (e) {
return null;
}
};
urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e) {
return null;
const removeWWW = (string: string): string => {
return string.replace(/^www./, "");
};
const accountURL = (extra: IEventMetadataDescription): string | undefined => {
switch (extra.key) {
case "mz:social:twitter:account": {
const handle =
extra.value[0] === "@" ? extra.value.slice(1) : extra.value;
return `https://twitter.com/${handle}`;
}
}
};
simpleURL(url: string): string | null {
try {
const uri = new URL(url);
return `${this.removeWWW(uri.hostname)}${uri.pathname}${uri.search}${
uri.hash
}`;
} catch (e) {
return null;
}
}
private removeWWW(string: string): string {
return string.replace(/^www./, "");
}
accountURL(extra: IEventMetadataDescription): string | undefined {
switch (extra.key) {
case "mz:social:twitter:account": {
const handle =
extra.value[0] === "@" ? extra.value.slice(1) : extra.value;
return `https://twitter.com/${handle}`;
}
}
}
get userTimezone(): string | undefined {
return this.user?.settings?.timezone;
}
}
const userTimezone = computed((): string | undefined => {
return props.user?.settings?.timezone;
});
</script>
<style lang="scss" scoped>
::v-deep .metadata-organized-by {
:deep(.metadata-organized-by) {
.v-popover.popover .trigger {
width: 100%;
.media-content {

View File

@@ -1,32 +1,36 @@
<template>
<router-link
class="event-minimalist-card-wrapper bg-white rounded-lg shadow-md"
class="block md:flex gap-x-2 gap-y-3 bg-white dark:bg-violet-2 rounded-lg shadow-md w-full"
dir="auto"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" :small="true" />
<div class="relative w-full">
<div class="flex absolute bottom-2 left-2 z-10 date-component">
<date-calendar-icon :date="event.beginsOn.toString()" :small="true" />
</div>
<lazy-image-wrapper
:picture="event.picture"
:rounded="true"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
class="object-cover flex-none h-32 md:w-48 rounded-t-lg md:rounded-none md:rounded-l-lg"
/>
</div>
</div>
<div class="title-info-wrapper has-text-grey-dark">
<h3 class="event-minimalist-title" :lang="event.language" dir="auto">
<div class="title-info-wrapper p-2">
<h3
class="event-minimalist-title pb-2 text-lg leading-6 line-clamp-3 font-bold text-violet-title dark:text-white"
:lang="event.language"
dir="auto"
>
<b-tag
type="is-info"
variant="info"
class="mr-1"
v-if="event.status === EventStatus.TENTATIVE"
>
{{ $t("Tentative") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
class="mr-1"
v-if="event.status === EventStatus.CANCELLED"
>
@@ -34,7 +38,7 @@
</b-tag>
<b-tag
class="mr-2"
type="is-warning"
variant="warning"
size="is-medium"
v-if="event.draft"
>{{ $t("Draft") }}</b-tag
@@ -50,47 +54,51 @@
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
>
<b-icon icon="video" />
<Video />
<span>{{ $t("Online") }}</span>
</div>
<div class="event-subtitle event-organizer" v-if="showOrganizer">
<div class="event-subtitle event-organizer flex" v-if="showOrganizer">
<figure
class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
v-if="organizer(event) && organizer(event)?.avatar"
>
<img class="is-rounded" :src="organizer(event).avatar.url" alt="" />
<img class="is-rounded" :src="organizer(event)?.avatar?.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
<AccountCircle v-else />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<p class="participant-metadata">
<b-icon icon="account-multiple" />
<p class="flex gap-1">
<AccountMultiple />
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$tc(
$t(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
},
event.options.maximumAttendeeCapacity -
event.participantStats.participant
)
}}
</span>
<span v-else>
{{
$tc("{count} participants", event.participantStats.participant, {
count: event.participantStats.participant,
})
$t(
"{count} participants",
{
count: event.participantStats.participant,
},
event.participantStats.participant
)
}}
</span>
<span v-if="event.participantStats.notApproved > 0">
<b-button
<o-button
type="is-text"
@click="
gotToWithCheck(participation, {
@@ -101,105 +109,92 @@
"
>
{{
$tc(
$t(
"{count} requests waiting",
event.participantStats.notApproved,
{
count: event.participantStats.notApproved,
}
},
event.participantStats.notApproved
)
}}
</b-button>
</o-button>
</span>
</p>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { IEvent, organizer, organizerDisplayName } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { EventStatus, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import InlineAddress from "@/components/Address/InlineAddress.vue";
import Video from "vue-material-design-icons/Video.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
InlineAddress,
},
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
RouteName = RouteName;
ParticipantRole = ParticipantRole;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
EventStatus = EventStatus;
}
withDefaults(
defineProps<{
event: IEvent;
showOrganizer?: boolean;
}>(),
{ showOrganizer: false }
);
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
@import "@/variables.scss";
// @import "node_modules/bulma/sass/utilities/mixins.sass";
// @import "@/variables.scss";
.event-minimalist-card-wrapper {
display: grid;
grid-gap: 5px 10px;
// display: grid;
// grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
color: initial;
// color: initial;
@include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
}
// @include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
// }
.event-preview {
& > div {
position: relative;
height: 120px;
width: 100%;
// .event-preview {
// & > div {
// position: relative;
// height: 120px;
// width: 100%;
div.date-component {
display: flex;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
}
}
// div.date-component {
// display: flex;
// position: absolute;
// bottom: 5px;
// left: 5px;
// z-index: 1;
// }
// }
// }
.calendar-icon {
@include margin-right(1rem);
}
// .calendar-icon {
// @include margin-right(1rem);
// }
.title-info-wrapper {
flex: 2;
.event-minimalist-title {
padding-bottom: 5px;
font-size: 18px;
line-height: 24px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: bold;
color: $title-color;
}
// .event-minimalist-title {
// padding-bottom: 5px;
// font-size: 18px;
// line-height: 24px;
// display: -webkit-box;
// -webkit-line-clamp: 3;
// -webkit-box-orient: vertical;
// overflow: hidden;
// font-weight: bold;
// color: $title-color;
// }
::v-deep .icon {
:deep(.icon) {
vertical-align: middle;
}
}

View File

@@ -1,7 +1,13 @@
<template>
<article class="box mb-5 mt-4">
<div class="identity-header" dir="auto">
<figure class="image is-24x24" v-if="participation.actor.avatar">
<article class="bg-white dark:bg-mbz-purple mb-5 mt-4 p-0">
<div
class="bg-mbz-yellow-2 flex p-2 text-violet-title rounded-t-lg"
dir="auto"
>
<figure
class="image is-24x24 ltr:pr-1 rtl:pl-1"
v-if="participation.actor.avatar"
>
<img
class="is-rounded"
:src="participation.actor.avatar.url"
@@ -10,20 +16,23 @@
width="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
<AccountCircle class="ltr:pr-1 rtl:pl-1" v-else />
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card">
<div class="content-and-actions">
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<div class="list-card flex flex-col relative">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-x-1.5 gapt-x-3"
>
<div class="mr-0 ml-0">
<div class="h-36 relative w-full">
<div class="flex absolute bottom-2 left-2 z-10">
<date-calendar-icon
:date="participation.event.beginsOn"
:date="participation.event.beginsOn.toString()"
:small="true"
/>
</div>
<router-link
class="h-full"
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
@@ -43,10 +52,10 @@
</router-link>
</div>
</div>
<div class="list-card-content">
<div class="title-wrapper" dir="auto">
<div class="list-card-content lg:col-span-2 flex-1 p-2">
<div class="flex items-center pt-2" dir="auto">
<b-tag
type="is-info"
variant="info"
class="mr-1 mb-1"
size="is-medium"
v-if="participation.event.status === EventStatus.TENTATIVE"
@@ -54,7 +63,7 @@
{{ $t("Tentative") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
class="mr-1 mb-1"
size="is-medium"
v-if="participation.event.status === EventStatus.CANCELLED"
@@ -67,52 +76,46 @@
params: { uuid: participation.event.uuid },
}"
>
<h3 class="title" :lang="participation.event.language">
<h3
class="line-clamp-3 font-bold mx-auto my-0 text-lg text-violet-title dark:text-white"
:lang="participation.event.language"
>
{{ participation.event.title }}
</h3>
</router-link>
</div>
<inline-address
v-if="participation.event.physicalAddress"
class="event-subtitle"
:physical-address="participation.event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="
participation.event.options &&
participation.event.options.isOnline
"
>
<b-icon icon="video" />
<Video />
<span>{{ $t("Online") }}</span>
</div>
<div class="event-subtitle event-organizer">
<figure
class="image is-24x24"
v-if="
organizer(participation.event) &&
organizer(participation.event).avatar
"
>
<div class="flex gap-1">
<figure class="" v-if="actorAvatarURL">
<img
class="is-rounded"
:src="organizer(participation.event).avatar.url"
class="rounded"
:src="actorAvatarURL"
alt=""
width="24"
height="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
<AccountCircle v-else />
<span>
{{ organizerDisplayName(participation.event) }}
</span>
</div>
<div class="event-subtitle event-participants">
<b-icon
:class="{ 'has-text-danger': lastSeatsLeft }"
icon="account-group"
/>
<div class="flex">
<AccountGroup :class="{ 'has-text-danger': lastSeatsLeft }" />
<span
class="participant-stats"
class="flex items-center py-0 px-2"
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
>
<!-- Less than 10 seats left -->
@@ -129,32 +132,32 @@
"
>
{{
$tc(
$t(
"{available}/{capacity} available places",
participation.event.options.maximumAttendeeCapacity -
participation.event.participantStats.participant,
{
available:
participation.event.options.maximumAttendeeCapacity -
participation.event.participantStats.participant,
capacity:
participation.event.options.maximumAttendeeCapacity,
}
},
participation.event.options.maximumAttendeeCapacity -
participation.event.participantStats.participant
)
}}
</span>
<span v-else>
{{
$tc(
$t(
"{count} participants",
participation.event.participantStats.participant,
{
count: participation.event.participantStats.participant,
}
},
participation.event.participantStats.participant
)
}}
</span>
<b-button
<o-button
v-if="participation.event.participantStats.notApproved > 0"
type="is-text"
@click="
@@ -166,32 +169,36 @@
"
>
{{
$tc(
$t(
"{count} requests waiting",
participation.event.participantStats.notApproved,
{
count: participation.event.participantStats.notApproved,
}
},
participation.event.participantStats.notApproved
)
}}
</b-button>
</o-button>
</span>
</div>
</div>
<div class="actions">
<b-dropdown aria-role="list" position="is-bottom-left">
<b-button slot="trigger" role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
<o-dropdown aria-role="list">
<template #trigger>
<o-button icon-right="dots-horizontal">
{{ $t("Actions") }}
</o-button>
</template>
<o-dropdown-item
aria-role="listitem"
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT,
@@ -199,13 +206,17 @@
})
"
>
<b-icon icon="pencil" />
<Pencil />
{{ $t("Edit") }}
</b-dropdown-item>
</div>
</o-dropdown-item>
<b-dropdown-item
v-if="participation.role === ParticipantRole.CREATOR"
aria-role="listitem"
<o-dropdown-item
aria-role="listitem"
v-if="participation.role === ParticipantRole.CREATOR"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.DUPLICATE_EVENT,
@@ -213,32 +224,37 @@
})
"
>
<b-icon icon="content-duplicate" />
<ContentDuplicate />
{{ $t("Duplicate") }}
</b-dropdown-item>
</div>
</o-dropdown-item>
<b-dropdown-item
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
@click="openDeleteEventModalWrapper"
>
<b-icon icon="delete" />
<o-dropdown-item
aria-role="listitem"
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
>
<div @click="openDeleteEventModalWrapper" class="flex gap-1">
<Delete />
{{ $t("Delete") }}
</b-dropdown-item>
</div>
</o-dropdown-item>
<b-dropdown-item
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
aria-role="listitem"
<o-dropdown-item
aria-role="listitem"
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
@@ -246,304 +262,383 @@
})
"
>
<b-icon icon="account-multiple-plus" />
<AccountMultiplePlus />
{{ $t("Manage participations") }}
</b-dropdown-item>
</div>
</o-dropdown-item>
<b-dropdown-item aria-role="listitem" has-link>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<b-icon icon="view-compact" />
{{ $t("View event page") }}
</router-link>
</b-dropdown-item>
</b-dropdown>
</div>
<o-dropdown-item aria-role="listitem">
<router-link
class="flex gap-1"
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<ViewCompact />
{{ $t("View event page") }}
</router-link>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
</article>
</template>
<script lang="ts">
import { Component, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { mixins } from "vue-class-component";
import { RawLocation, Route } from "vue-router";
import { EventStatus, EventVisibility, ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IParticipant } from "@/types/participant.model";
import {
IEvent,
IEventCardOptions,
organizer,
organizerAvatarUrl,
organizerDisplayName,
} from "../../types/event.model";
import { displayNameAndUsername, IActor, IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import EventMixin from "../../mixins/event";
import RouteName from "../../router/name";
import { changeIdentity } from "../../utils/auth";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
} from "@/types/event.model";
import { displayNameAndUsername, IActor, IPerson } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import RouteName from "@/router/name";
import { changeIdentity } from "@/utils/identity";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import InlineAddress from "@/components/Address/InlineAddress.vue";
import { PropType } from "vue";
import { RouteLocationRaw, useRouter } from "vue-router";
import Pencil from "vue-material-design-icons/Pencil.vue";
import ContentDuplicate from "vue-material-design-icons/ContentDuplicate.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
import ViewCompact from "vue-material-design-icons/ViewCompact.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import Video from "vue-material-design-icons/Video.vue";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import { computed, inject } from "vue";
import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { Dialog } from "@/plugins/dialog";
import { Snackbar } from "@/plugins/snackbar";
import { useDeleteEvent } from "@/composition/apollo/event";
const defaultOptions: IEventCardOptions = {
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
memberofGroup: false,
};
@Component({
components: {
DateCalendarIcon,
PopoverActorCard,
LazyImageWrapper,
InlineAddress,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
const props = withDefaults(
defineProps<{
participation: IParticipant;
options: IEventCardOptions;
}>(),
{
options: () => ({
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
}),
}
);
const emit = defineEmits(["eventDeleted"]);
const { result: currentActorResult } = useQuery(CURRENT_ACTOR_CLIENT);
const currentActor = computed(() => currentActorResult.value?.currentActor);
const { t } = useI18n({ useScope: "global" });
const mergedOptions = computed<IEventCardOptions>(() => {
return { ...defaultOptions, ...props.options };
});
const dialog = inject<Dialog>("dialog");
const openDeleteEventModal = (
event: IEvent,
callback: (anEvent: IEvent) => any
): void => {
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
const participantsLength = event.participantStats.participant;
const prefix = participantsLength
? t(
"There are {participants} participants.",
{
participants: participantsLength,
},
participantsLength
)
: "";
dialog?.prompt({
variant: "danger",
title: t("Delete event"),
message: `${prefix}
${t(
"Are you sure you want to delete this event? This action cannot be reverted."
)}
<br><br>
${t('To confirm, type your event title "{eventTitle}"', {
eventTitle: event.title,
})}`,
confirmText: t("Delete {eventTitle}", {
eventTitle: event.title,
}),
inputAttrs: {
placeholder: event.title,
pattern: escapeRegExp(event.title),
},
},
})
export default class EventParticipationCard extends mixins(
ActorMixin,
EventMixin
) {
onConfirm: () => callback(event),
});
};
const { oruga } = useProgrammatic();
const snackbar = inject<Snackbar>("snackbar");
const {
mutate: deleteEvent,
onDone: onDeleteEventDone,
onError: onDeleteEventError,
} = useDeleteEvent();
onDeleteEventDone(() => {
/**
* The participation associated
* When the event corresponding has been deleted (by the organizer).
* A notification is already triggered.
*
* @type {string}
*/
@Prop({ required: true, type: Object as PropType<IParticipant> })
participation!: IParticipant;
emit("eventDeleted", props.participation.event.id);
/**
* Options are merged with default options
*/
@Prop({ required: false, default: () => defaultOptions })
options!: IEventCardOptions;
oruga.notification.open({
message: t("Event {eventTitle} deleted", {
eventTitle: props.participation.event.title,
}),
variant: "success",
position: "bottom-right",
duration: 5000,
});
});
currentActor!: IPerson;
onDeleteEventError((error) => {
snackbar?.open({
message: error.message,
variant: "danger",
position: "bottom",
});
ParticipantRole = ParticipantRole;
console.error(error);
});
EventVisibility = EventVisibility;
/**
* Delete the event
*/
const openDeleteEventModalWrapper = () => {
openDeleteEventModal(
props.participation.event,
deleteEvent(props.participation.event)
);
};
displayNameAndUsername = displayNameAndUsername;
const router = useRouter();
organizerDisplayName = organizerDisplayName;
organizer = organizer;
RouteName = RouteName;
EventStatus = EventStatus;
get mergedOptions(): IEventCardOptions {
return { ...defaultOptions, ...this.options };
const gotToWithCheck = async (
participation: IParticipant,
route: RouteLocationRaw
): Promise<any> => {
if (
participation.actor.id !== currentActor.value.id &&
participation.event.organizerActor
) {
const organizerActor = participation.event.organizerActor as IPerson;
await changeIdentity(organizerActor);
oruga.notification.open({
message: t(
"Current identity has been changed to {identityName} in order to manage this event.",
{
identityName: organizerActor.preferredUsername,
}
),
variant: "info",
position: "bottom-right",
duration: 5000,
});
}
return router.push(route);
};
/**
* Delete the event
*/
async openDeleteEventModalWrapper(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await this.openDeleteEventModal(this.participation.event);
const organizerActor = computed<IActor | undefined>(() => {
if (
props.participation.event.attributedTo &&
props.participation.event.attributedTo.id
) {
return props.participation.event.attributedTo;
}
return props.participation.event.organizerActor;
});
async gotToWithCheck(
participation: IParticipant,
route: RawLocation
): Promise<Route> {
if (
participation.actor.id !== this.currentActor.id &&
participation.event.organizerActor
) {
const organizerActor = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizerActor);
this.$buefy.notification.open({
message: this.$t(
"Current identity has been changed to {identityName} in order to manage this event.",
{
identityName: organizerActor.preferredUsername,
}
) as string,
type: "is-info",
position: "is-bottom-right",
duration: 5000,
});
}
return this.$router.push(route);
const seatsLeft = computed<number | null>(() => {
if (props.participation.event.options.maximumAttendeeCapacity > 0) {
return (
props.participation.event.options.maximumAttendeeCapacity -
props.participation.event.participantStats.participant
);
}
return null;
});
get organizerActor(): IActor | undefined {
if (
this.participation.event.attributedTo &&
this.participation.event.attributedTo.id
) {
return this.participation.event.attributedTo;
}
return this.participation.event.organizerActor;
const lastSeatsLeft = computed<boolean>(() => {
if (seatsLeft.value) {
return seatsLeft.value < 10;
}
return false;
});
get seatsLeft(): number | null {
if (this.participation.event.options.maximumAttendeeCapacity > 0) {
return (
this.participation.event.options.maximumAttendeeCapacity -
this.participation.event.participantStats.participant
);
}
return null;
}
const actorAvatarURL = computed<string | null>(() =>
organizerAvatarUrl(props.participation.event)
);
get lastSeatsLeft(): boolean {
if (this.seatsLeft) {
return this.seatsLeft < 10;
}
return false;
}
}
// export default class EventParticipationCard extends mixins(
// ActorMixin,
// EventMixin
// ) {
// }
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
// @import "node_modules/bulma/sass/utilities/mixins.sass";
article.box {
div.tag-container {
position: absolute;
top: 10px;
right: 0;
@include margin-left(-5px);
z-index: 10;
max-width: 40%;
// div.tag-container {
// position: absolute;
// top: 10px;
// right: 0;
// @include margin-left(-5px);
// z-index: 10;
// max-width: 40%;
span.tag {
margin: 5px auto;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 1);
/*word-break: break-all;*/
text-overflow: ellipsis;
overflow: hidden;
display: block;
/*text-align: right;*/
font-size: 1em;
/*padding: 0 1px;*/
line-height: 1.75em;
}
}
// span.tag {
// margin: 5px auto;
// box-shadow: 0 0 5px 0 rgba(0, 0, 0, 1);
// /*word-break: break-all;*/
// text-overflow: ellipsis;
// overflow: hidden;
// display: block;
// /*text-align: right;*/
// font-size: 1em;
// /*padding: 0 1px;*/
// line-height: 1.75em;
// }
// }
.list-card {
display: flex;
padding: 0 6px 0 0;
position: relative;
flex-direction: column;
// display: flex;
// padding: 0 6px 0 0;
// position: relative;
// flex-direction: column;
.content-and-actions {
display: grid;
grid-gap: 5px 10px;
// display: grid;
// grid-gap: 5px 10px;
grid-template-areas: "preview" "body" "actions";
@include tablet {
grid-template-columns: 1fr 3fr;
grid-template-areas: "preview body" "actions actions";
}
// @include tablet {
// grid-template-columns: 1fr 3fr;
// grid-template-areas: "preview body" "actions actions";
// }
@include desktop {
grid-template-columns: 1fr 3fr 1fr;
grid-template-areas: "preview body actions";
}
// @include desktop {
// grid-template-columns: 1fr 3fr 1fr;
// grid-template-areas: "preview body actions";
// }
.event-preview {
grid-area: preview;
& > div {
height: 128px;
width: 100%;
position: relative;
// width: 100%;
// position: relative;
div.date-component {
display: flex;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
// div.date-component {
// display: flex;
// position: absolute;
// bottom: 5px;
// left: 5px;
// z-index: 1;
// }
img {
width: 100%;
object-position: center;
object-fit: cover;
height: 100%;
}
// img {
// width: 100%;
// object-position: center;
// object-fit: cover;
// height: 100%;
// }
}
}
.actions {
padding: 7px;
cursor: pointer;
align-self: center;
justify-self: center;
// padding: 7px;
// cursor: pointer;
// align-self: center;
// justify-self: center;
grid-area: actions;
}
div.list-card-content {
flex: 1;
padding: 5px;
// flex: 1;
// padding: 5px;
grid-area: body;
.participant-stats {
display: flex;
align-items: center;
padding: 0 5px;
}
// .participant-stats {
// display: flex;
// align-items: center;
// padding: 0 5px;
// }
div.title-wrapper {
display: flex;
align-items: center;
padding-top: 5px;
// div.title-wrapper {
// display: flex;
// align-items: center;
// padding-top: 5px;
a {
text-decoration: none;
padding-bottom: 5px;
}
// a {
// text-decoration: none;
// padding-bottom: 5px;
// }
.title {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 18px;
line-height: 24px;
margin: auto 0;
font-weight: bold;
color: $title-color;
}
}
// .title {
// display: -webkit-box;
// -webkit-line-clamp: 3;
// -webkit-box-orient: vertical;
// overflow: hidden;
// font-size: 18px;
// line-height: 24px;
// margin: auto 0;
// font-weight: bold;
// color: $title-color;
// }
// }
}
}
}
.identity-header {
background: $yellow-2;
display: flex;
padding: 5px;
// .identity-header {
// background: $yellow-2;
// display: flex;
// padding: 5px;
figure,
span.icon {
@include padding-right(3px);
}
}
// figure,
// span.icon {
// @include padding-right(3px);
// }
// }
& > .columns {
padding: 1.25rem;
}
padding: 0;
// & > .columns {
// padding: 1.25rem;
// }
// padding: 0;
}
</style>

View File

@@ -1,56 +1,65 @@
<template>
<div class="address-autocomplete columns is-desktop">
<div class="column">
<b-field
<div class="address-autocomplete">
<div class="">
<o-field
:label-for="id"
expanded
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
:type="{ 'is-danger': fieldErrors }"
class="!-mt-2"
>
<template slot="label">
<template #label>
{{ actualLabel }}
<span
class="is-size-6 has-text-weight-normal"
v-if="gettingLocation"
>{{ $t("Getting location") }}</span
>{{ t("Getting location") }}</span
>
</template>
<p class="control" v-if="canShowLocateMeButton && !gettingLocation">
<b-button
<p class="control" v-if="canShowLocateMeButton">
<o-loading
:full-page="false"
v-model:active="gettingLocation"
:can-cancel="false"
:container="mapMarker?.$el"
/>
<o-button
ref="mapMarker"
icon-right="map-marker"
@click="locateMe"
:title="$t('Use my location')"
:title="t('Use my location')"
/>
</p>
<b-autocomplete
<o-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="fullName"
:placeholder="placeholderWithDefault"
:customFormatter="(elem: IAddress) => addressFullName(elem)"
:loading="isFetching"
@typing="fetchAsyncData"
:debounceTyping="debounceDelay"
@typing="asyncData"
:icon="canShowLocateMeButton ? null : 'map-marker'"
expanded
@select="updateSelected"
v-bind="$attrs"
:id="id"
:disabled="disabled"
dir="auto"
class="!mt-0"
>
<template #default="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" />
<o-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b
><br />
<small>{{ option.poiInfos.alternativeName }}</small>
</template>
<template #empty>
<span v-if="isFetching">{{ $t("Searching") }}</span>
<span v-if="isFetching">{{ t("Searching") }}</span>
<div v-else-if="queryText.length >= 3" class="is-enabled">
<span>{{
$t('No results for "{queryText}"', { queryText })
t('No results for "{queryText}"', { queryText })
}}</span>
<span>{{
$t(
t(
"You can try another search term or drag and drop the marker on the map",
{
queryText,
@@ -58,24 +67,24 @@
)
}}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- <button type="button" class="button is-primary">{{ t('Add') }}</button>-->
<!-- </p>-->
</div>
</template>
</b-autocomplete>
<b-button
</o-autocomplete>
<o-button
:disabled="!queryText"
@click="resetAddress"
class="reset-area"
icon-left="close"
:title="$t('Clear address field')"
:title="t('Clear address field')"
/>
</b-field>
</o-field>
<div
class="card"
v-if="!hideSelected && (selected.originId || selected.url)"
class="mt-2 p-2 rounded-lg shadow-md dark:bg-violet-3"
v-if="!hideSelected && (selected?.originId || selected?.url)"
>
<div class="card-content">
<div class="">
<address-info
:address="selected"
:show-icon="true"
@@ -86,7 +95,7 @@
</div>
</div>
<div
class="map column"
class="map"
v-if="!hideMap && selected && selected.geom && selected.poiInfos"
>
<map-leaflet
@@ -102,85 +111,245 @@
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { LatLng } from "leaflet";
import { Address, IAddress } from "../../types/address.model";
import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
<script lang="ts" setup>
import type { LatLng } from "leaflet";
import { Address, IAddress, addressFullName } from "../../types/address.model";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import { computed, ref, watch, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { useGeocodingAutocomplete } from "@/composition/apollo/config";
import { ADDRESS } from "@/graphql/address";
import { useReverseGeocode } from "@/composition/apollo/address";
import { useLazyQuery } from "@vue/apollo-composable";
const MapLeaflet = defineAsyncComponent(() => import("../Map.vue"));
@Component({
inheritAttrs: false,
components: {
AddressInfo,
const props = withDefaults(
defineProps<{
modelValue: IAddress | null;
label?: string;
userTimezone?: string;
disabled?: boolean;
hideMap?: boolean;
hideSelected?: boolean;
placeholder?: string;
}>(),
{
label: "",
disabled: false,
hideMap: false,
hideSelected: false,
}
);
const addressModalActive = ref(false);
const componentId = 0;
const emit = defineEmits(["update:modelValue"]);
const gettingLocationError = ref<string | null>(null);
const gettingLocation = ref(false);
const mapDefaultZoom = ref(15);
const addressData = ref<IAddress[]>([]);
const selected = ref<IAddress | null>(null);
const isFetching = ref(false);
const mapMarker = ref();
const placeholderWithDefault = computed(
() => props.placeholder ?? t("e.g. 10 Rue Jangot")
);
// created(): void {
// componentId += 1;
// }
const id = computed((): string => {
return `full-address-autocomplete-${componentId}`;
});
const modelValue = computed(() => props.modelValue);
watch(modelValue, () => {
if (!modelValue.value) return;
selected.value = modelValue.value;
});
const updateSelected = (option: IAddress): void => {
if (option == null) return;
selected.value = option;
emit("update:modelValue", selected.value);
};
const resetPopup = (): void => {
selected.value = new Address();
};
const openNewAddressModal = (): void => {
resetPopup();
addressModalActive.value = true;
};
const checkCurrentPosition = (e: LatLng): boolean => {
if (!selected.value?.geom) return false;
const lat = parseFloat(selected.value?.geom.split(";")[1]);
const lon = parseFloat(selected.value?.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
};
const { t, locale } = useI18n({ useScope: "global" });
const actualLabel = computed((): string => {
return props.label ?? (t("Find an address") as string);
});
// eslint-disable-next-line class-methods-use-this
const canShowLocateMeButton = computed((): boolean => {
return window.isSecureContext;
});
const { geocodingAutocomplete } = useGeocodingAutocomplete();
const debounceDelay = computed(() =>
geocodingAutocomplete.value === true ? 200 : 2000
);
const { onResult: onAddressSearchResult, load: searchAddress } = useLazyQuery<{
searchAddress: IAddress[];
}>(ADDRESS);
onAddressSearchResult((result) => {
if (result.loading) return;
const { data } = result;
addressData.value = data.searchAddress.map(
(address: IAddress) => new Address(address)
);
isFetching.value = false;
});
const asyncData = async (query: string): Promise<void> => {
if (!query.length) {
addressData.value = [];
selected.value = new Address();
return;
}
if (query.length < 3) {
addressData.value = [];
return;
}
isFetching.value = true;
searchAddress(undefined, {
query,
locale: locale.value,
});
};
const queryText = computed({
get() {
return selected.value ? addressFullName(selected.value) : "";
},
})
export default class FullAddressAutoComplete extends Mixins(
AddressAutoCompleteMixin
) {
@Prop({ required: false, default: "" }) label!: string;
@Prop({ required: false }) userTimezone!: string;
@Prop({ required: false, default: false, type: Boolean }) disabled!: boolean;
@Prop({ required: false, default: false, type: Boolean }) hideMap!: boolean;
@Prop({ required: false, default: false, type: Boolean })
hideSelected!: boolean;
set(text) {
if (text === "" && selected.value?.id) {
console.log("doing reset");
resetAddress();
}
},
});
addressModalActive = false;
const resetAddress = (): void => {
emit("update:modelValue", null);
selected.value = new Address();
};
private static componentId = 0;
created(): void {
FullAddressAutoComplete.componentId += 1;
const locateMe = async (): Promise<void> => {
gettingLocation.value = true;
gettingLocationError.value = null;
try {
const location = await getLocation();
mapDefaultZoom.value = 12;
reverseGeoCode(
new LatLng(location.coords.latitude, location.coords.longitude),
12
);
} catch (e: any) {
gettingLocationError.value = e.message;
}
gettingLocation.value = false;
};
get id(): string {
return `full-address-autocomplete-${FullAddressAutoComplete.componentId}`;
const { onResult: onReverseGeocodeResult, load: loadReverseGeocode } =
useReverseGeocode();
onReverseGeocodeResult((result) => {
if (result.loading !== false) return;
const { data } = result;
addressData.value = data.reverseGeocode.map(
(elem: IAddress) => new Address(elem)
);
if (addressData.value.length > 0) {
const defaultAddress = new Address(addressData.value[0]);
selected.value = defaultAddress;
emit("update:modelValue", selected.value);
}
});
@Watch("value")
updateEditing(): void {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
}
const reverseGeoCode = (e: LatLng, zoom: number) => {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (checkCurrentPosition(e)) return;
updateSelected(option: IAddress): void {
if (option == null) return;
this.selected = option;
this.$emit("input", this.selected);
}
loadReverseGeocode(undefined, {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: locale.value as string,
});
};
resetPopup(): void {
this.selected = new Address();
}
// eslint-disable-next-line no-undef
const getLocation = async (): Promise<GeolocationPosition> => {
let errorMessage = t("Failed to get location.");
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error(errorMessage as string));
}
openNewAddressModal(): void {
this.resetPopup();
this.addressModalActive = true;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
switch (err.code) {
case GeolocationPositionError.PERMISSION_DENIED:
errorMessage = t("The geolocation prompt was denied.");
break;
case GeolocationPositionError.POSITION_UNAVAILABLE:
errorMessage = t("Your position was not available.");
break;
case GeolocationPositionError.TIMEOUT:
errorMessage = t("Geolocation was not determined in time.");
break;
default:
errorMessage = err.message;
}
reject(new Error(errorMessage as string));
}
);
});
};
checkCurrentPosition(e: LatLng): boolean {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
get actualLabel(): string {
return this.label || (this.$t("Find an address") as string);
}
// eslint-disable-next-line class-methods-use-this
get canShowLocateMeButton(): boolean {
return window.isSecureContext;
}
}
const fieldErrors = computed(() => {
return gettingLocationError.value;
});
</script>
<style lang="scss">
.address-autocomplete {
margin-bottom: 0.75rem;
}
.autocomplete {
.dropdown-menu {
z-index: 2000;

View File

@@ -1,11 +1,10 @@
<template>
<div class="events-wrapper">
<div class="month-group" v-for="key of keys" :key="key">
<div class="flex flex-col gap-4" v-for="key of keys" :key="key">
<h2 class="is-size-5 month-name">
{{ monthName(groupEvents(key)[0]) }}
</h2>
<event-minimalist-card
class="py-4"
v-for="event in groupEvents(key)"
:key="event.id"
:event="event"
@@ -14,51 +13,46 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed } from "vue";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class GroupedMultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
const props = withDefaults(
defineProps<{
events: IEvent[];
isCurrentActorMember?: boolean;
}>(),
{ isCurrentActorMember: false }
);
get monthlyGroupedEvents(): Map<string, IEvent[]> {
return this.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
const beginsOn = new Date(event.beginsOn);
const month = `${beginsOn.getUTCMonth()}-${beginsOn.getUTCFullYear()}`;
const monthEvents = acc.get(month) || [];
acc.set(month, [...monthEvents, event]);
return acc;
}, new Map());
}
get keys(): string[] {
return Array.from(this.monthlyGroupedEvents.keys()).sort((a, b) =>
b.localeCompare(a)
);
}
groupEvents(key: string): IEvent[] {
return this.monthlyGroupedEvents.get(key) || [];
}
monthName(event: IEvent): string {
const monthlyGroupedEvents = computed((): Map<string, IEvent[]> => {
return props.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
const beginsOn = new Date(event.beginsOn);
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(beginsOn);
}
}
const month = `${beginsOn.getUTCMonth()}-${beginsOn.getUTCFullYear()}`;
const monthEvents = acc.get(month) || [];
acc.set(month, [...monthEvents, event]);
return acc;
}, new Map());
});
const keys = computed((): string[] => {
return Array.from(monthlyGroupedEvents.value.keys()).sort((a, b) =>
b.localeCompare(a)
);
});
const groupEvents = (key: string): IEvent[] => {
return monthlyGroupedEvents.value.get(key) || [];
};
const monthName = (event: IEvent): string => {
const beginsOn = new Date(event.beginsOn);
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(beginsOn);
};
</script>
<style lang="scss" scoped>
.events-wrapper {

View File

@@ -9,16 +9,10 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class EtherpadIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
}
defineProps<{ metadata: IEventMetadataDescription }>();
</script>
<style lang="scss" scoped>
.etherpad {

View File

@@ -9,16 +9,9 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class JitsiMeetIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
}
defineProps<{ metadata: IEventMetadataDescription }>();
</script>
<style lang="scss" scoped>
.jitsi-meet {

View File

@@ -12,30 +12,25 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed } from "vue";
@Component
export default class PeerTubeIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
const props = defineProps<{ metadata: IEventMetadataDescription }>();
get videoDetails(): { host: string; uuid: string } | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1] && matches[2]) {
return { host: matches[1], uuid: matches[2] };
}
const videoDetails = computed((): { host: string; uuid: string } | null => {
if (props.metadata.pattern) {
const matches = props.metadata.pattern.exec(props.metadata.value);
if (matches && matches[1] && matches[2]) {
return { host: matches[1], uuid: matches[2] };
}
return null;
}
return null;
});
get origin(): string {
return window.location.hostname;
}
}
const origin = computed((): string => {
return window.location.hostname;
});
</script>
<style lang="scss" scoped>
.peertube {

View File

@@ -13,30 +13,25 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed } from "vue";
@Component
export default class TwitchIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
const props = defineProps<{ metadata: IEventMetadataDescription }>();
get channelName(): string | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
const channelName = computed((): string | null => {
if (props.metadata.pattern) {
const matches = props.metadata.pattern.exec(props.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
return null;
}
return null;
});
get origin(): string {
return window.location.hostname;
}
}
const origin = computed((): string => {
return window.location.hostname;
});
</script>
<style lang="scss" scoped>
.twitch {

View File

@@ -13,30 +13,25 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import { computed } from "vue";
@Component
export default class YouTubeIntegration extends Vue {
@Prop({ type: Object as PropType<IEventMetadataDescription>, required: true })
metadata!: IEventMetadataDescription;
const props = defineProps<{ metadata: IEventMetadataDescription }>();
get videoID(): string | null {
if (this.metadata.pattern) {
const matches = this.metadata.pattern.exec(this.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
const videoID = computed((): string | null => {
if (props.metadata.pattern) {
const matches = props.metadata.pattern.exec(props.metadata.value);
if (matches && matches[1]) {
return matches[1];
}
return null;
}
return null;
});
get origin(): string {
return window.location.hostname;
}
}
const origin = computed((): string => {
return window.location.hostname;
});
</script>
<style lang="scss" scoped>
.youtube {

View File

@@ -8,20 +8,14 @@
/>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventCard from "./EventCard.vue";
@Component({
components: {
EventCard,
},
})
export default class MultiCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
}
defineProps<{
events: IEvent[];
}>();
</script>
<style lang="scss" scoped>
.multi-card-event {

View File

@@ -9,25 +9,21 @@
/>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class MultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
}
withDefaults(
defineProps<{
events: IEvent[];
isCurrentActorMember?: boolean;
showOrganizer?: boolean;
}>(),
{
isCurrentActorMember: false,
showOrganizer: false,
}
);
</script>
<style lang="scss" scoped>
.events-wrapper {

View File

@@ -1,6 +1,6 @@
<template>
<div class="list is-hoverable">
<b-input
<o-input
dir="auto"
:placeholder="$t('Filter by profile or group name')"
v-model="actorFilter"
@@ -18,7 +18,7 @@
<li
class="relative focus-within:shadow-lg"
v-for="availableActor in actualFilteredAvailableActors"
:key="availableActor.id"
:key="availableActor?.id"
>
<input
class="sr-only peer"
@@ -26,126 +26,127 @@
:value="availableActor"
name="availableActors"
v-model="selectedActor"
:id="`availableActor-${availableActor.id}`"
:id="`availableActor-${availableActor?.id}`"
/>
<label
class="flex flex-wrap p-3 bg-white border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 peer-checked:ring-primary peer-checked:ring-2 peer-checked:border-transparent"
:for="`availableActor-${availableActor.id}`"
class="flex flex-wrap p-3 bg-white hover:bg-gray-50 dark:bg-violet-3 dark:hover:bg-violet-3/60 border border-gray-300 rounded-lg cursor-pointer peer-checked:ring-primary peer-checked:ring-2 peer-checked:border-transparent"
:for="`availableActor-${availableActor?.id}`"
>
<figure class="image is-48x48" v-if="availableActor.avatar">
<figure class="" v-if="availableActor?.avatar">
<img
class="image is-rounded"
class="rounded"
:src="availableActor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<div>
<h3>{{ availableActor.name }}</h3>
<small>{{ `@${availableActor.preferredUsername}` }}</small>
<AccountCircle v-else :size="48" />
<div class="flex-1">
<h3>{{ availableActor?.name }}</h3>
<small>{{ `@${availableActor?.preferredUsername}` }}</small>
</div>
</label>
</li>
</transition-group>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IPerson, IActor, Actor } from "@/types/actor";
import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_MEMBERSHIPS,
} from "@/graphql/actor";
import { Paginate } from "@/types/paginate";
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import { computed, ref } from "vue";
import {
useCurrentActorClient,
useCurrentUserIdentities,
} from "@/composition/apollo/actor";
import { IUser } from "@/types/current-user.model";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { useQuery } from "@vue/apollo-composable";
@Component({
apollo: {
groupMemberships: {
query: LOGGED_USER_MEMBERSHIPS,
update: (data) => data.loggedUser.memberships,
variables() {
return {
page: 1,
limit: 10,
membershipName: this.actorFilter,
};
},
},
identities: IDENTITIES,
currentActor: CURRENT_ACTOR_CLIENT,
},
})
export default class OrganizerPicker extends Vue {
@Prop() value!: IActor;
const props = withDefaults(
defineProps<{ modelValue: IActor; restrictModeratorLevel?: boolean }>(),
{ restrictModeratorLevel: false }
);
@Prop({ required: false, default: false }) restrictModeratorLevel!: boolean;
const emit = defineEmits(["update:modelValue"]);
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
const { currentActor } = useCurrentActorClient();
const { identities } = useCurrentUserIdentities();
currentActor!: IPerson;
const actorFilter = ref("");
actorFilter = "";
get selectedActor(): IActor | undefined {
if (this.value?.id) {
return this.value;
const { result: groupMembershipsResult } = useQuery<{
loggedUser: Pick<IUser, "memberships">;
}>(LOGGED_USER_MEMBERSHIPS, () => ({
page: 1,
limit: 10,
membershipName: actorFilter.value,
}));
const groupMemberships = computed(
() =>
groupMembershipsResult.value?.loggedUser.memberships ?? {
elements: [],
total: 0,
}
if (this.currentActor) {
return this.identities.find(
(identity) => identity.id === this.currentActor.id
);
const selectedActor = computed({
get(): IActor | undefined {
if (props.modelValue?.id) {
return props.modelValue;
}
if (currentActor.value) {
return (identities.value ?? []).find(
(identity) => identity.id === currentActor.value?.id
);
}
return undefined;
},
set(actor: IActor | undefined) {
emit("update:modelValue", actor);
},
});
const actualMemberships = computed((): IMember[] => {
if (props.restrictModeratorLevel) {
return groupMemberships.value.elements.filter((membership: IMember) =>
[
MemberRole.ADMINISTRATOR,
MemberRole.MODERATOR,
MemberRole.CREATOR,
].includes(membership.role)
);
}
return groupMemberships.value.elements;
});
set selectedActor(actor: IActor | undefined) {
this.$emit("input", actor);
}
const actualAvailableActors = computed((): (IActor | undefined)[] => {
return [
currentActor.value,
...(identities.value ?? []).filter(
(identity: IActor) => identity.id !== currentActor.value?.id
),
...actualMemberships.value.map((member) => member.parent),
].filter((elem) => elem);
});
identities: IActor[] = [];
Actor = Actor;
get actualMemberships(): IMember[] {
if (this.restrictModeratorLevel) {
return this.groupMemberships.elements.filter((membership: IMember) =>
[
MemberRole.ADMINISTRATOR,
MemberRole.MODERATOR,
MemberRole.CREATOR,
].includes(membership.role)
);
}
return this.groupMemberships.elements;
}
get actualAvailableActors(): IActor[] {
const actualFilteredAvailableActors = computed((): (IActor | undefined)[] => {
return (actualAvailableActors.value ?? []).filter((actor) => {
if (actor === undefined) return false;
return [
this.currentActor,
...this.identities.filter(
(identity: IActor) => identity.id !== this.currentActor?.id
),
...this.actualMemberships.map((member) => member.parent),
].filter((elem) => elem);
}
get actualFilteredAvailableActors(): IActor[] {
return this.actualAvailableActors.filter((actor) => {
return [
actor.preferredUsername.toLowerCase(),
actor.name?.toLowerCase(),
actor.domain?.toLowerCase(),
].some((match) => match?.includes(this.actorFilter.toLowerCase()));
});
}
}
actor.preferredUsername.toLowerCase(),
actor.name?.toLowerCase(),
actor.domain?.toLowerCase(),
].some((match) => match?.includes(actorFilter.value.toLowerCase()));
});
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
::v-deep .list-item {
:deep(.list-item) {
box-sizing: content-box;
label.b-radio {
@@ -155,14 +156,14 @@ export default class OrganizerPicker extends Vue {
padding: 0.25rem 0;
align-items: center;
figure.image,
span.icon.media-left {
@include margin-right(0.5rem);
}
// figure.image,
// span.icon.media-left {
// @include margin-right(0.5rem);
// }
span.icon.media-left {
@include margin-left(-0.25rem);
}
// span.icon.media-left {
// @include margin-left(-0.25rem);
// }
}
}
}

View File

@@ -1,38 +1,40 @@
<template>
<div
class="bg-white border border-gray-300 rounded-lg cursor-pointer"
class="bg-white dark:bg-violet-3 border border-gray-300 rounded-lg cursor-pointer"
v-if="selectedActor"
>
<!-- If we have a current actor (inline) -->
<div
v-if="inline && selectedActor.id"
class="inline box"
class=""
dir="auto"
@click="isComponentModalActive = true"
>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="selectedActor.avatar">
<div class="flex gap-1 p-4">
<div class="">
<figure class="" v-if="selectedActor.avatar">
<img
class="image is-rounded"
class="rounded"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt || ''"
:alt="selectedActor.avatar.alt ?? ''"
height="48"
width="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle v-else :size="48" />
</div>
<div class="media-content" v-if="selectedActor.name">
<p class="is-4">{{ selectedActor.name }}</p>
<p class="is-6 has-text-grey-dark">
<div class="flex-1" v-if="selectedActor.name">
<p class="">{{ selectedActor.name }}</p>
<p class="">
{{ `@${selectedActor.preferredUsername}` }}
</p>
</div>
<div class="media-content" v-else>
<div class="flex-1" v-else>
{{ `@${selectedActor.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
<o-button type="text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</b-button>
</o-button>
</div>
</div>
<!-- If we have a current actor -->
@@ -42,35 +44,37 @@
@click="isComponentModalActive = true"
>
<img
class="image is-48x48"
class="rounded"
v-if="selectedActor.avatar"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt"
width="48"
height="48"
/>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle v-else :size="48" />
</span>
<b-modal
:active.sync="isComponentModalActive"
<o-modal
v-model:active="isComponentModalActive"
has-modal-card
:close-button-aria-label="$t('Close')"
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Pick a profile or a group") }}</p>
<div class="p-2 rounded">
<header class="">
<h2 class="">{{ $t("Pick a profile or a group") }}</h2>
</header>
<section class="modal-card-body">
<div class="columns">
<div class="column actor-picker">
<section class="">
<div class="flex gap-2">
<div class="actor-picker">
<organizer-picker
v-model="selectedActor"
@input="relay"
:restrict-moderator-level="true"
/>
</div>
<div class="column contact-picker">
<div class="contact-picker">
<div v-if="isSelectedActorAGroup">
<p>{{ $t("Add a contact") }}</p>
<b-input
<o-input
:placeholder="$t('Filter by name')"
:value="contactFilter"
@input="debounceSetFilterByName"
@@ -82,36 +86,34 @@
v-for="actor in filteredActorMembers"
:key="actor.id"
>
<b-checkbox
<o-checkbox
v-model="actualContacts"
:native-value="actor.id"
>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="actor.avatar">
<div class="flex gap-1">
<div class="">
<figure class="" v-if="actor.avatar">
<img
class="image is-rounded"
class="rounded"
:src="actor.avatar.url"
:alt="actor.avatar.alt"
width="48"
height="48"
/>
</figure>
<b-icon
v-else
size="is-large"
icon="account-circle"
/>
<AccountCircle v-else :size="48" />
</div>
<div class="media-content" v-if="actor.name">
<p class="is-4">{{ actor.name }}</p>
<p class="is-6 has-text-grey-dark">
<div class="" v-if="actor.name">
<p class="">{{ actor.name }}</p>
<p class="">
{{ `@${usernameWithDomain(actor)}` }}
</p>
</div>
<div class="media-content" v-else>
<div class="" v-else>
{{ `@${usernameWithDomain(actor)}` }}
</div>
</div>
</b-checkbox>
</o-checkbox>
</p>
</div>
<div
@@ -124,35 +126,36 @@
</empty-content>
</div>
</div>
<div v-else class="content has-text-grey-dark has-text-centered">
<div v-else class="">
<p>{{ $t("Your profile will be shown as contact.") }}</p>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-primary" type="button" @click="pickActor">
<footer class="">
<o-button variant="primary" @click="pickActor">
{{ $t("Pick") }}
</button>
</o-button>
</footer>
</div>
</b-modal>
</o-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IMember } from "@/types/actor/member.model";
import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
<script lang="ts" setup>
import { IActor, IGroup, usernameWithDomain } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue";
import EmptyContent from "../Utils/EmptyContent.vue";
import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
PERSON_GROUP_MEMBERSHIPS,
} from "../../graphql/actor";
import { Paginate } from "../../types/paginate";
import { PERSON_GROUP_MEMBERSHIPS } from "../../graphql/actor";
import { GROUP_MEMBERS } from "@/graphql/member";
import { ActorType, MemberRole } from "@/types/enums";
import { useQuery } from "@vue/apollo-composable";
import { computed, ref, watch } from "vue";
import {
useCurrentActorClient,
useCurrentUserIdentities,
} from "@/composition/apollo/actor";
import { useRoute } from "vue-router";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import debounce from "lodash/debounce";
const MEMBER_ROLES = [
@@ -162,144 +165,133 @@ const MEMBER_ROLES = [
MemberRole.MEMBER,
];
@Component({
components: { OrganizerPicker, EmptyContent },
apollo: {
members: {
query: GROUP_MEMBERS,
variables() {
return {
groupName: usernameWithDomain(this.selectedActor),
page: this.membersPage,
limit: 10,
roles: MEMBER_ROLES.join(","),
name: this.contactFilter,
};
},
update: (data) => data.group.members,
skip() {
return (
!this.selectedActor || this.selectedActor.type !== ActorType.GROUP
);
},
},
currentActor: CURRENT_ACTOR_CLIENT,
personMemberships: {
query: PERSON_GROUP_MEMBERSHIPS,
variables() {
return {
id: this.currentActor?.id,
page: 1,
limit: 10,
groupId: this.$route.query?.actorId,
};
},
update: (data) => data.person.memberships,
},
identities: IDENTITIES,
},
})
export default class OrganizerPickerWrapper extends Vue {
@Prop({ type: Object, required: false }) value!: IActor;
const { currentActor } = useCurrentActorClient();
@Prop({ default: true, type: Boolean }) inline!: boolean;
const route = useRoute();
@Prop({ type: Array, required: false, default: () => [] })
contacts!: IActor[];
const { result: personMembershipsResult } = useQuery(
PERSON_GROUP_MEMBERSHIPS,
() => ({
id: currentActor.value?.id,
page: 1,
limit: 10,
groupId: route.query?.actorId,
})
);
currentActor!: IPerson;
identities!: IPerson[];
isComponentModalActive = false;
contactFilter = "";
usernameWithDomain = usernameWithDomain;
members: Paginate<IMember> = { elements: [], total: 0 };
membersPage = 1;
personMemberships: Paginate<IMember> = { elements: [], total: 0 };
data(): Record<string, unknown> {
return {
debounceSetFilterByName: debounce(this.setContactFilter, 1000),
};
}
get actualContacts(): (string | undefined)[] {
return this.contacts.map(({ id }) => id);
}
set actualContacts(contactsIds: (string | undefined)[]) {
this.$emit(
"update:contacts",
this.actorMembers.filter(({ id }) => contactsIds.includes(id))
);
}
setContactFilter(contactFilter: string) {
this.contactFilter = contactFilter;
}
@Watch("personMemberships")
setInitialActor(): void {
if (
this.personMemberships?.elements[0]?.parent?.id ===
this.$route.query?.actorId
) {
this.selectedActor = this.personMemberships?.elements[0]?.parent;
const personMemberships = computed(
() =>
personMembershipsResult.value?.person.memberships ?? {
elements: [],
total: 0,
}
}
);
get selectedActor(): IActor | undefined {
if (this.value?.id) {
return this.value;
const { identities } = useCurrentUserIdentities();
const props = withDefaults(
defineProps<{
modelValue?: IActor;
inline?: boolean;
contacts?: IActor[];
}>(),
{ inline: true, contacts: () => [] }
);
const emit = defineEmits(["update:modelValue", "update:Contacts"]);
const selectedActor = computed({
get(): IActor | undefined {
if (props.modelValue?.id) {
return props.modelValue;
}
if (this.currentActor) {
return this.identities.find(
(identity) => identity.id === this.currentActor.id
if (currentActor.value) {
return (identities.value ?? []).find(
(identity) => identity.id === currentActor.value?.id
);
}
return undefined;
}
},
set(selectedActor: IActor | undefined) {
emit("update:modelValue", selectedActor);
},
});
set selectedActor(selectedActor: IActor | undefined) {
this.$emit("input", selectedActor);
}
const isComponentModalActive = ref(false);
const contactFilter = ref("");
const membersPage = ref(1);
async relay(group: IGroup): Promise<void> {
this.actualContacts = [];
this.selectedActor = group;
}
const { result: membersResult } = useQuery(
GROUP_MEMBERS,
() => ({
groupName: usernameWithDomain(selectedActor.value),
page: membersPage.value,
limit: 10,
roles: MEMBER_ROLES.join(","),
name: contactFilter.value,
}),
() => ({ enabled: selectedActor.value?.type === ActorType.GROUP })
);
pickActor(): void {
this.isComponentModalActive = false;
}
const members = computed(
() => membersResult.value?.members ?? { elements: [], total: 0 }
);
get actorMembers(): IActor[] {
if (this.isSelectedActorAGroup) {
return this.members.elements.map(({ actor }: { actor: IActor }) => actor);
}
return [];
}
const actualContacts = computed({
get(): (string | undefined)[] {
return props.contacts.map(({ id }) => id);
},
set(contactsIds: (string | undefined)[]) {
emit(
"update:Contacts",
actorMembers.value.filter(({ id }) => contactsIds.includes(id))
);
},
});
get filteredActorMembers(): IActor[] {
return this.actorMembers.filter((actor) => {
return [
actor.preferredUsername.toLowerCase(),
actor.name?.toLowerCase(),
actor.domain?.toLowerCase(),
];
});
}
const setContactFilter = (newContactFilter: string) => {
contactFilter.value = newContactFilter;
};
get isSelectedActorAGroup(): boolean {
return this.selectedActor?.type === ActorType.GROUP;
const debounceSetFilterByName = debounce(setContactFilter, 1000);
watch(personMemberships, () => {
if (
personMemberships.value?.elements[0]?.parent?.id === route.query?.actorId
) {
selectedActor.value = personMemberships.value?.elements[0]?.parent;
}
}
});
const relay = async (group: IGroup): Promise<void> => {
actualContacts.value = [];
selectedActor.value = group;
};
const pickActor = (): void => {
isComponentModalActive.value = false;
};
const actorMembers = computed((): IActor[] => {
if (isSelectedActorAGroup.value) {
return members.value.elements.map(({ actor }: { actor: IActor }) => actor);
}
return [];
});
const filteredActorMembers = computed((): IActor[] => {
return actorMembers.value.filter((actor) => {
return [
actor.preferredUsername.toLowerCase(),
actor.name?.toLowerCase(),
actor.domain?.toLowerCase(),
];
});
});
const isSelectedActorAGroup = computed((): boolean => {
return selectedActor.value?.type === ActorType.GROUP;
});
</script>
<style lang="scss" scoped>
.modal-card-body .columns .column {

View File

@@ -0,0 +1,114 @@
<template>
<Story>
<Variant title="Unlogged">
<ParticipationButton
:event="event"
:current-actor="emptyCurrentActor"
:participation="undefined"
:identities="[]"
/>
</Variant>
<Variant title="Basic">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="undefined"
:identities="identities"
@join-event="hstEvent('Join event', $event)"
@join-modal="hstEvent('Join modal', $event)"
@confirm-leave="hstEvent('Confirm leave', $event)"
/>
</Variant>
<Variant title="Basic with confirmation">
<ParticipationButton
:event="{ ...event, joinOptions: EventJoinOptions.RESTRICTED }"
:current-actor="currentActor"
:participation="undefined"
:identities="identities"
@join-event-with-confirmation="
hstEvent('Join Event with confirmation', $event)
"
@join-modal="hstEvent('Join modal', $event)"
/>
</Variant>
<Variant title="Participating">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="participation"
:identities="identities"
@confirm-leave="hstEvent('Confirm leave', $event)"
/>
</Variant>
<Variant title="Pending approval">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="{
...participation,
role: ParticipantRole.NOT_APPROVED,
}"
:identities="identities"
@confirm-leave="hstEvent('Confirm leave', $event)"
/>
</Variant>
<Variant title="Rejected">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="{
...participation,
role: ParticipantRole.REJECTED,
}"
:identities="identities"
@confirm-leave="hstEvent('Confirm leave', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor, IPerson } from "@/types/actor";
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import ParticipationButton from "./ParticipationButton.vue";
import { hstEvent } from "histoire/client";
import { IParticipant } from "@/types/participant.model";
const emptyCurrentActor: IPerson = {};
const currentActor: IPerson = {
id: "1",
preferredUsername: "tcit",
name: "Thomas",
avatar: {
url: "https://mobilizon.fr/media/3a5f18c058a8193b1febfaf561f94ae8b91f85ac64c01ddf5ad7b251fb43baf5.jpg?name=profil.jpg",
},
};
const participation: IParticipant = {
actor: currentActor,
role: ParticipantRole.PARTICIPANT,
};
const identities: IPerson[] = [
currentActor,
{
id: "2",
preferredUsername: "another",
name: "Another",
avatar: {
url: "https://mobilizon.fr/media/95ab5ba92287ab4857bb517cadae2a7ab6a553748d1c48cefc27e2b7ab640fea.jpg?name=FB_IMG_16150214351371162.jpg",
},
},
];
const event: IEvent = {
title: "hello",
url: "https://mobilizon.fr/events/an-uuid",
options: {
anonymousParticipation: false,
},
joinOptions: EventJoinOptions.FREE,
};
</script>

View File

@@ -1,90 +1,61 @@
import {EventJoinOptions} from "@/types/event.model";
<docs>
A button to set your participation
##### If the participant has been confirmed
```vue
<ParticipationButton :participation="{ role: 'PARTICIPANT' }" :currentActor="{ preferredUsername: 'test', avatar: { url: 'https://huit.re/EPX7vs1j' } }" />
```
##### If the participant has not being approved yet
```vue
<ParticipationButton :participation="{ role: 'NOT_APPROVED' }" :currentActor="{ preferredUsername: 'test', avatar: { url: 'https://huit.re/EPX7vs1j' } }" />
```
##### If the participant has been rejected
```vue
<ParticipationButton :participation="{ role: 'REJECTED' }" :currentActor="{ preferredUsername: 'test', avatar: { url: 'https://huit.re/EPX7vs1j' } }" />
```
##### If the participant doesn't exist yet
```vue
<ParticipationButton :participation="null" :currentActor="{ preferredUsername: 'test', avatar: { url: 'https://huit.re/EPX7vs1j' } }" />
```
</docs>
<template>
<div class="participation-button">
<b-dropdown
aria-role="list"
position="is-bottom-left"
<div>
<o-dropdown
v-if="participation && participation.role === ParticipantRole.PARTICIPANT"
>
<template #trigger="{ active }">
<b-button
type="is-success"
size="is-large"
<o-button
variant="success"
size="large"
icon-left="check"
:icon-right="active ? 'menu-up' : 'menu-down'"
>
{{ $t("I participate") }}
</b-button>
{{ t("I participate") }}
</o-button>
</template>
<b-dropdown-item
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
@keyup.enter="confirmLeave"
class="has-text-danger"
>{{ $t("Cancel my participation…") }}</b-dropdown-item
>
</b-dropdown>
class=""
>{{ t("Cancel my participation…") }}
</o-dropdown-item>
</o-dropdown>
<div
v-else-if="
participation && participation.role === ParticipantRole.NOT_APPROVED
"
class="flex flex-col"
>
<b-dropdown
aria-role="list"
position="is-bottom-left"
class="dropdown-disabled"
>
<button class="button is-success is-large" type="button" slot="trigger">
<b-icon icon="timer-sand-empty" />
<template>
<span>{{ $t("I participate") }}</span>
</template>
<b-icon icon="menu-down" />
</button>
<o-dropdown>
<template #trigger>
<o-button variant="success" size="large" type="button">
<template class="flex items-center">
<TimerSandEmpty />
<span>{{ t("I participate") }}</span>
<MenuDown />
</template>
</o-button>
</template>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<o-dropdown-item :value="false" aria-role="listitem">
{{ t("Change my identity…") }}
</o-dropdown-item>
<b-dropdown-item
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
@keyup.enter="confirmLeave"
class="has-text-danger"
>{{ $t("Cancel my participation request…") }}</b-dropdown-item
class=""
>{{ t("Cancel my participation request…") }}</o-dropdown-item
>
</b-dropdown>
<small>{{ $t("Participation requested!") }}</small>
<br />
<small>{{ $t("Waiting for organization team approval.") }}</small>
</o-dropdown>
<p>{{ t("Participation requested!") }}</p>
<p>{{ t("Waiting for organization team approval.") }}</p>
</div>
<div
@@ -94,63 +65,63 @@ A button to set your participation
>
<span>
{{
$t(
t(
"Unfortunately, your participation request was rejected by the organizers."
)
}}
</span>
</div>
<b-dropdown
aria-role="list"
position="is-bottom-left"
v-else-if="!participation && currentActor.id"
>
<o-dropdown v-else-if="!participation && currentActor?.id">
<template #trigger="{ active }">
<b-button
type="is-primary"
size="is-large"
<o-button
variant="primary"
size="large"
:icon-right="active ? 'menu-up' : 'menu-down'"
>
{{ $t("Participate") }}
</b-button>
{{ t("Participate") }}
</o-button>
</template>
<b-dropdown-item
<o-dropdown-item
:value="true"
aria-role="listitem"
@click="joinEvent(currentActor)"
@keyup.enter="joinEvent(currentActor)"
>
<div class="media">
<div class="media-left" v-if="currentActor.avatar">
<figure class="image is-32x32">
<img class="is-rounded" :src="currentActor.avatar.url" alt />
</figure>
</div>
<div class="media-content">
<div class="flex gap-2 items-center">
<figure class="" v-if="currentActor?.avatar">
<img
class="rounded-xl"
:src="currentActor.avatar.url"
alt=""
width="24"
height="24"
/>
</figure>
<AccountCircle v-else />
<div class="">
<span>
{{
$t("as {identity}", {
identity:
currentActor.name || `@${currentActor.preferredUsername}`,
t("as {identity}", {
identity: displayName(currentActor),
})
}}
</span>
</div>
</div>
</b-dropdown-item>
</o-dropdown-item>
<b-dropdown-item
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="joinModal"
@keyup.enter="joinModal"
v-if="identities.length > 1"
>{{ $t("with another identity…") }}</b-dropdown-item
v-if="(identities ?? []).length > 1"
>{{ t("with another identity…") }}</o-dropdown-item
>
</b-dropdown>
<b-button
</o-dropdown>
<o-button
rel="nofollow"
tag="router-link"
:to="{
@@ -158,110 +129,72 @@ A button to set your participation
params: { uuid: event.uuid },
}"
v-else-if="!participation && hasAnonymousParticipationMethods"
type="is-primary"
size="is-large"
variant="primary"
size="large"
native-type="button"
>{{ $t("Participate") }}</b-button
>{{ t("Participate") }}</o-button
>
<b-button
<o-button
tag="router-link"
rel="nofollow"
:to="{
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
params: { uuid: event.uuid },
}"
v-else-if="!currentActor.id"
type="is-primary"
size="is-large"
v-else-if="!currentActor?.id"
variant="primary"
size="large"
native-type="button"
>{{ $t("Participate") }}</b-button
>{{ t("Participate") }}</o-button
>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEvent } from "../../types/event.model";
import { IPerson, Person } from "../../types/actor";
import { CURRENT_ACTOR_CLIENT, IDENTITIES } from "../../graphql/actor";
import { CURRENT_USER_CLIENT } from "../../graphql/user";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import { IPerson, displayName } from "../../types/actor";
import RouteName from "../../router/name";
import { computed } from "vue";
import MenuDown from "vue-material-design-icons/MenuDown.vue";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import TimerSandEmpty from "vue-material-design-icons/TimerSandEmpty.vue";
@Component({
apollo: {
currentUser: {
query: CURRENT_USER_CLIENT,
},
currentActor: CURRENT_ACTOR_CLIENT,
config: CONFIG,
identities: {
query: IDENTITIES,
update: ({ identities }) =>
identities
? identities.map((identity: IPerson) => new Person(identity))
: [],
skip() {
return this.currentUser.isLoggedIn === false;
},
},
},
})
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
const props = defineProps<{
participation: IParticipant | undefined;
event: IEvent;
currentActor: IPerson;
identities: IPerson[];
}>();
@Prop({ required: true }) event!: IEvent;
const emit = defineEmits([
"join-event-with-confirmation",
"join-event",
"join-modal",
"confirm-leave",
]);
@Prop({ required: true }) currentActor!: IPerson;
const { t } = useI18n({ useScope: "global" });
ParticipantRole = ParticipantRole;
identities: IPerson[] = [];
config!: IConfig;
RouteName = RouteName;
joinEvent(actor: IPerson): void {
if (this.event.joinOptions === EventJoinOptions.RESTRICTED) {
this.$emit("join-event-with-confirmation", actor);
} else {
this.$emit("join-event", actor);
}
const joinEvent = (actor: IPerson | undefined): void => {
if (props.event.joinOptions === EventJoinOptions.RESTRICTED) {
emit("join-event-with-confirmation", actor);
} else {
emit("join-event", actor);
}
};
joinModal(): void {
this.$emit("join-modal");
}
const joinModal = (): void => {
emit("join-modal");
};
confirmLeave(): void {
this.$emit("confirm-leave");
}
const confirmLeave = (): void => {
emit("confirm-leave");
};
get hasAnonymousParticipationMethods(): boolean {
return this.event.options.anonymousParticipation;
}
}
const hasAnonymousParticipationMethods = computed((): boolean => {
return props.event.options.anonymousParticipation;
});
</script>
<style lang="scss" scoped>
.participation-button {
.dropdown {
display: flex;
justify-content: flex-end;
&.dropdown-disabled button {
opacity: 0.5;
}
}
}
.anonymousParticipationModal {
::v-deep .animation-content {
z-index: 1;
}
}
</style>

View File

@@ -2,8 +2,8 @@
<div>
<p class="time">
{{
formatDistanceToNow(new Date(event.publishAt || event.insertedAt), {
locale: $dateFnsLocale,
formatDistanceToNow(new Date(event.publishAt), {
locale: dateFnsLocale,
addSuffix: true,
}) || $t("Right now")
}}
@@ -11,22 +11,16 @@
<EventCard :event="event" />
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { formatDistanceToNow } from "date-fns";
import { Component, Prop, Vue } from "vue-property-decorator";
import { inject } from "vue";
import EventCard from "./EventCard.vue";
defineProps<{
event: IEvent;
}>();
@Component({
components: {
EventCard,
},
})
export default class RecentEventCardWrapper extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
formatDistanceToNow = formatDistanceToNow;
}
const dateFnsLocale = inject<Locale>("dateFnsLocale");
</script>
<style lang="scss" scoped>
p.time {

View File

@@ -0,0 +1,29 @@
<template>
<Story>
<Variant title="Public">
<ShareEventModal :event="event" />
</Variant>
<Variant title="Private">
<ShareEventModal
:event="{ ...event, visibility: EventVisibility.PRIVATE }"
/>
</Variant>
<Variant title="Cancelled">
<ShareEventModal :event="{ ...event, status: EventStatus.CANCELLED }" />
</Variant>
<Variant title="No seats left">
<ShareEventModal :event="event" :event-capacity-o-k="false" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { EventVisibility, EventStatus } from "@/types/enums";
import ShareEventModal from "./ShareEventModal.vue";
const event = {
title: "hello",
url: "https://mobilizon.fr/events/an-uuid",
visibility: EventVisibility.PUBLIC,
};
</script>

View File

@@ -1,220 +1,49 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Share this event") }}</p>
</header>
<section class="modal-card-body is-flex" v-if="event">
<div class="container has-text-centered">
<b-notification
type="is-warning"
v-if="event.visibility !== EventVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This event is accessible only through it's link. Be careful where you post this link."
)
}}
</b-notification>
<b-notification
type="is-danger"
v-if="event.status === EventStatus.CANCELLED"
:closable="false"
>
{{ $t("This event has been cancelled.") }}
</b-notification>
<small class="maximumNumberOfPlacesWarning" v-if="!eventCapacityOK">
{{ $t("All the places have already been taken") }}
</small>
<b-field :label="$t('Event URL')" label-for="event-url-text">
<b-input
id="event-url-text"
ref="eventURLInput"
:value="event.url"
expanded
/>
<p class="control">
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
:title="$t('Copy URL to clipboard')"
/>
</b-tooltip>
</p>
</b-field>
<div>
<a
:href="twitterShareUrl"
target="_blank"
rel="nofollow noopener"
title="Twitter"
><b-icon icon="twitter" size="is-large" type="is-primary"
/></a>
<a
:href="mastodonShareUrl"
class="mastodon"
target="_blank"
rel="nofollow noopener"
title="Mastodon"
>
<mastodon-logo />
</a>
<a
:href="facebookShareUrl"
target="_blank"
rel="nofollow noopener"
title="Facebook"
><b-icon icon="facebook" size="is-large" type="is-primary"
/></a>
<a
:href="whatsAppShareUrl"
target="_blank"
rel="nofollow noopener"
title="WhatsApp"
><b-icon icon="whatsapp" size="is-large" type="is-primary"
/></a>
<a
:href="telegramShareUrl"
class="telegram"
target="_blank"
rel="nofollow noopener"
title="Telegram"
>
<telegram-logo />
</a>
<a
:href="linkedInShareUrl"
target="_blank"
rel="nofollow noopener"
title="LinkedIn"
><b-icon icon="linkedin" size="is-large" type="is-primary"
/></a>
<a
:href="diasporaShareUrl"
class="diaspora"
target="_blank"
rel="nofollow noopener"
title="Diaspora"
>
<diaspora-logo />
</a>
<a
:href="emailShareUrl"
target="_blank"
rel="nofollow noopener"
title="Email"
><b-icon icon="email" size="is-large" type="is-primary"
/></a>
</div>
</div>
</section>
<div class="dark:text-white">
<ShareModal
:title="t('Share this event')"
:text="event.title"
:url="event.url"
:input-label="t('Event URL')"
>
<o-notification
variant="warning"
v-if="event.visibility !== EventVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This event is accessible only through it's link. Be careful where you post this link."
)
}}
</o-notification>
<o-notification
variant="danger"
v-if="event.status === EventStatus.CANCELLED"
:closable="false"
>
{{ $t("This event has been cancelled.") }}
</o-notification>
<o-notification variant="warning" v-if="!eventCapacityOK">
{{ $t("All the places have already been taken") }}
</o-notification>
</ShareModal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
<script lang="ts" setup>
import { EventStatus, EventVisibility } from "@/types/enums";
import { IEvent } from "../../types/event.model";
import DiasporaLogo from "../Share/DiasporaLogo.vue";
import MastodonLogo from "../Share/MastodonLogo.vue";
import TelegramLogo from "../Share/TelegramLogo.vue";
import { useI18n } from "vue-i18n";
import { IEvent } from "@/types/event.model";
import ShareModal from "@/components/Share/ShareModal.vue";
@Component({
components: {
DiasporaLogo,
MastodonLogo,
TelegramLogo,
},
})
export default class ShareEventModal extends Vue {
@Prop({ type: Object, required: true }) event!: IEvent;
const props = withDefaults(
defineProps<{
event: IEvent;
eventCapacityOK?: boolean;
}>(),
{ eventCapacityOK: true }
);
@Prop({ type: Boolean, required: false, default: true })
eventCapacityOK!: boolean;
@Ref("eventURLInput") readonly eventURLInput!: any;
EventVisibility = EventVisibility;
EventStatus = EventStatus;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
this.event.url
)}&text=${this.event.title}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
this.event.url
)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
this.event.url
)}&title=${this.event.title}`;
}
get whatsAppShareUrl(): string {
return `https://wa.me/?text=${encodeURIComponent(this.basicTextToEncode)}`;
}
get telegramShareUrl(): string {
return `https://t.me/share/url?url=${encodeURIComponent(
this.event.url
)}&text=${encodeURIComponent(this.event.title)}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.event.url}&subject=${this.event.title}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(
this.event.title
)}&url=${encodeURIComponent(this.event.url)}`;
}
get mastodonShareUrl(): string {
return `https://toot.kytta.dev/?text=${encodeURIComponent(
this.basicTextToEncode
)}`;
}
get basicTextToEncode(): string {
return `${this.event.title}\r\n${this.event.url}`;
}
copyURL(): void {
this.eventURLInput.$refs.input.select();
document.execCommand("copy");
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
}
const { t } = useI18n({ useScope: "global" });
</script>
<style lang="scss" scoped>
.diaspora,
.mastodon,
.telegram {
::v-deep span svg {
width: 2.25rem;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<Story>
<Variant title="row">
<SkeletonEventResult />
</Variant>
<Variant title="column">
<SkeletonEventResult view-mode="column" />
</Variant>
<Variant title="not minimal">
<SkeletonEventResult :minimal="false" />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import SkeletonEventResult from "./SkeletonEventResult.vue";
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div
:class="`bg-white dark:bg-slate-800 shadow rounded-md ${
isRowMode ? 'max-w-4xl' : 'max-w-sm'
} w-full mx-auto`"
>
<div
:class="`animate-pulse flex flex-col items-center ${
isRowMode ? 'md:flex-row' : 'md:flex-col'
}`"
>
<div class="object-cover h-56 w-full md:max-w-[20rem] bg-slate-700" />
<div
class="flex-1 space-3-4 flex self-start flex-col justify-between p-2 md:p-4 w-full"
>
<span class="h-2 bg-slate-700"></span>
<span class="mb-2 h-4 bg-slate-700"></span>
<div class="flex space-x-4 flex-row">
<div class="rounded-full bg-slate-700 h-10 w-10"></div>
<div class="flex flex-col flex-1 space-y-2">
<div class="h-3 bg-slate-700"></div>
<div class="h-2 bg-slate-700"></div>
</div>
</div>
<div class="h-3 bg-slate-700 mt-3 w-60" v-if="!minimal"></div>
<div class="flex" v-if="!minimal">
<div
class="h-3 bg-slate-700 mt-2 w-20 mr-2 rounded"
v-for="i in 3"
:key="i"
></div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
const props = withDefaults(
defineProps<{
viewMode?: string;
minimal?: boolean;
}>(),
{ viewMode: "row", minimal: true }
);
const isRowMode = computed<boolean>(() => props.viewMode == "row");
</script>

View File

@@ -0,0 +1,23 @@
<template>
<Story>
<Variant title="new">
<TagInput v-model="tags" :fetch-tags="fetchTags" />
</Variant>
<!-- <Variant title="small">
<TagInput v-model="tags" />
</Variant> -->
</Story>
</template>
<script lang="ts" setup>
import { ITag } from "@/types/tag.model";
import { reactive } from "vue";
import TagInput from "./TagInput.vue";
const tags = reactive<ITag[]>([{ title: "Hello", slug: "hello" }]);
const fetchTags = async (text: string) =>
new Promise<ITag[]>((resolve, reject) => {
resolve([{ title: "Welcome", slug: "welcome" }]);
});
</script>

View File

@@ -1,104 +1,91 @@
<template>
<b-field :label-for="id">
<template slot="label">
<o-field :label-for="id">
<template #label>
{{ $t("Add some tags") }}
<b-tooltip
type="is-dark"
<o-tooltip
type="dark"
:label="
$t('You can add tags by hitting the Enter key or by adding a comma')
"
>
<b-icon size="is-small" icon="help-circle-outline"></b-icon>
</b-tooltip>
<HelpCircleOutline :size="16" />
</o-tooltip>
</template>
<b-taginput
<o-inputitems
v-model="tagsStrings"
:data="filteredTags"
autocomplete
:autocomplete="true"
:allow-new="true"
:field="'title'"
icon="label"
maxlength="20"
maxtags="10"
:maxlength="20"
:maxitems="10"
:placeholder="$t('Eg: Stockholm, Dance, Chess…')"
@typing="debouncedGetFilteredTags"
:id="id"
dir="auto"
>
</b-taginput>
</b-field>
</o-inputitems>
</o-field>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import differenceBy from "lodash/differenceBy";
import { ITag } from "../../types/tag.model";
import { FILTER_TAGS } from "@/graphql/tags";
import debounce from "lodash/debounce";
import { computed, onBeforeMount, ref } from "vue";
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
@Component({
apollo: {
tags: {
query: FILTER_TAGS,
variables() {
return {
filter: this.text,
};
},
},
const props = defineProps<{
modelValue: ITag[];
fetchTags: (text: string) => Promise<ITag[]>;
}>();
const emit = defineEmits(["update:modelValue"]);
const text = ref("");
const tags = ref<ITag[]>([]);
let componentId = 0;
onBeforeMount(() => {
componentId += 1;
});
const id = computed((): string => {
return `tag-input-${componentId}`;
});
const getFilteredTags = async (newText: string): Promise<void> => {
text.value = newText;
tags.value = await props.fetchTags(newText);
};
const debouncedGetFilteredTags = debounce(getFilteredTags, 200);
const filteredTags = computed((): ITag[] => {
return differenceBy(tags.value, props.modelValue, "id").filter(
(option) =>
option.title.toString().toLowerCase().indexOf(text.value.toLowerCase()) >=
0 ||
option.slug.toString().toLowerCase().indexOf(text.value.toLowerCase()) >=
0
);
});
const tagsStrings = computed({
get(): string[] {
return props.modelValue.map((tag: ITag) => tag.title);
},
})
export default class TagInput extends Vue {
@Prop({ required: true }) value!: ITag[];
tags!: ITag[];
text = "";
private static componentId = 0;
created(): void {
TagInput.componentId += 1;
}
get id(): string {
return `tag-input-${TagInput.componentId}`;
}
data(): Record<string, unknown> {
return {
debouncedGetFilteredTags: debounce(this.getFilteredTags, 200),
};
}
async getFilteredTags(text: string): Promise<void> {
this.text = text;
await this.$apollo.queries.tags.refetch();
}
get filteredTags(): ITag[] {
return differenceBy(this.tags, this.value, "id").filter(
(option) =>
option.title
.toString()
.toLowerCase()
.indexOf(this.text.toLowerCase()) >= 0 ||
option.slug.toString().toLowerCase().indexOf(this.text.toLowerCase()) >=
0
);
}
get tagsStrings(): string[] {
return (this.value || []).map((tag: ITag) => tag.title);
}
set tagsStrings(tagsStrings: string[]) {
set(tagsStrings: string[]) {
console.debug("tagsStrings", tagsStrings);
const tagEntities = tagsStrings.map((tag: string | ITag) => {
if (typeof tag !== "string") {
return tag;
}
return { title: tag, slug: tag } as ITag;
});
this.$emit("input", tagEntities);
}
}
emit("update:modelValue", tagEntities);
},
});
</script>

View File

@@ -0,0 +1,132 @@
<template>
<form
v-if="sentryReady && !submittedFeedback"
@submit.prevent="sendErrorToSentry"
>
<o-field :label="t('What happened?')" label-for="what-happened">
<o-input
v-model="feedback"
type="textarea"
id="what-happened"
:placeholder="t(`I've clicked on X, then on Y`)"
/>
</o-field>
<o-button icon-left="send" native-type="submit" variant="primary">{{
t("Send feedback")
}}</o-button>
<p class="prose dark:prose-invert">
{{
t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
</form>
<o-notification variant="danger" v-else-if="feedbackError">
<p>
{{
t(
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway."
)
}}
</p>
<i18n-t keypath="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
t("return to the homepage")
}}</router-link>
</template>
</i18n-t>
</o-notification>
<o-notification variant="success" v-else-if="submittedFeedback">
<p>{{ t("Thanks a lot, your feedback was submitted!") }}</p>
<i18n-t keypath="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
t("return to the homepage")
}}</router-link>
</template>
</i18n-t>
</o-notification>
<div class="prose dark:prose-invert" v-if="!sentryReady || submittedFeedback">
<p v-if="submittedFeedback">{{ $t("You may also:") }}</p>
<ul>
<li>
<a href="https://framacolibri.org/c/mobilizon/39" target="_blank">{{
$t("Open a topic on our forum")
}}</a>
</li>
<li>
<a
href="https://framagit.org/framasoft/mobilizon/-/issues/"
target="_blank"
>{{ $t("Open an issue on our bug tracker (advanced users)") }}</a
>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { convertConfig } from "@/services/statistics";
import { IAnalyticsConfig } from "@/types/config.model";
import { ISentryConfiguration } from "@/types/analytics/sentry.model";
import { submitFeedback } from "@/services/statistics/sentry";
import { computed, ref } from "vue";
import { useLoggedUser } from "@/composition/apollo/user";
import { useI18n } from "vue-i18n";
import RouteName from "@/router/name";
const props = defineProps<{
providerConfig: IAnalyticsConfig;
}>();
const { t } = useI18n({ useScope: "global" });
const { loggedUser } = useLoggedUser();
const feedback = ref("");
const submittedFeedback = ref(false);
const feedbackError = ref(false);
const sentryConfig = computed((): ISentryConfiguration | undefined => {
if (props.providerConfig?.configuration) {
return convertConfig(
props.providerConfig?.configuration
) as ISentryConfiguration;
}
return undefined;
});
const sentryReady = computed(() => {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = sentryConfig.value?.dsn;
const organization = sentryConfig.value?.organization;
const project = sentryConfig.value?.project;
const host = sentryConfig.value?.host;
return eventId && dsn && organization && project && host;
});
const sendErrorToSentry = async () => {
try {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = sentryConfig.value?.dsn;
const organization = sentryConfig.value?.organization;
const project = sentryConfig.value?.project;
const host = sentryConfig.value?.host;
const endpoint = `https://${host}/api/0/projects/${organization}/${project}/user-feedback/`;
if (eventId && dsn && sentryReady) {
await submitFeedback(endpoint, dsn, {
event_id: eventId,
name:
loggedUser.value?.defaultActor?.preferredUsername || "Unknown user",
email: loggedUser.value?.email || "unknown@email.org",
comments: feedback.value,
});
submittedFeedback.value = true;
}
} catch (error) {
console.error(error);
feedbackError.value = true;
}
};
</script>

View File

@@ -1,6 +1,9 @@
<template>
<footer class="footer" ref="footer">
<picture>
<footer
class="bg-violet-2 color-secondary flex flex-col items-center py-2 px-3"
ref="footer"
>
<picture class="flex max-w-xl">
<source
:srcset="`/img/pics/footer_${random}-1024w.webp 1x, /img/pics/footer_${random}-1920w.webp 2x`"
type="image/webp"
@@ -17,13 +20,14 @@
loading="lazy"
/>
</picture>
<ul>
<ul
class="inline-flex flex-wrap justify-around gap-3 text-lg text-white underline decoration-yellow-1"
>
<li>
<b-select
:aria-label="$t('Language')"
v-if="$i18n"
<o-select
:aria-label="t('Language')"
v-model="locale"
:placeholder="$t('Select a language')"
:placeholder="t('Select a language')"
>
<option
v-for="(language, lang) in langs"
@@ -33,16 +37,16 @@
>
{{ language }}
</option>
</b-select>
</o-select>
</li>
<li>
<router-link :to="{ name: RouteName.ABOUT }">{{
$t("About")
t("About")
}}</router-link>
</li>
<li>
<router-link :to="{ name: RouteName.TERMS }">{{
$t("Terms")
t("Terms")
}}</router-link>
</li>
<li>
@@ -51,131 +55,64 @@
hreflang="en"
href="https://framagit.org/framasoft/mobilizon/blob/main/LICENSE"
>
{{ $t("License") }}
{{ t("License") }}
</a>
</li>
<li>
<a href="#navbar">{{ $t("Back to top") }}</a>
<a href="#navbar">{{ t("Back to top") }}</a>
</li>
</ul>
<div class="content has-text-centered">
<i18n
<div class="text-center flex-1 pt-2 text-yellow-1">
<i18n-t
tag="span"
path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
keypath="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
>
<a rel="external" slot="mobilizon" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
<span slot="date">{{ new Date().getFullYear() }}</span>
<a
rel="external"
href="https://joinmobilizon.org/hall-of-fame"
slot="contributors"
>{{ $t("more than 1360 contributors") }}</a
<template #mobilizon>
<a
rel="external"
class="text-white underline decoration-yellow-1"
href="https://joinmobilizon.org"
>{{ t("Mobilizon") }}</a
>
</template>
<template #date
><span>{{ new Date().getFullYear() }}</span></template
>
</i18n>
<template #contributors>
<a
rel="external"
class="text-white underline decoration-yellow-1"
href="https://joinmobilizon.org/hall-of-fame"
>{{ t("more than 1360 contributors") }}</a
>
</template>
</i18n-t>
</div>
</footer>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
<script setup lang="ts">
import { saveLocaleData } from "@/utils/auth";
import { loadLanguageAsync } from "@/utils/i18n";
import RouteName from "../router/name";
import langs from "../i18n/langs.json";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
@Component
export default class Footer extends Vue {
RouteName = RouteName;
const { locale, t } = useI18n({ useScope: "global" });
locale: string | null = this.$i18n.locale;
const random = computed((): number => {
return Math.floor(Math.random() * 4) + 1;
});
langs: Record<string, string> = langs;
// eslint-disable-next-line class-methods-use-this
get random(): number {
return Math.floor(Math.random() * 4) + 1;
watch(locale, async () => {
if (locale) {
console.debug("Setting locale from footer");
await loadLanguageAsync(locale.value as string);
saveLocaleData(locale.value as string);
}
});
@Watch("locale")
// eslint-disable-next-line class-methods-use-this
async updateLocale(locale: string): Promise<void> {
if (locale) {
console.debug("Setting locale from footer");
await loadLanguageAsync(locale);
saveLocaleData(locale);
}
}
@Watch("$i18n.locale", { deep: true })
updateLocaleFromI18n(locale: string): void {
if (locale) {
this.locale = locale;
}
}
isLangSelected(lang: string): boolean {
return lang === this.locale;
}
}
const isLangSelected = (lang: string): boolean => {
return lang === locale.value;
};
</script>
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
footer.footer {
color: $secondary;
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
padding: 1rem 1.5rem;
img {
flex: 1;
max-width: 40rem;
@include mobile {
max-width: 100%;
}
}
div.content {
flex: 1;
padding-top: 10px;
}
ul {
display: inline-flex;
flex-wrap: wrap;
justify-content: space-around;
li {
display: inline-flex;
margin: auto 5px;
padding: 2px 0;
a {
font-size: 1.1rem;
}
}
}
a {
color: $white;
text-decoration: underline;
text-decoration-color: $secondary;
&:focus {
background-color: #000;
color: #fff;
outline: 3px solid #000;
text-decoration: none;
}
}
::v-deep span.select {
select,
option {
background: $background-color;
color: $white;
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<Story>
<Variant title="Empty">
<div class="p-5">
<GroupCard :group="basicGroup" />
</div>
</Variant>
<Variant title="With media">
<div class="p-5">
<GroupCard :group="groupWithMedia" />
</div>
</Variant>
<Variant title="with followers or members">
<div class="p-5">
<GroupCard :group="groupWithFollowersOrMembers" />
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import GroupCard from "./GroupCard.vue";
const basicGroup: IActor = {
name: "Framasoft",
preferredUsername: "framasoft",
avatar: null,
domain: "mobilizon.fr",
url: "",
summary: "",
suspended: false,
members: { total: 0, elements: [] },
followers: { total: 0, elements: [] },
};
const groupWithMedia = {
...basicGroup,
banner: {
url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
},
avatar: {
url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
},
};
const groupWithFollowersOrMembers = {
...groupWithMedia,
members: { total: 2, elements: [] },
followers: { total: 5, elements: [] },
};
</script>

View File

@@ -4,26 +4,21 @@
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="card"
class="card flex flex-col max-w-md bg-white dark:bg-mbz-purple dark:text-white rounded shadow-lg"
>
<div class="card-image">
<figure class="image is-16by9">
<lazy-image-wrapper
:picture="group.banner"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
/>
</figure>
</div>
<div class="card-content">
<div class="media mb-2">
<div class="media-left">
<figure class="image is-48x48" v-if="group.avatar">
<img class="is-rounded" :src="group.avatar.url" alt="" />
<figure class="rounded-t-lg flex justify-center h-1/4">
<lazy-image-wrapper :picture="group.banner" :rounded="true" />
</figure>
<div class="py-2 pl-2">
<div class="flex gap-1 mb-2">
<div class="">
<figure class="" v-if="group.avatar">
<img class="rounded-xl" :src="group.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
<AccountGroup v-else :size="48" />
</div>
<div class="media-content">
<h3 class="is-size-5 group-title" dir="auto">
<div class="">
<h3 class="text-2xl" dir="auto">
{{ displayName(group) }}
</h3>
<span class="is-6 has-text-grey-dark group-federated-username">
@@ -34,19 +29,18 @@
<div class="mb-2 line-clamp-3" dir="auto" v-html="group.summary" />
<div>
<inline-address
class="has-text-grey-dark"
v-if="group.physicalAddress && addressFullName(group.physicalAddress)"
:physicalAddress="group.physicalAddress"
/>
<p class="has-text-grey-dark">
<b-icon icon="account" />
<p class="flex">
<Account />
{{
$tc(
t(
"{count} members or followers",
group.members.total + group.followers.total,
{
count: group.members.total + group.followers.total,
}
},
group.members.total + group.followers.total
)
}}
</p>
@@ -55,85 +49,19 @@
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { displayName, IGroup, usernameWithDomain } from "@/types/actor";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import RouteName from "../../router/name";
import InlineAddress from "@/components/Address/InlineAddress.vue";
import { addressFullName } from "@/types/address.model";
import { useI18n } from "vue-i18n";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import Account from "vue-material-design-icons/Account.vue";
@Component({
components: {
LazyImageWrapper,
InlineAddress,
},
})
export default class GroupCard extends Vue {
@Prop({ required: true }) group!: IGroup;
defineProps<{
group: IGroup;
}>();
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
addressFullName = addressFullName;
}
const { t } = useI18n({ useScope: "global" });
</script>
<style lang="scss" scoped>
.card {
.card-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
height: 100%;
.content {
flex: 1;
}
::v-deep .content {
& > *:first-child {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 0;
* {
font-weight: normal;
text-transform: none;
font-style: normal;
text-decoration: none;
}
}
& > *:not(:first-child) {
display: none;
}
}
.media-left {
margin-right: inherit;
margin-inline-end: 0.5rem;
}
.media-content {
overflow: hidden;
text-overflow: ellipsis;
.group-title {
line-height: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: bold;
}
.group-federated-username {
font-size: 14px;
}
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<Story>
<Variant title="simple member">
<div class="p-5">
<GroupMemberCard :member="basicMember" />
</div>
</Variant>
<Variant title="moderator">
<div class="p-5">
<GroupMemberCard :member="moderatorMember" />
</div>
</Variant>
<Variant title="administrator">
<div class="p-5">
<GroupMemberCard :member="adminMember" />
</div>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IActor } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import GroupMemberCard from "./GroupMemberCard.vue";
const baseActorAvatar = {
id: "",
name: "",
alt: "",
metadata: {},
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
};
const basePerson: IActor = {
name: "Thomas Citharel",
preferredUsername: "tcit",
avatar: baseActorAvatar,
domain: null,
url: "",
summary: "",
suspended: false,
};
const basicGroup: IActor = {
name: "Framasoft",
preferredUsername: "framasoft",
avatar: {
url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
},
domain: "mobilizon.fr",
url: "",
summary: `<p><strong>La Fediverse</strong>, <strong>c'est la <em><u>Féd</u>ération qui englobe l'Un<u>ivers</u> des réseaux sociaux libres et décentralisés,</em> </strong>dont Mobilizon (évènements), Mastodon (microblog), Peertube (vidéos), Pixelfed (photos), Funkwhale (musique), Matrix (messagerie instantanée)... et tant d'autres font partie.</p><p><strong>Et "La Fediverse <em>Nantaise</em>" est un collectif cherchant à faire connaître localement tout le potentiel de ces réseaux ! :-)</strong></p>`,
suspended: false,
members: { total: 0, elements: [] },
followers: { total: 0, elements: [] },
};
const basicMember: IMember = {
parent: basicGroup as IActor,
actor: basePerson,
role: MemberRole.MEMBER,
};
const moderatorMember: IMember = {
parent: basicGroup,
actor: basePerson,
role: MemberRole.MODERATOR,
};
const adminMember: IMember = {
parent: basicGroup,
actor: basePerson,
role: MemberRole.ADMINISTRATOR,
};
const groupWithMedia = {
...basicGroup,
banner: {
url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
},
avatar: {
url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
},
};
const groupWithFollowersOrMembers = {
...groupWithMedia,
members: { total: 2, elements: [] },
followers: { total: 5, elements: [] },
};
</script>

View File

@@ -1,22 +1,37 @@
<template>
<div class="card">
<div class="identity-header" dir="auto">
<figure class="image is-24x24" v-if="member.actor.avatar">
<img class="is-rounded" :src="member.actor.avatar.url" alt="" />
<div class="rounded shadow-lg bg-white dark:bg-mbz-purple dark:text-white">
<div
class="bg-yellow-2 text-black flex items-center gap-1 p-2 rounded-t-lg"
dir="auto"
>
<figure class="" v-if="member.actor.avatar">
<img
class="rounded-xl"
:src="member.actor.avatar.url"
alt=""
width="24"
height="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
<AccountCircle v-else :size="24" />
{{ displayNameAndUsername(member.actor) }}
</div>
<div class="card-content" dir="auto">
<div>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="member.parent.avatar">
<img class="is-rounded" :src="member.parent.avatar.url" alt="" />
<div class="flex items-center gap-2 p-2 pr-4" dir="auto">
<div class="flex-1">
<div class="p-2 flex gap-2">
<div class="">
<figure class="" v-if="member.parent.avatar">
<img
class="rounded-lg"
:src="member.parent.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-group" />
<AccountGroup v-else :size="48" />
</div>
<div class="media-content" dir="auto">
<div class="" dir="auto">
<router-link
:to="{
name: RouteName.GROUP,
@@ -25,94 +40,65 @@
},
}"
>
<h2>{{ member.parent.name }}</h2>
<p class="is-6 has-text-grey-dark">
<span>{{ `@${usernameWithDomain(member.parent)}` }}</span>
<b-taglist>
<b-tag
type="is-info"
<h2 class="text-2xl">{{ member.parent.name }}</h2>
<div class="">
<span class="text-sm">{{
`@${usernameWithDomain(member.parent)}`
}}</span>
<div>
<tag
variant="info"
v-if="member.role === MemberRole.ADMINISTRATOR"
>{{ $t("Administrator") }}</b-tag
>{{ $t("Administrator") }}</tag
>
<b-tag
type="is-info"
<tag
variant="info"
v-else-if="member.role === MemberRole.MODERATOR"
>{{ $t("Moderator") }}</b-tag
>{{ $t("Moderator") }}</tag
>
</b-taglist>
</p>
</div>
</div>
</router-link>
</div>
</div>
<div class="content" v-if="member.parent.summary">
<div class="mt-3 prose lg:prose-xl" v-if="member.parent.summary">
<p v-html="member.parent.summary" />
</div>
</div>
<div>
<b-dropdown aria-role="list" position="is-bottom-left">
<b-icon icon="dots-horizontal" slot="trigger" />
<o-dropdown aria-role="list" position="bottom-left">
<template #trigger>
<DotsHorizontal class="cursor-pointer" />
</template>
<b-dropdown-item aria-role="listitem" @click="$emit('leave')">
<b-icon icon="exit-to-app" />
<o-dropdown-item
class="inline-flex gap-1"
aria-role="listitem"
@click="emit('leave')"
>
<ExitToApp />
{{ $t("Leave") }}
</b-dropdown-item>
</b-dropdown>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { displayNameAndUsername, usernameWithDomain } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import RouteName from "../../router/name";
import ExitToApp from "vue-material-design-icons/ExitToApp.vue";
import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Tag from "@/components/Tag.vue";
@Component
export default class GroupMemberCard extends Vue {
@Prop({ required: true }) member!: IMember;
defineProps<{
member: IMember;
}>();
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayNameAndUsername = displayNameAndUsername;
MemberRole = MemberRole;
}
const emit = defineEmits(["leave"]);
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.card {
.card-content {
display: flex;
align-items: center;
& > div:first-child {
flex: 1;
}
& > div:last-child {
cursor: pointer;
}
.media-content {
::v-deep .tags {
margin-bottom: 0;
}
}
}
.identity-header {
background: $yellow-2;
display: flex;
padding: 5px;
figure,
span.icon {
@include padding-right(3px);
}
}
}
</style>

View File

@@ -1,95 +1,40 @@
<template>
<section>
<div class="group-section-title" :class="{ privateSection }">
<h2>
<b-icon :icon="icon" />
<span>{{ title }}</span>
</h2>
<router-link :to="route">{{ $t("View all") }}</router-link>
<section
class="flex flex-col mb-3 border-2"
:class="{
'border-mbz-purple': privateSection,
'border-yellow-1': !privateSection,
}"
>
<div class="flex items-stretch py-3 px-1 bg-yellow-1 text-violet-title">
<div class="flex flex-1 gap-1">
<o-icon :icon="icon" custom-size="36" />
<h2 class="text-2xl font-medium mt-0">{{ title }}</h2>
</div>
<router-link class="self-center" :to="route">{{
t("View all")
}}</router-link>
</div>
<div class="main-slot">
<div class="flex-1">
<slot></slot>
</div>
<div class="create-slot">
<div class="flex justify-end p-2">
<slot name="create"></slot>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { Route } from "vue-router";
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
@Component
export default class GroupSection extends Vue {
@Prop({ required: true, type: String }) title!: string;
@Prop({ required: true, type: String }) icon!: string;
@Prop({ required: false, type: Boolean, default: true })
privateSection!: boolean;
@Prop({ required: true, type: Object }) route!: Route;
}
withDefaults(
defineProps<{
title: string;
icon: string;
privateSection?: boolean;
route: { name: string; params: { preferredUsername: string } };
}>(),
{ privateSection: true }
);
const { t } = useI18n({ useScope: "global" });
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
section {
display: flex;
flex-direction: column;
margin-bottom: 2rem;
border: 2px solid $violet;
min-height: 30vh;
.create-slot {
display: flex;
justify-content: flex-end;
padding-bottom: 0.5rem;
@include padding-right(0.5rem);
}
.main-slot {
min-height: 5rem;
padding: 2px 5px;
flex: 1;
}
}
div.group-section-title {
--title-color: $violet-2;
display: flex;
align-items: stretch;
background: $secondary;
color: var(--title-color);
&.privateSection {
color: $purple-3;
background: $violet-2;
}
::v-deep & > a {
align-self: center;
@include margin-right(5px);
color: var(--title-color);
}
h2 {
flex: 1;
::v-deep span {
display: inline;
padding: 3px 8px;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-weight: 500;
font-size: 30px;
flex: 1;
}
::v-deep span.icon {
flex: 0;
height: 100%;
}
}
}
</style>

View File

@@ -2,20 +2,22 @@
<div class="card">
<div class="card-content media">
<div class="media-content">
<div class="content">
<i18n
<div class="prose dark:prose-invert">
<i18n-t
tag="p"
path="You have been invited by {invitedBy} to the following group:"
keypath="You have been invited by {invitedBy} to the following group:"
>
<b slot="invitedBy">{{ member.invitedBy.name }}</b>
</i18n>
<template v-slot:invitedBy>
<b>{{ member?.invitedBy?.name }}</b>
</template>
</i18n-t>
</div>
<div class="media subfield">
<div class="media-left">
<figure class="image is-48x48" v-if="member.parent.avatar">
<img class="is-rounded" :src="member.parent.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
<o-icon v-else size="large" icon="account-group" />
</div>
<div class="media-content">
<div class="level">
@@ -45,20 +47,20 @@
</div>
<div class="level-right">
<div class="level-item">
<b-button
type="is-success"
<o-button
variant="success"
@click="$emit('accept', member.id)"
>
{{ $t("Accept") }}
</b-button>
</o-button>
</div>
<div class="level-item">
<b-button
type="is-danger"
<o-button
variant="danger"
@click="$emit('reject', member.id)"
>
{{ $t("Decline") }}
</b-button>
</o-button>
</div>
</div>
</div>
@@ -69,20 +71,14 @@
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
@Component
export default class InvitationCard extends Vue {
@Prop({ required: true }) member!: IMember;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
defineProps<{
member: IMember;
}>();
</script>
<style lang="scss" scoped>

View File

@@ -9,78 +9,63 @@
/>
</section>
</template>
<script lang="ts">
<script lang="ts" setup>
import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
import { Component, Prop, Vue } from "vue-property-decorator";
import InvitationCard from "@/components/Group/InvitationCard.vue";
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import { useMutation } from "@vue/apollo-composable";
import { ErrorResponse } from "@/types/errors.model";
@Component({
components: {
InvitationCard,
},
})
export default class Invitations extends Vue {
@Prop({ required: true, type: Array }) invitations!: IMember;
const props = defineProps<{
invitations: IMember[];
}>();
async acceptInvitation(id: string): Promise<void> {
try {
await this.$apollo.mutate<{ acceptInvitation: IMember }>({
mutation: ACCEPT_INVITATION,
variables: {
id,
},
refetchQueries({ data }) {
const profile = data?.acceptInvitation?.actor as IPerson;
const group = data?.acceptInvitation?.parent as IGroup;
if (profile && group) {
return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
}
return [];
},
});
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
const { mutate: acceptInvitation, onError: onAcceptInvitationError } =
useMutation(ACCEPT_INVITATION, {
refetchQueries({ data }) {
const profile = data?.acceptInvitation?.actor as IPerson;
const group = data?.acceptInvitation?.parent as IGroup;
if (profile && group) {
return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
}
}
}
return [];
},
});
async rejectInvitation(id: string): Promise<void> {
try {
await this.$apollo.mutate<{ rejectInvitation: IMember }>({
mutation: REJECT_INVITATION,
variables: {
id,
},
refetchQueries({ data }) {
const profile = data?.rejectInvitation?.actor as IPerson;
const group = data?.rejectInvitation?.parent as IGroup;
if (profile && group) {
return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
}
return [];
},
});
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
const notifier = inject<Notifier>("notifier");
const onError = (error: ErrorResponse) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier.error(error.graphQLErrors[0].message);
}
}
};
onAcceptInvitationError((err) => onError(err as unknown as ErrorResponse));
const { mutate: rejectInvitation, onError: onRejectInvitationError } =
useMutation(REJECT_INVITATION, {
refetchQueries({ data }) {
const profile = data?.rejectInvitation?.actor as IPerson;
const group = data?.rejectInvitation?.parent as IGroup;
if (profile && group) {
return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
}
return [];
},
});
onRejectInvitationError((err) => onError(err as unknown as ErrorResponse));
</script>

View File

@@ -3,60 +3,42 @@
v-if="uri"
:uri="uri"
:pathAfterLogin="`/@${preferredUsername}`"
:sentence="sentence"
:sentence="
t(
`We will redirect you to your instance in order to interact with this group`
)
"
/>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue";
import { FETCH_GROUP } from "@/graphql/group";
import { displayName, IGroup } from "@/types/actor";
import { useGroup } from "@/composition/apollo/group";
import { displayName } from "@/types/actor";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
@Component({
components: { RedirectWithAccount },
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.$route.params.preferredUsername,
beforeDateTime: null,
afterDateTime: new Date(),
};
},
skip() {
return !this.$route.params.preferredUsername;
},
},
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.$t("Join group {group}", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
group: this.groupTitle,
}) as string,
};
},
})
export default class JoinGroupWithAccount extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
const props = defineProps<{
preferredUsername: string;
}>();
group!: IGroup;
const { group } = useGroup(props.preferredUsername);
get uri(): string {
return this.group?.url;
}
const { t } = useI18n({ useScope: "global" });
get groupTitle(): undefined | string {
return this.group && displayName(this.group);
}
useHead({
title: computed(() =>
t("Join group {group}", {
group: groupTitle.value,
})
),
});
sentence = this.$t(
"We will redirect you to your instance in order to interact with this group"
);
}
const uri = computed((): string | undefined => {
return group.value?.url;
});
const groupTitle = computed((): undefined | string => {
return group && displayName(group.value);
});
</script>

View File

@@ -8,20 +8,13 @@
/>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { IGroup } from "@/types/actor";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import GroupCard from "./GroupCard.vue";
@Component({
components: {
GroupCard,
},
})
export default class MultiGroupCard extends Vue {
@Prop({ type: Array as PropType<IGroup[]>, required: true })
groups!: IGroup[];
}
defineProps<{
groups: IGroup[];
}>();
</script>
<style lang="scss" scoped>
.multi-card-group {

View File

@@ -0,0 +1,48 @@
<template>
<group-section
:title="t('Discussions')"
icon="chat"
:route="{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template #default>
<div v-if="group?.discussions?.total ?? 0 > 0">
<discussion-list-item
v-for="discussion in group?.discussions?.elements ?? []"
:key="discussion.id"
:discussion="discussion"
/>
</div>
<empty-content v-else icon="chat" :inline="true">
{{ t("No discussions yet") }}
</empty-content>
</template>
<template #create>
<o-button
tag="router-link"
:to="{
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ t("+ Start a discussion") }}</o-button
>
</template>
</group-section>
</template>
<script lang="ts" setup>
import RouteName from "@/router/name";
import { IGroup } from "@/types/actor/group.model";
import { usernameWithDomain } from "@/types/actor";
import { useI18n } from "vue-i18n";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import GroupSection from "@/components/Group/GroupSection.vue";
const { t } = useI18n({ useScope: "global" });
defineProps<{ group: IGroup }>();
</script>

View File

@@ -0,0 +1,54 @@
<template>
<group-section
:title="t('Events')"
icon="calendar"
:privateSection="false"
:route="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template #default>
<div
class="flex flex-wrap gap-2 py-1"
v-if="group && group.organizedEvents.total > 0"
>
<event-minimalist-card
v-for="event in group.organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
/>
</div>
<empty-content v-else-if="group" icon="calendar" :inline="true">
{{ t("No public upcoming events") }}
</empty-content>
<!-- <o-skeleton animated v-else></o-skeleton> -->
</template>
<template #create>
<o-button
tag="router-link"
v-if="isModerator"
:to="{
name: RouteName.CREATE_EVENT,
query: { actorId: group?.id },
}"
class="button is-primary"
>{{ t("+ Create an event") }}</o-button
>
</template>
</group-section>
</template>
<script lang="ts" setup>
import RouteName from "@/router/name";
import { IGroup } from "@/types/actor/group.model";
import { usernameWithDomain } from "@/types/actor";
import { useI18n } from "vue-i18n";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import GroupSection from "@/components/Group/GroupSection.vue";
const { t } = useI18n({ useScope: "global" });
defineProps<{ group: IGroup; isModerator: boolean }>();
</script>

View File

@@ -0,0 +1,50 @@
<template>
<group-section
:title="t('Public page')"
icon="bullhorn"
:privateSection="false"
:route="{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template #default>
<div class="p-1">
<multi-post-list-item
v-if="group?.posts?.total ?? 0 > 0"
:posts="(group?.posts?.elements ?? []).slice(0, 3)"
:isCurrentActorMember="isMember"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ t("No posts yet") }}
</empty-content>
</div>
</template>
<template #create>
<o-button
tag="router-link"
v-if="isModerator"
:to="{
name: RouteName.POST_CREATE,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ t("+ Create a post") }}</o-button
>
</template>
</group-section>
</template>
<script lang="ts" setup>
import RouteName from "@/router/name";
import { IGroup } from "@/types/actor/group.model";
import { usernameWithDomain } from "@/types/actor";
import { useI18n } from "vue-i18n";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import MultiPostListItem from "@/components/Post/MultiPostListItem.vue";
import GroupSection from "@/components/Group/GroupSection.vue";
const { t } = useI18n({ useScope: "global" });
defineProps<{ group: IGroup; isModerator: boolean; isMember: boolean }>();
</script>

View File

@@ -0,0 +1,60 @@
<template>
<group-section
:title="t('Resources')"
icon="link"
:route="{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template #default>
<div v-if="group?.resources?.elements?.length ?? 0 > 0" class="p-1">
<div
v-for="resource in group?.resources?.elements ?? []"
:key="resource.id"
>
<resource-item
:resource="resource"
v-if="resource.type !== 'folder'"
:inline="true"
/>
<folder-item
:resource="resource"
:group="group"
v-else-if="group"
:inline="true"
/>
</div>
</div>
<empty-content v-else icon="link" :inline="true">
{{ t("No resources yet") }}
</empty-content>
</template>
<template #create>
<o-button
tag="router-link"
:to="{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ t("+ Add a resource") }}</o-button
>
</template>
</group-section>
</template>
<script lang="ts" setup>
import RouteName from "@/router/name";
import { IGroup } from "@/types/actor/group.model";
import { usernameWithDomain } from "@/types/actor";
import { useI18n } from "vue-i18n";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import GroupSection from "@/components/Group/GroupSection.vue";
const { t } = useI18n({ useScope: "global" });
defineProps<{ group: IGroup }>();
</script>

View File

@@ -1,206 +1,41 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Share this group") }}</p>
</header>
<section class="modal-card-body is-flex" v-if="group">
<div class="container has-text-centered">
<b-notification
type="is-warning"
v-if="group.visibility !== GroupVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This group is accessible only through it's link. Be careful where you post this link."
)
}}
</b-notification>
<b-field :label="$t('Group URL')" label-for="group-url-text">
<b-input
id="group-url-text"
ref="groupURLInput"
:value="group.url"
expanded
/>
<p class="control">
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
:title="$t('Copy URL to clipboard')"
/>
</b-tooltip>
</p>
</b-field>
<div>
<a
:href="twitterShareUrl"
target="_blank"
rel="nofollow noopener"
title="Twitter"
><b-icon icon="twitter" size="is-large" type="is-primary"
/></a>
<a
:href="mastodonShareUrl"
class="mastodon"
target="_blank"
rel="nofollow noopener"
title="Mastodon"
>
<mastodon-logo />
</a>
<a
:href="facebookShareUrl"
target="_blank"
rel="nofollow noopener"
title="Facebook"
><b-icon icon="facebook" size="is-large" type="is-primary"
/></a>
<a
:href="linkedInShareUrl"
target="_blank"
rel="nofollow noopener"
title="LinkedIn"
><b-icon icon="linkedin" size="is-large" type="is-primary"
/></a>
<a
:href="whatsAppShareUrl"
target="_blank"
rel="nofollow noopener"
title="WhatsApp"
><b-icon icon="whatsapp" size="is-large" type="is-primary"
/></a>
<a
:href="telegramShareUrl"
class="telegram"
target="_blank"
rel="nofollow noopener"
title="Telegram"
>
<telegram-logo />
</a>
<a
title="Diaspora"
:href="diasporaShareUrl"
class="diaspora"
target="_blank"
rel="nofollow noopener"
>
<diaspora-logo />
</a>
<a
:href="emailShareUrl"
target="_blank"
rel="nofollow noopener"
title="Email"
><b-icon icon="email" size="is-large" type="is-primary"
/></a>
</div>
</div>
</section>
</div>
<share-modal
:title="t('Share this group')"
:text="displayName(group)"
:url="group.url"
:input-label="t('Group URL')"
>
<o-notification
variant="warning"
v-if="group.visibility !== GroupVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This group is accessible only through it's link. Be careful where you post this link."
)
}}
</o-notification>
</share-modal>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
<script lang="ts" setup>
import { GroupVisibility } from "@/types/enums";
import DiasporaLogo from "../Share/DiasporaLogo.vue";
import MastodonLogo from "../Share/MastodonLogo.vue";
import TelegramLogo from "../Share/TelegramLogo.vue";
import { displayName, IGroup } from "@/types/actor";
import { useI18n } from "vue-i18n";
import ShareModal from "@/components/Share/ShareModal.vue";
@Component({
components: {
DiasporaLogo,
MastodonLogo,
TelegramLogo,
},
})
export default class ShareGroupModal extends Vue {
@Prop({ type: Object, required: true }) group!: IGroup;
const { t } = useI18n({ useScope: "global" });
@Ref("groupURLInput") readonly groupURLInput!: any;
GroupVisibility = GroupVisibility;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
this.group.url
)}&text=${displayName(this.group)}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
this.group.url
)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
this.group.url
)}&title=${displayName(this.group)}`;
}
get whatsAppShareUrl(): string {
return `https://wa.me/?text=${encodeURIComponent(this.basicTextToEncode)}`;
}
get telegramShareUrl(): string {
return `https://t.me/share/url?url=${encodeURIComponent(
this.group.url
)}&text=${encodeURIComponent(displayName(this.group))}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.group.url}&subject=${displayName(
this.group
)}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(
displayName(this.group)
)}&url=${encodeURIComponent(this.group.url)}`;
}
get mastodonShareUrl(): string {
return `https://toot.kytta.dev/?text=${encodeURIComponent(
this.basicTextToEncode
)}`;
}
get basicTextToEncode(): string {
return `${displayName(this.group)}\r\n${this.group.url}`;
}
copyURL(): void {
this.groupURLInput.$refs.input.select();
document.execCommand("copy");
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
}
defineProps<{
group: IGroup;
}>();
</script>
<style lang="scss" scoped>
.diaspora,
.mastodon,
.telegram {
::v-deep span svg {
:deep(span svg) {
width: 2.25rem;
}
}

View File

@@ -0,0 +1,16 @@
<template>
<Story :setup-app="setupApp">
<Variant>
<CategoriesPreview />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { apolloClient } from "@/vue-apollo";
import { DefaultApolloClient } from "@vue/apollo-composable";
import CategoriesPreview from "./CategoriesPreview.vue";
function setupApp({ app }) {
app.provide(DefaultApolloClient, apolloClient);
}
</script>

View File

@@ -0,0 +1,68 @@
<template>
<section
class="mx-auto container flex flex-wrap items-center justify-center gap-3 md:gap-5 my-3"
>
<CategoryCard
v-for="category in promotedCategories"
:key="category.key"
:category="category"
:with-details="false"
/>
<router-link :to="{ name: RouteName.CATEGORIES }">
<div
class="flex items-end brightness-85 h-36 w-36 md:h-52 md:w-52 rounded-lg font-semibold text-lg md:text-xl p-4 text-white bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500"
>
<span>
{{ t("View all categories") }}
</span>
</div>
</router-link>
</section>
</template>
<script lang="ts" setup>
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import CategoryCard from "../Categories/CategoryCard.vue";
import RouteName from "@/router/name";
import { CategoryStatsModel } from "@/types/stats.model";
import { CATEGORY_STATISTICS } from "@/graphql/statistics";
import { useI18n } from "vue-i18n";
import shuffle from "lodash/shuffle";
import { categoriesWithPictures } from "../Categories/constants";
import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config";
const { t } = useI18n({ useScope: "global" });
const { result: categoryStatsResult } = useQuery<{
categoryStatistics: CategoryStatsModel[];
}>(CATEGORY_STATISTICS);
const categoryStats = computed(
() => categoryStatsResult.value?.categoryStatistics ?? []
);
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const eventCategories = computed(() => config.value?.eventCategories ?? []);
const eventCategoryLabel = (categoryId: string): string | undefined => {
return eventCategories.value.find(({ id }) => categoryId == id)?.label;
};
const promotedCategories = computed((): CategoryStatsModel[] => {
return shuffle(
categoryStats.value.filter(
({ key, number }) =>
key !== "MEETING" && number >= 1 && categoriesWithPictures.includes(key)
)
)
.map(({ key, number }) => ({
key,
number,
label: eventCategoryLabel(key),
}))
.slice(0, 10);
});
</script>

View File

@@ -0,0 +1,10 @@
<template>
<Story>
<Variant>
<MobilizonPresentation />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import MobilizonPresentation from "./MobilizonPresentation.vue";
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="container mx-auto py-4 px-2">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<h3 class="dark:text-white text-3xl font-bold">
{{ t("A practical tool") }}
</h3>
<p
class="dark:text-white"
v-html="
t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"
/>
</div>
<div>
<h3 class="dark:text-white text-3xl font-bold">
{{ t("An ethical alternative") }}
</h3>
<p
class="dark:text-white"
v-html="
t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"
/>
</div>
<div>
<h3 class="dark:text-white text-3xl font-bold">
{{ t("A federated software") }}
</h3>
<p
class="dark:text-white"
v-html="
t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"
/>
</div>
</div>
<div class="mt-4 text-center">
<o-button
variant="primary"
size="large"
tag="a"
href="https://joinmobilizon.org"
>{{ t("Learn more about Mobilizon") }}</o-button
>
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
</script>

View File

@@ -0,0 +1,22 @@
<template>
<Story :setup-app="setupApp">
<Variant>
<SearchFields
:search="''"
:location="{}"
@update:location="hstEvent('update location', $event)"
@update:search="hstEvent('update search', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { apolloClient } from "@/vue-apollo";
import { DefaultApolloClient } from "@vue/apollo-composable";
import SearchFields from "./SearchFields.vue";
import { hstEvent } from "histoire/client";
function setupApp({ app }) {
app.provide(DefaultApolloClient, apolloClient);
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<form
id="search-anchor"
class="container mx-auto my-3 px-2 flex flex-wrap flex-col sm:flex-row items-stretch gap-2 text-center items-center justify-center dark:text-slate-100"
role="search"
@submit.prevent="emit('submit')"
>
<o-input
class="flex-1"
v-model="search"
:placeholder="t('Keyword, event title, group name, etc.')"
autofocus
autocapitalize="off"
autocomplete="off"
autocorrect="off"
maxlength="1024"
/>
<full-address-auto-complete
:type="AddressSearchType.ADMINISTRATIVE"
:doGeoLocation="false"
v-model="location"
/>
<o-button type="submit" icon-left="magnify">
<template v-if="search">{{ t("Go!") }}</template>
<template v-else>{{ t("Explore!") }}</template>
</o-button>
</form>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { AddressSearchType } from "@/types/enums";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import FullAddressAutoComplete from "../Event/FullAddressAutoComplete.vue";
const props = defineProps<{
location: IAddress;
search: string;
}>();
const emit = defineEmits<{
(event: "update:location", location: IAddress): void;
(event: "update:search", newSearch: string): void;
(event: "submit"): void;
}>();
const location = computed({
get(): IAddress {
return props.location;
},
set(newLocation: IAddress) {
emit("update:location", newLocation);
},
});
const search = computed({
get(): string {
return props.search;
},
set(newSearch: string) {
emit("update:search", newSearch);
},
});
const { t } = useI18n({ useScope: "global" });
</script>
<style scoped>
#search-anchor :deep(.o-ctrl-input) {
flex: 1;
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<Story :setup-app="setupApp">
<Variant :title="'Open'">
<UnloggedIntroduction />
</Variant>
<Variant :title="'Closed'">
<UnloggedIntroduction />
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { apolloClient } from "@/vue-apollo";
import { DefaultApolloClient } from "@vue/apollo-composable";
import UnloggedIntroduction from "./UnloggedIntroduction.vue";
function setupApp({ app }) {
app.provide(DefaultApolloClient, apolloClient);
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<section v-if="config" class="container mx-auto px-2 my-3">
<h1 class="dark:text-white font-bold">
{{ config.slogan || t("Gather ⋅ Organize ⋅ Mobilize") }}
</h1>
<i18n-t
keypath="Join {instance}, a Mobilizon instance"
tag="p"
class="dark:text-white"
>
<template #instance>
<b>{{ config.name }}</b>
</template>
</i18n-t>
<p class="dark:text-white mb-2">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->
<div class="flex flex-wrap gap-2 items-center">
<o-button
variant="primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ t("Create an account") }}</o-button
>
<!-- We don't invite to find other instances yet -->
<!-- <o-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ t('Find an instance') }}</o-button> -->
<router-link
:to="{ name: RouteName.ABOUT }"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-violet-title focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
{{ t("Learn more about {instance}", { instance: config.name }) }}
</router-link>
</div>
</section>
</template>
<script lang="ts" setup>
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import RouteName from "@/router/name";
import { useI18n } from "vue-i18n";
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const { t } = useI18n({ useScope: "global" });
</script>

View File

@@ -2,37 +2,33 @@
<canvas ref="canvas" width="32" height="32" />
</template>
<script lang="ts">
<script lang="ts" setup>
import { decode } from "blurhash";
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
@Component
export default class BlurhashImg extends Vue {
@Prop({ type: String, required: true }) hash!: string;
@Prop({ type: Number, default: 1 }) aspectRatio!: string;
import { ref, onMounted } from "vue";
@Ref("canvas") readonly canvas!: any;
const props = withDefaults(
defineProps<{
hash: string;
aspectRatio?: number;
}>(),
{ aspectRatio: 1 }
);
mounted(): void {
try {
const pixels = decode(this.hash, 32, 32);
const canvas = ref<HTMLCanvasElement | undefined>(undefined);
onMounted(() => {
try {
if (canvas.value) {
const pixels = decode(props.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);
const context = canvas.value.getContext("2d");
if (context) {
context.putImageData(imageData, 0, 0);
}
}
} catch (e) {
console.error(e);
}
}
});
</script>
<style lang="scss" scoped>
canvas {
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width: 100%;
height: 100%;
}
</style>

Some files were not shown because too many files have changed in this diff Show More