Merge branch 'feat-private-messages' into 'main'

Private messages

Closes #496

See merge request framasoft/mobilizon!1477
This commit is contained in:
Thomas Citharel
2023-11-20 16:58:20 +00:00
676 changed files with 17527 additions and 9061 deletions

18
src/@types/dom.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
declare global {
interface GeolocationCoordinates {
readonly accuracy: number;
readonly altitude: number | null;
readonly altitudeAccuracy: number | null;
readonly heading: number | null;
readonly latitude: number;
readonly longitude: number;
readonly speed: number | null;
}
interface GeolocationPosition {
readonly coords: GeolocationCoordinates;
readonly timestamp: number;
}
}
export {};

1
src/@types/v-tooltip/index.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "v-tooltip";

57
src/@types/vuedraggable/index.d.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
declare module "vuedraggable" {
import Vue, { ComponentOptions } from "vue";
export interface DraggedContext<T> {
index: number;
futureIndex: number;
element: T;
}
export interface DropContext<T> {
index: number;
component: Vue;
element: T;
}
export interface Rectangle {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
export interface MoveEvent<T> {
originalEvent: DragEvent;
dragged: Element;
draggedContext: DraggedContext<T>;
draggedRect: Rectangle;
related: Element;
relatedContext: DropContext<T>;
relatedRect: Rectangle;
from: Element;
to: Element;
willInsertAfter: boolean;
isTrusted: boolean;
}
export interface ChangeEvent<T> {
added: {
newIndex: number;
element: T;
};
removed: {
oldIndex: number;
element: T;
};
moved: {
newIndex: number;
oldIndex: number;
};
}
const draggableComponent: ComponentOptions<Vue>;
export default draggableComponent;
}

338
src/App.vue Normal file
View File

@@ -0,0 +1,338 @@
<template>
<div id="mobilizon">
<!-- <VueAnnouncer />
<VueSkipTo to="#main" :label="t('Skip to main content')" /> -->
<NavBar />
<div v-if="isDemoMode">
<o-notification
class="container mx-auto"
variant="danger"
:title="t('Warning').toLocaleUpperCase()"
closable
:aria-close-label="t('Close')"
>
<p>
{{ t("This is a demonstration site to test Mobilizon.") }}
<b>{{ t("Please do not use it in any real way.") }}</b>
{{
t(
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
)
}}
</p>
</o-notification>
</div>
<ErrorComponent v-if="error" :error="error" />
<main id="main" class="px-2 py-4" v-else>
<router-view></router-view>
</main>
<mobilizon-footer />
</div>
</template>
<script lang="ts" setup>
import NavBar from "@/components/NavBar.vue";
import {
AUTH_ACCESS_TOKEN,
AUTH_USER_EMAIL,
AUTH_USER_ID,
AUTH_USER_ROLE,
} from "@/constants";
import { UPDATE_CURRENT_USER_CLIENT } from "@/graphql/user";
import MobilizonFooter from "@/components/PageFooter.vue";
import { jwtDecode } from "jwt-decode";
import type { JwtPayload } from "jwt-decode";
import { refreshAccessToken } from "@/apollo/utils";
import {
reactive,
ref,
provide,
onUnmounted,
onMounted,
onBeforeMount,
inject,
defineAsyncComponent,
computed,
watch,
onBeforeUnmount,
} from "vue";
import { LocationType } from "@/types/user-location.model";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
initializeCurrentActor,
NoIdentitiesException,
} from "@/utils/identity";
import { useI18n } from "vue-i18n";
import { Snackbar } from "@/plugins/snackbar";
import { Notifier } from "@/plugins/notifier";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useRouter } from "vue-router";
import RouteName from "@/router/name";
const { result: configResult } = useQuery<{ config: IConfig }>(
CONFIG,
undefined,
{ fetchPolicy: "cache-only" }
);
const config = computed(() => configResult.value?.config);
const ErrorComponent = defineAsyncComponent(
() => import("@/components/ErrorComponent.vue")
);
const { t } = useI18n({ useScope: "global" });
const location = computed(() => config.value?.location);
const userLocation = reactive<LocationType>({
lon: undefined,
lat: undefined,
name: undefined,
picture: undefined,
isIPLocation: true,
accuracy: 100,
});
const updateUserLocation = (newLocation: LocationType) => {
userLocation.lat = newLocation.lat;
userLocation.lon = newLocation.lon;
userLocation.name = newLocation.name;
userLocation.picture = newLocation.picture;
userLocation.isIPLocation = newLocation.isIPLocation;
userLocation.accuracy = newLocation.accuracy;
};
updateUserLocation({
lat: location.value?.latitude,
lon: location.value?.longitude,
name: "", // config.ipLocation.country.name,
isIPLocation: true,
accuracy: 150, // config.ipLocation.location.accuracy_radius * 1.5 || 150,
});
provide("userLocation", {
userLocation,
updateUserLocation,
});
// const routerView = ref("routerView");
const error = ref<Error | null>(null);
const online = ref(true);
const interval = ref<number>(0);
const notifier = inject<Notifier>("notifier");
interval.value = window.setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwtDecode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken();
}
}
}, 60000) as unknown as number;
onBeforeMount(async () => {
console.debug("Before mount App");
if (initializeCurrentUser()) {
try {
await initializeCurrentActor();
} catch (err) {
if (err instanceof NoIdentitiesException) {
await router.push({
name: RouteName.REGISTER_PROFILE,
params: {
email: localStorage.getItem(AUTH_USER_EMAIL),
userAlreadyActivated: "true",
},
});
} else {
throw err;
}
}
}
});
const snackbar = inject<Snackbar>("snackbar");
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
onMounted(() => {
online.value = window.navigator.onLine;
window.addEventListener("offline", () => {
online.value = false;
showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
online.value = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
snackbar?.open({
queue: false,
indefinite: true,
variant: "dark",
actionText: t("Update app"),
cancelText: t("Ignore"),
message: t("A new version is available."),
onAction: async () => {
const registration = (
event as unknown as { detail: ServiceWorkerRegistration }
).detail;
try {
await refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
notifier?.error(t("An error has occured while refreshing the page."));
}
},
});
});
darkModePreference.addEventListener("change", changeTheme);
});
onUnmounted(() => {
clearInterval(interval.value);
interval.value = 0;
});
const { mutate: updateCurrentUser } = useMutation(UPDATE_CURRENT_USER_CLIENT);
const initializeCurrentUser = () => {
console.debug("Initializing current user");
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
const userData = {
id: userId,
email: userEmail,
isLoggedIn: true,
role,
};
updateCurrentUser(userData);
console.debug("Initialized current user", userData);
return true;
}
console.debug("Failed to initialize current user");
return false;
};
const refreshApp = async (
registration: ServiceWorkerRegistration
): Promise<any> => {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
};
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
});
};
const showOfflineNetworkWarning = (): void => {
notifier?.error(t("You are offline"));
};
// const extractPageTitleFromRoute = (routeWatched: RouteLocation): string => {
// if (routeWatched.meta?.announcer?.message) {
// return routeWatched.meta?.announcer?.message();
// }
// return document.title;
// };
// watch(route, (routeWatched) => {
// const pageTitle = extractPageTitleFromRoute(routeWatched);
// if (pageTitle) {
// // this.$announcer.polite(
// // t("Navigated to {pageTitle}", {
// // pageTitle,
// // }) as string
// // );
// }
// // Set the focus to the router view
// // https://marcus.io/blog/accessible-routing-vuejs
// setTimeout(() => {
// const focusTarget = (
// routerView.value?.$refs?.componentFocusTarget !== undefined
// ? routerView.value?.$refs?.componentFocusTarget
// : routerView.value?.$el
// ) as HTMLElement;
// if (focusTarget && focusTarget instanceof Element) {
// // Make focustarget programmatically focussable
// focusTarget.setAttribute("tabindex", "-1");
// // Focus element
// focusTarget.focus();
// // Remove tabindex from focustarget.
// // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
// focusTarget.removeAttribute("tabindex");
// }
// }, 0);
// });
const router = useRouter();
watch(config, async (configWatched: IConfig | undefined) => {
if (configWatched) {
const { statistics } = await import("@/services/statistics");
statistics(configWatched?.analytics, {
router,
version: configWatched.version,
});
}
});
const isDemoMode = computed(() => config.value?.demoMode);
const changeTheme = () => {
console.debug("changing theme");
if (
localStorage.getItem("theme") === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
onBeforeUnmount(() => {
darkModePreference.removeEventListener("change", changeTheme);
});
</script>
<style lang="scss">
#mobilizon {
min-height: 100vh;
display: flex;
flex-direction: column;
main {
flex-grow: 1;
}
}
.vue-skip-to {
z-index: 40;
}
</style>

27
src/api/_entrypoint.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Host of the instance
*
* Required
*
* Example: framameet.org
*/
export const MOBILIZON_INSTANCE_HOST = window.location.hostname;
/**
* URL on which the API is. "/api" will be added at the end
*
* Required
*
* Example: https://framameet.org
*/
export const GRAPHQL_API_ENDPOINT =
import.meta.env.VITE_SERVER_URL ?? window.location.origin;
/**
* URL with path on which the API is. Replaces GRAPHQL_API_ENDPOINT if used
*
* Optional
*
* Example: https://framameet.org/api
*/
export const GRAPHQL_API_FULL_PATH = `${GRAPHQL_API_ENDPOINT}/api`;

View File

@@ -0,0 +1,25 @@
import { Socket as PhoenixSocket } from "phoenix";
import { create } from "@framasoft/socket";
import { createAbsintheSocketLink } from "@framasoft/socket-apollo-link";
import { AUTH_ACCESS_TOKEN } from "@/constants";
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
const httpServer = GRAPHQL_API_ENDPOINT || "http://localhost:4000";
const webSocketPrefix = import.meta.env.PROD ? "wss" : "ws";
const wsEndpoint = `${webSocketPrefix}${httpServer.substring(
httpServer.indexOf(":")
)}/graphql_socket`;
const phoenixSocket = new PhoenixSocket(wsEndpoint, {
params: () => {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (token) {
return { token };
}
return {};
},
});
const absintheSocket = create(phoenixSocket);
export default createAbsintheSocketLink(absintheSocket);

View File

@@ -0,0 +1,20 @@
import fetch from "unfetch";
import { createLink } from "apollo-absinthe-upload-link";
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from "@/api/_entrypoint";
// Endpoints
const httpServer = GRAPHQL_API_ENDPOINT || "http://localhost:4000";
const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`;
const customFetch = async (uri: string, options: any) => {
const response = await fetch(uri, options);
if (response.status >= 400) {
return Promise.reject(response.status);
}
return response;
};
export const uploadLink = createLink({
uri: httpEndpoint,
fetch: customFetch,
});

23
src/apollo/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { AUTH_ACCESS_TOKEN } from "@/constants";
import { ApolloLink } from "@apollo/client/core";
export function generateTokenHeader() {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
return token ? `Bearer ${token}` : null;
}
const authMiddleware = new ApolloLink((operation, forward) => {
// add the authorization to the headers
operation.setContext({
headers: {
authorization: generateTokenHeader(),
},
});
if (forward) return forward(operation);
return null;
});
export { authMiddleware };

101
src/apollo/error-link.ts Normal file
View File

@@ -0,0 +1,101 @@
import { logout } from "@/utils/auth";
import { onError } from "@apollo/client/link/error";
import { fromPromise } from "@apollo/client/core";
import { refreshAccessToken } from "./utils";
import { GraphQLError } from "graphql";
import { generateTokenHeader } from "./auth";
let isRefreshing = false;
let pendingRequests: any[] = [];
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback());
pendingRequests = [];
};
const isAuthError = (graphQLError: GraphQLError | undefined) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return graphQLError && [403, 401].includes(graphQLError.status_code);
};
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
console.debug("We have an apollo error", [graphQLErrors, networkError]);
if (
graphQLErrors?.some((graphQLError) => isAuthError(graphQLError)) ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
networkError === 401
) {
console.debug("It's a authorization error (statusCode 401)");
let forwardOperation;
if (!isRefreshing) {
console.debug("Setting isRefreshing to true");
isRefreshing = true;
forwardOperation = fromPromise(
refreshAccessToken()
.then((res) => {
if (res !== true) {
// failed to refresh the token
throw "Failed to refresh the token";
}
resolvePendingRequests();
const context = operation.getContext();
const oldHeaders = context.headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: generateTokenHeader(),
},
});
return true;
})
.catch((e) => {
console.debug("Something failed, let's logout", e);
pendingRequests = [];
// don't perform a logout since we don't have any working access/refresh tokens
logout(false);
return;
})
.finally(() => {
isRefreshing = false;
})
).filter((value) => Boolean(value));
} else {
forwardOperation = fromPromise(
new Promise((resolve) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
pendingRequests.push(() => resolve());
})
);
}
return forwardOperation.flatMap(() => forward(operation));
}
if (graphQLErrors) {
graphQLErrors.map(
(graphQLError: GraphQLError & { status_code?: number }) => {
if (graphQLError?.status_code !== 401) {
console.debug(
`[GraphQL error]: Message: ${graphQLError.message}, Location: ${graphQLError.locations}, Path: ${graphQLError.path}`
);
}
}
);
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
console.debug(JSON.stringify(networkError));
}
}
);
export default errorLink;

40
src/apollo/link.ts Normal file
View File

@@ -0,0 +1,40 @@
import { split } from "@apollo/client/core";
import { RetryLink } from "@apollo/client/link/retry";
import { getMainDefinition } from "@apollo/client/utilities";
import absintheSocketLink from "./absinthe-socket-link";
import { authMiddleware } from "./auth";
import errorLink from "./error-link";
import { uploadLink } from "./absinthe-upload-socket-link";
let link;
// The Absinthe socket Apollo link relies on an old library
// (@jumpn/utils-composite) which itself relies on an old
// Babel version, which is incompatible with Histoire.
// We just don't use the absinthe apollo socket link
// in this case.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!import.meta.env.VITE_HISTOIRE_ENV) {
// const absintheSocketLink = await import("./absinthe-socket-link");
link = split(
// split based on operation type
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
absintheSocketLink,
uploadLink
);
}
const retryLink = new RetryLink();
export const fullLink = authMiddleware
.concat(retryLink)
.concat(errorLink)
.concat(link ?? uploadLink);

14
src/apollo/memory.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defaultDataIdFromObject, InMemoryCache } from "@apollo/client/core";
import { possibleTypes, typePolicies } from "./utils";
export const cache = new InMemoryCache({
addTypename: true,
typePolicies,
possibleTypes,
dataIdFromObject: (object: any) => {
if (object.__typename === "Address") {
return object.origin_id;
}
return defaultDataIdFromObject(object);
},
});

137
src/apollo/user.ts Normal file
View File

@@ -0,0 +1,137 @@
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CURRENT_USER_LOCATION_CLIENT } from "@/graphql/location";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUserRole } from "@/types/enums";
import { ApolloCache, NormalizedCacheObject } from "@apollo/client/cache";
import { Resolvers } from "@apollo/client/core/types";
export default function buildCurrentUserResolver(
cache: ApolloCache<NormalizedCacheObject>
): Resolvers {
cache?.writeQuery({
query: CURRENT_USER_CLIENT,
data: {
currentUser: {
__typename: "CurrentUser",
id: null,
email: null,
isLoggedIn: false,
role: ICurrentUserRole.USER,
},
},
});
cache?.writeQuery({
query: CURRENT_ACTOR_CLIENT,
data: {
currentActor: {
__typename: "CurrentActor",
id: null,
preferredUsername: null,
name: null,
avatar: null,
},
},
});
cache?.writeQuery({
query: CURRENT_USER_LOCATION_CLIENT,
data: {
currentUserLocation: {
lat: null,
lon: null,
accuracy: null,
isIPLocation: null,
name: null,
picture: null,
},
},
});
return {
Mutation: {
updateCurrentUser: (
_: any,
{
id,
email,
isLoggedIn,
role,
}: { id: string; email: string; isLoggedIn: boolean; role: string },
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentUser: {
id,
email,
isLoggedIn,
role,
__typename: "CurrentUser",
},
};
localCache.writeQuery({ data, query: CURRENT_USER_CLIENT });
},
updateCurrentActor: (
_: any,
{
id,
preferredUsername,
avatar,
name,
}: {
id: string;
preferredUsername: string;
avatar: string;
name: string;
},
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentActor: {
id,
preferredUsername,
avatar,
name,
__typename: "CurrentActor",
},
};
localCache.writeQuery({ data, query: CURRENT_ACTOR_CLIENT });
},
updateCurrentUserLocation: (
_: any,
{
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
}: {
lat: number;
lon: number;
accuracy: number;
isIPLocation: boolean;
name: string;
picture: any;
},
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentUserLocation: {
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
__typename: "CurrentUserLocation",
},
};
localCache.writeQuery({ data, query: CURRENT_USER_LOCATION_CLIENT });
},
},
};
}

212
src/apollo/utils.ts Normal file
View File

@@ -0,0 +1,212 @@
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants";
import { REFRESH_TOKEN } from "@/graphql/auth";
import { IFollower } from "@/types/actor/follower.model";
import { IParticipant } from "@/types/participant.model";
import { Paginate } from "@/types/paginate";
import { saveTokenData } from "@/utils/auth";
import { FieldPolicy, Reference, TypePolicies } from "@apollo/client/core";
import introspectionQueryResultData from "../../fragmentTypes.json";
import { IMember } from "@/types/actor/member.model";
import { IComment } from "@/types/comment.model";
import { IEvent } from "@/types/event.model";
import { IActivity } from "@/types/activity.model";
import uniqBy from "lodash/uniqBy";
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo";
import { IToken } from "@/types/login.model";
type possibleTypes = { name: string };
type schemaType = {
kind: string;
name: string;
possibleTypes: possibleTypes[];
};
// eslint-disable-next-line no-underscore-dangle
const types = introspectionQueryResultData.__schema.types as schemaType[];
export const possibleTypes = types.reduce(
(acc, type) => {
if (type.kind === "INTERFACE") {
acc[type.name] = type.possibleTypes.map(({ name }) => name);
}
return acc;
},
{} as Record<string, string[]>
);
const replaceMergePolicy = <TExisting = any, TIncoming = any>(
_existing: TExisting,
incoming: TIncoming
): TIncoming => incoming;
export const typePolicies: TypePolicies = {
Discussion: {
fields: {
comments: paginatedLimitPagination<IComment>(),
},
},
Conversation: {
fields: {
comments: paginatedLimitPagination<IComment>(),
},
},
Group: {
fields: {
organizedEvents: paginatedLimitPagination([
"afterDatetime",
"beforeDatetime",
]),
activity: paginatedLimitPagination<IActivity>(["type", "author"]),
},
},
Person: {
fields: {
organizedEvents: paginatedLimitPagination<IEvent>(),
participations: paginatedLimitPagination<IParticipant>(["eventId"]),
memberships: paginatedLimitPagination<IMember>(["group"]),
},
},
Event: {
fields: {
participants: paginatedLimitPagination<IParticipant>(["roles"]),
comments: pageLimitPagination<IComment>(),
relatedEvents: pageLimitPagination<IEvent>(),
options: { merge: replaceMergePolicy },
participantStats: { merge: replaceMergePolicy },
},
},
Instance: {
keyFields: ["domain"],
},
Config: {
merge: true,
},
Address: {
keyFields: ["id", "originId"],
},
RootQueryType: {
fields: {
relayFollowers: paginatedLimitPagination<IFollower>(),
relayFollowings: paginatedLimitPagination<IFollower>([
"orderBy",
"direction",
]),
events: paginatedLimitPagination(),
groups: paginatedLimitPagination([
"preferredUsername",
"name",
"domain",
"local",
"suspended",
]),
persons: paginatedLimitPagination([
"preferredUsername",
"name",
"domain",
"local",
"suspended",
]),
},
},
};
export async function refreshAccessToken(): Promise<boolean> {
// Remove invalid access token, so the next request is not authenticated
localStorage.removeItem(AUTH_ACCESS_TOKEN);
const refreshToken = localStorage.getItem(AUTH_REFRESH_TOKEN);
if (!refreshToken) {
console.debug("Refresh token not found");
return false;
}
console.debug("Refreshing access token.");
return new Promise((resolve, reject) => {
const { mutate, onDone, onError } = provideApolloClient(apolloClient)(() =>
useMutation<{ refreshToken: IToken }>(REFRESH_TOKEN)
);
mutate({
refreshToken,
});
onDone(({ data }) => {
if (data?.refreshToken) {
saveTokenData(data?.refreshToken);
resolve(true);
}
reject(false);
});
onError((err) => {
console.debug("Failed to refresh token", err);
reject(false);
});
});
}
type KeyArgs = FieldPolicy<any>["keyArgs"];
export function pageLimitPagination<T = Reference>(
keyArgs: KeyArgs = false
): FieldPolicy<T[]> {
return {
keyArgs,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
merge(existing, incoming, { args }) {
if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time
return doMerge(existing as Array<T>, incoming as Array<T>, args);
},
};
}
export function paginatedLimitPagination<T = Paginate<any>>(
keyArgs: KeyArgs = false
): FieldPolicy<Paginate<T>> {
return {
keyArgs,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
merge(existing, incoming, { args }) {
if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time
return {
total: incoming.total,
elements: doMerge(existing.elements, incoming.elements, args),
};
},
};
}
function doMerge<T = any>(
existing: Array<T>,
incoming: Array<T>,
args: Record<string, any> | null
): Array<T> {
const merged = existing && Array.isArray(existing) ? existing.slice(0) : [];
const previous = incoming && Array.isArray(incoming) ? incoming.slice(0) : [];
let res;
if (args) {
// Assume an page of 1 if args.page omitted.
const { page = 1, limit = 10 } = args;
for (let i = 0; i < previous.length; ++i) {
merged[(page - 1) * limit + i] = previous[i];
}
res = merged;
} else {
// It's unusual (probably a mistake) for a paginated field not
// to receive any arguments, so you might prefer to throw an
// exception here, instead of recovering by appending incoming
// onto the existing array.
res = [...merged, ...previous];
// eslint-disable-next-line no-underscore-dangle
res = uniqBy(res, (elem: any) => elem.__ref);
}
return res;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0" y="0" viewBox="0 0 64 64" xml:space="preserve" id="svg4965"><style type="text/css" id="style4919">.st1{opacity:.2}.st2{fill:#231f20}.st6{fill:#e0e0d1}</style><g id="Layer_1"><g id="g4923"><circle cx="32" cy="32" r="32" id="circle4921" fill="#77b3d4"/></g><g id="g4961"><g class="st1" id="g4927"><path class="st2" d="M12 25v25c0 2.2 1.8 4 4 4h32c2.2 0 4-1.8 4-4V25H12z" id="path4925"/></g><g id="g4931"><path d="M12 23v25c0 2.2 1.8 4 4 4h32c2.2 0 4-1.8 4-4V23H12z" id="path4929" fill="#fff"/></g><g class="st1" id="g4935"><path class="st2" d="M48 14H16c-2.2 0-4 1.8-4 4v7h40v-7c0-2.2-1.8-4-4-4z" id="path4933"/></g><g id="g4939"><path d="M48 12H16c-2.2 0-4 1.8-4 4v7h40v-7c0-2.2-1.8-4-4-4z" id="path4937" fill="#c75c5c"/></g><g class="st1" id="g4947"><path class="st2" d="M20 21c-1.1 0-2-.9-2-2v-7c0-1.1.9-2 2-2s2 .9 2 2v7c0 1.1-.9 2-2 2z" id="path4945"/></g><g class="st1" id="g4951"><path class="st2" d="M45 21c-1.1 0-2-.9-2-2v-7c0-1.1.9-2 2-2s2 .9 2 2v7c0 1.1-.9 2-2 2z" id="path4949"/></g><g id="g4955"><path class="st6" d="M20 19c-1.1 0-2-.9-2-2v-7c0-1.1.9-2 2-2s2 .9 2 2v7c0 1.1-.9 2-2 2z" id="path4953"/></g><g id="g4959"><path class="st6" d="M45 19c-1.1 0-2-.9-2-2v-7c0-1.1.9-2 2-2s2 .9 2 2v7c0 1.1-.9 2-2 2z" id="path4957"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/assets/footer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -0,0 +1,361 @@
body {
@apply bg-body-background-color dark:bg-zinc-800 dark:text-white;
}
.out {
@apply underline hover:decoration-2 hover:decoration-mbz-yellow-alt-600;
}
/* Button */
.btn {
@apply font-bold py-2 px-4 bg-mbz-bluegreen hover:bg-mbz-bluegreen-600 text-white rounded h-10 outline-none focus:ring ring-offset-1 ring-offset-slate-50 ring-blue-300;
}
.btn:hover {
@apply text-slate-200;
}
.btn-rounded {
@apply rounded-full;
}
.btn-size-large {
@apply text-2xl py-6;
}
.btn-size-small {
@apply text-sm py-1 px-2;
}
.btn-disabled {
@apply opacity-50 cursor-not-allowed;
}
.btn-danger {
@apply bg-mbz-danger hover:bg-mbz-danger/90;
}
.btn-success {
@apply bg-mbz-success;
}
.btn-warning {
@apply bg-mbz-warning text-black hover:bg-mbz-warning/90 hover:text-slate-800;
}
.btn-text {
@apply bg-transparent border-transparent text-black dark:text-white font-normal underline hover:bg-zinc-200 hover:text-black;
}
.btn-outlined-,
.btn-outlined-primary {
@apply bg-transparent text-black dark:text-white font-semibold py-2 px-4 border border-mbz-bluegreen dark:border-violet-3;
}
.btn-outlined-success {
@apply border-2 border-mbz-success bg-transparent text-mbz-success hover:bg-mbz-success;
}
.btn-outlined-warning {
@apply bg-transparent border dark:text-white hover:dark:text-slate-900 hover:bg-mbz-warning border-mbz-warning;
}
.btn-outlined-danger {
@apply border-2 bg-transparent border-mbz-danger text-mbz-danger hover:bg-mbz-danger;
}
.btn-outlined-text {
@apply bg-transparent hover:text-slate-900;
}
.btn-outlined-:hover,
.btn-outlined-primary:hover {
@apply font-semibold py-2 px-4 bg-mbz-bluegreen dark:bg-violet-3 text-white rounded;
}
/* Field */
.field {
margin-top: 0.5rem;
}
.field-label {
@apply block text-gray-700 dark:text-gray-100 text-base font-bold mb-2;
}
.o-field--horizontal.field {
@apply items-center;
}
.o-field__horizontal-label .field-label {
@apply mb-0;
}
.o-field__horizontal-body > .field {
@apply mt-0;
}
.field-danger {
@apply text-red-500;
}
.o-field.o-field--addons .control:last-child:not(:only-child) .button {
@apply inline-flex text-gray-800 bg-gray-200 h-9 mt-[1px] rounded text-center px-2 py-1.5;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.field-message-info {
@apply text-mbz-info;
}
.field-message-danger {
@apply text-mbz-danger;
}
/* Input */
.input {
@apply appearance-none box-border rounded border w-full py-2 px-3 text-black leading-tight dark:bg-zinc-600 dark:placeholder:text-zinc-400 dark:text-zinc-50;
}
.input-danger {
@apply border-red-500;
}
.input-icon-right {
right: 0.5rem;
}
.input[type="text"]:disabled,
.input[type="email"]:disabled {
@apply bg-zinc-200 dark:bg-zinc-400;
}
.icon-warning {
@apply text-amber-600;
}
.icon-danger {
@apply text-red-500;
}
.icon-success {
@apply text-mbz-success;
}
.icon-grey {
@apply text-gray-500;
}
.o-input__icon-left {
@apply dark:text-black h-10 w-10;
}
.o-input-iconspace-left {
@apply pl-10;
}
/* InputItems */
.inputitems-item {
@apply bg-primary mr-2;
}
.inputitems-item:first-child {
@apply ml-2;
}
/* Autocomplete */
.autocomplete-menu {
@apply max-h-[200px] drop-shadow-md text-black;
}
.autocomplete-item {
@apply py-1.5 px-4 text-start;
}
/* Dropdown */
.dropdown {
@apply inline-flex relative;
}
.dropdown-menu {
min-width: 12em;
@apply bg-white dark:bg-zinc-700 shadow-lg rounded text-start py-2;
}
.dropdown-item {
@apply relative inline-flex gap-1 no-underline p-2 cursor-pointer w-full;
}
.dropdown-item-active {
@apply bg-mbz-yellow-500 dark:bg-mbz-yellow-900 dark:text-zinc-100 text-black;
}
.dropdown-button {
@apply inline-flex gap-1;
}
/* Checkbox */
.checkbox {
margin-inline-end: 1rem;
}
.checkbox-check {
@apply appearance-none bg-primary border-primary;
}
.checkbox-checked {
@apply bg-primary text-primary;
}
.checkbox-label {
margin-left: 0.2rem;
}
/* Modal */
.modal-content {
@apply bg-white dark:bg-zinc-800 rounded px-2 py-4 w-full z-0;
}
/* Switch */
.switch {
@apply cursor-pointer inline-flex items-center relative mr-2;
}
.switch-label {
@apply pl-2;
}
.switch-check-checked {
@apply bg-primary;
}
/* Select */
.select {
@apply dark:bg-zinc-600 dark:placeholder:text-zinc-400 dark:text-zinc-50 rounded pl-2 pr-8 border-2 border-transparent h-10 shadow-none border rounded w-full;
}
/* Radio */
.form-radio {
@apply bg-none text-primary accent-primary;
}
.radio-label {
@apply pl-2;
}
.o-field--addons .o-radio:not(:only-child) input {
@apply rounded-full;
}
/* Editor */
button.menubar__button {
@apply dark:text-white;
}
/* Notification */
.notification {
@apply p-7 bg-mbz-yellow-alt-200 dark:bg-mbz-purple-600 text-black dark:text-white rounded;
}
.notification-primary {
@apply bg-primary;
}
.notification-info {
@apply bg-mbz-info text-black;
}
.notification-warning {
@apply bg-amber-600 text-black;
}
.notification-danger {
@apply bg-mbz-danger text-white;
}
/* Table */
.table tr {
@apply odd:bg-white dark:odd:bg-zinc-600 last:border-b-0 even:bg-gray-50 dark:even:bg-zinc-700 border-b rounded;
}
.table-td {
@apply py-4 px-2 whitespace-nowrap;
}
.table-th {
@apply p-2;
}
.table-root {
@apply mt-4;
}
/* Snackbar */
.notification-dark {
@apply text-white;
background: #363636;
}
/** Pagination */
.pagination {
@apply flex items-center text-center justify-between;
}
.pagination-link {
@apply inline-flex items-center relative justify-center cursor-pointer rounded h-10 m-1 p-2 bg-white dark:bg-zinc-300 text-lg text-black;
}
.pagination-list {
@apply flex items-center text-center list-none flex-wrap grow shrink justify-start;
}
.pagination-next,
.pagination-previous {
@apply px-3 dark:text-black;
}
.pagination-link-current {
@apply bg-primary dark:bg-primary cursor-not-allowed pointer-events-none border-primary text-white dark:text-zinc-900;
}
.pagination-ellipsis {
@apply text-center m-1 text-gray-300;
}
.pagination-link-disabled {
@apply bg-gray-200 dark:bg-gray-400;
}
/** Tabs */
.tabs-nav {
@apply flex items-center justify-start pb-0.5;
}
.tabs-nav-item-boxed {
@apply flex items-center justify-center px-2 py-2 rounded-t border-transparent;
}
.tabs-nav-item-active-boxed {
@apply bg-white border-gray-300 text-primary;
}
/** Tooltip */
.tooltip-content {
@apply bg-zinc-800 text-white dark:bg-zinc-300 dark:text-black rounded py-1 px-2;
}
.tooltip-arrow {
@apply text-zinc-800 dark:text-zinc-200;
}
.tooltip-content-success {
@apply bg-mbz-success text-white;
}
/** Tiptap editor */
.menubar__button {
@apply hover:bg-[rgba(0,0,0,.05)];
}
/** Datepicker */
.o-drop__menu--active {
@apply z-50;
}
.o-dpck__box {
@apply px-4 py-1;
}
.o-dpck__header {
@apply pb-2 mb-2;
border-bottom: 1px solid #dbdbdb;
}
.o-dpck__header__next,
.o-dpck__header__previous {
@apply justify-center text-center no-underline cursor-pointer items-center shadow-none inline-flex relative select-none leading-6 border rounded h-10 p-2 m-1 dark:text-white;
min-width: 2.25em;
}
.o-dpck__header__list {
@apply order-2 items-center flex justify-center text-center list-none flex-wrap my-0 p-0 -mx-0.5;
}
.o-dpck__header__list > * {
@apply mx-0.5;
}
.o-dpck__month__cell,
.o-dpck__table__cell {
@apply rounded py-2 px-3;
}
.o-dpck__table__cell--selectable {
@apply dark:text-zinc-50;
}
.o-dpck__month__head-cell,
.o-dpck__table__head-cell {
@apply font-semibold;
}
/** Timepicker */
.o-tpck__select {
@apply dark:bg-zinc-700 dark:placeholder:text-zinc-400 dark:text-zinc-50;
}

1
src/assets/profile.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 100 125">&quot;&gt;&quot;&gt;<path d="M77.74 83.19H22.26v-6.72a24 24 0 0124-24h7.48a24 24 0 0124 24zm-51.48-4h47.48v-2.72a20 20 0 00-20-20h-7.48a20 20 0 00-20 20z"/><g>&quot;&gt;<path d="M50 50.5a16.85 16.85 0 1116.85-16.84A16.87 16.87 0 0150 50.5zm0-29.7a12.85 12.85 0 1012.85 12.86A12.86 12.86 0 0050 20.81z"/></g></svg>

After

Width:  |  Height:  |  Size: 395 B

47
src/assets/tailwind.css Normal file
View File

@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white dark:bg-gray-900;
}
h1 {
@apply text-4xl lg:text-5xl leading-none font-extrabold tracking-tight mt-5 mb-4 sm:mt-7 sm:mb-5;
}
h2 {
@apply text-xl mt-2;
}
h3 {
@apply text-lg;
}
}
@layer components {
.mbz-card {
@apply block bg-mbz-yellow-alt-300 hover:bg-mbz-yellow-alt-200 text-violet-title dark:text-white dark:hover:text-white rounded-lg dark:border-violet-title shadow-md dark:bg-mbz-purple dark:hover:dark:bg-mbz-purple-400 dark:text-white dark:hover:text-white;
}
}
@media (prefers-color-scheme: dark) {
:root {
--oruga-variant-primary: #1e7d97;
--oruga-field-label-color: white;
--oruga-table-background-color: #111827;
--oruga-table-th-color: white;
--oruga-modal-content-background-color: #111827;
--oruga-dropdown-item-color: white;
--oruga-dropdown-menu-background: #111827;
--oruga-dropdown-item-hover-color: white;
--oruga-dropdown-item-hover-background-color: #111827;
}
}

1
src/assets/texting.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

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

@@ -0,0 +1,52 @@
<template>
<p>
<a dir="auto" :title="contact" v-if="configLink" :href="configLink.uri">{{
configLink.text
}}</a>
<span dir="auto" v-else-if="contact">{{ contact }}</span>
<span v-else>{{ t("contact uninformed") }}</span>
</p>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
contact?: 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;
}
};
</script>

View File

@@ -0,0 +1,86 @@
<template>
<o-inputitems
:modelValue="modelValueWithDisplayName"
@update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)"
:data="availableActors"
:allow-autocomplete="true"
:allow-new="false"
:open-on-focus="false"
field="displayName"
placeholder="Add a recipient"
@typing="getActors"
>
<template #default="props">
<ActorInline :actor="props.option" />
</template>
</o-inputitems>
</template>
<script setup lang="ts">
import { SEARCH_PERSON_AND_GROUPS } from "@/graphql/search";
import { IActor, IGroup, IPerson, displayName } from "@/types/actor";
import { Paginate } from "@/types/paginate";
import { useLazyQuery } from "@vue/apollo-composable";
import { computed, ref } from "vue";
import ActorInline from "./ActorInline.vue";
const props = defineProps<{
modelValue: IActor[];
}>();
defineEmits<{
"update:modelValue": [value: IActor[]];
}>();
const modelValue = computed(() => props.modelValue);
const modelValueWithDisplayName = computed(() =>
modelValue.value.map((actor) => ({
...actor,
displayName: displayName(actor),
}))
);
const {
load: loadSearchPersonsAndGroupsQuery,
refetch: refetchSearchPersonsAndGroupsQuery,
} = useLazyQuery<
{ searchPersons: Paginate<IPerson>; searchGroups: Paginate<IGroup> },
{ searchText: string }
>(SEARCH_PERSON_AND_GROUPS);
const availableActors = ref<IActor[]>([]);
const getActors = async (text: string) => {
availableActors.value = await fetchActors(text);
};
const fetchActors = async (text: string): Promise<IActor[]> => {
if (text === "") return [];
try {
const res =
(await loadSearchPersonsAndGroupsQuery(SEARCH_PERSON_AND_GROUPS, {
searchText: text,
})) ||
(
await refetchSearchPersonsAndGroupsQuery({
searchText: text,
})
)?.data;
if (!res) return [];
return [
...res.searchPersons.elements.map((person) => ({
...person,
displayName: displayName(person),
})),
...res.searchGroups.elements.map((group) => ({
...group,
displayName: displayName(group),
})),
];
} catch (e) {
console.error(e);
return [];
}
};
</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://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.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

@@ -0,0 +1,121 @@
<template>
<div
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 class="flex pl-2">
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-full object-cover h-full"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
loading="lazy"
/>
</figure>
<AccountCircle
v-else
: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 dark:text-gray-200 whitespace-pre-line line-clamp-2"
>
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 dark:text-gray-200 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
class="only-first-child"
:class="{
'line-clamp-3': limit,
'line-clamp-10': !limit,
}"
v-html="actor.summary"
/>
</div>
<div class="flex pr-2" v-if="actor.type === ActorType.PERSON">
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
query: {
newMessage: 'true',
personMentions: usernameWithDomain(actor),
},
}"
>
<Email />
</router-link>
</div>
</div>
<!-- <div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
dir="auto"
>
<div class="flex-shrink-0">
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<o-icon
v-else
size="large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div>
<div class="flex-1 min-w-0">
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
class="line-clamp-3"
:class="{ limit: limit }"
v-html="actor.summary"
/>
</div>
</div> -->
</template>
<script lang="ts" setup>
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Email from "vue-material-design-icons/Email.vue";
import RouteName from "@/router/name";
import { ActorType } from "@/types/enums";
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 :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://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.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

@@ -0,0 +1,58 @@
<template>
<div
class="inline-flex items-start gap-2 bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
>
<div class="flex-none">
<figure v-if="actor.avatar">
<img
class="rounded-xl"
:src="actor.avatar.url"
alt=""
width="36"
height="36"
loading="lazy"
/>
</figure>
<AccountCircle :size="36" v-else />
</div>
<div class="flex-auto">
<p class="text-lg line-clamp-3 md:line-clamp-2 max-w-xl">
{{ displayName(actor) }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-300 truncate">
@{{ usernameWithDomain(actor) }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
defineProps<{
actor: IActor;
}>();
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
div.actor-inline {
align-items: flex-start;
display: inline-flex;
text-align: inherit;
align-items: top;
div.actor-avatar {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
// @include margin-right(0.5rem);
}
div.actor-name {
flex-basis: auto;
flex-grow: 1;
flex-shrink: 1;
text-align: inherit;
}
}
</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://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.jpg",
},
};
function setupApp({ app }) {
app.use(FloatingVue);
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<VMenu
:distance="16"
:triggers="['hover']"
class="popover"
:class="{ inline, clickable: actor && actor.type === ActorType.GROUP }"
>
<slot></slot>
<template #popper>
<actor-card :full="true" :actor="actor" :popover="true" />
</template>
</VMenu>
</template>
<script lang="ts" setup>
import { ActorType } from "@/types/enums";
import { IActor } from "../../types/actor";
import ActorCard from "./ActorCard.vue";
withDefaults(
defineProps<{
actor: IActor;
inline?: boolean;
}>(),
{
inline: false,
}
);
</script>

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

@@ -0,0 +1,60 @@
<template>
<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 #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" setup>
import { IPerson } from "@/types/actor";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
defineProps<{
currentActor: IPerson;
instanceName: string;
}>();
const { t } = useI18n({ useScope: "global" });
const domain = computed(() => window.location.hostname);
</script>

View File

@@ -0,0 +1,124 @@
<template>
<div class="activity-item">
<o-icon :icon="'chat'" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityDiscussionSubject } from "@/types/enums";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
import { formatTimeString } from "@/filters/datetime";
import {
useActivitySubjectParams,
useIsActivityAuthorCurrentActor,
} from "@/composition/activity";
const props = defineProps<{
activity: IActivity;
}>();
const useIsActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const useActivitySubjectParamsFct = useActivitySubjectParams();
const isAuthorCurrentActor = computed(() =>
useIsActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() =>
useActivitySubjectParamsFct(props.activity)
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
if (isAuthorCurrentActor.value) {
return "You created the discussion {discussion}.";
}
return "{profile} created the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
if (isAuthorCurrentActor.value) {
return "You replied to the discussion {discussion}.";
}
return "{profile} replied to the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
if (isAuthorCurrentActor.value) {
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.value) {
return "You archived the discussion {discussion}.";
}
return "{profile} archived the discussion {discussion}.";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
if (isAuthorCurrentActor.value) {
return "You deleted the discussion {discussion}.";
}
return "{profile} deleted the discussion {discussion}.";
default:
return undefined;
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityDiscussionSubject.DISCUSSION_CREATED:
case ActivityDiscussionSubject.DISCUSSION_REPLIED:
return "success";
case ActivityDiscussionSubject.DISCUSSION_RENAMED:
case ActivityDiscussionSubject.DISCUSSION_ARCHIVED:
return "grey";
case ActivityDiscussionSubject.DISCUSSION_DELETED:
return "danger";
default:
return undefined;
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="activity-item">
<o-icon :icon="'calendar'" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<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,
ActivityEventParticipantSubject,
ActivityEventSubject,
} from "@/types/enums";
import { computed } from "vue";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
const props = defineProps<{
activity: IActivity;
}>();
const useIsActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const useActivitySubjectParamsFct = useActivitySubjectParams();
const isAuthorCurrentActor = computed(() =>
useIsActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() =>
useActivitySubjectParamsFct(props.activity)
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
if (isAuthorCurrentActor.value) {
return "You created the event {event}.";
}
return "The event {event} was created by {profile}.";
case ActivityEventSubject.EVENT_UPDATED:
if (isAuthorCurrentActor.value) {
return "You updated the event {event}.";
}
return "The event {event} was updated by {profile}.";
case ActivityEventSubject.EVENT_DELETED:
if (isAuthorCurrentActor.value) {
return "You deleted the event {event}.";
}
return "The event {event} was deleted by {profile}.";
case ActivityEventCommentSubject.COMMENT_POSTED:
if (subjectParams.value.comment_reply_to) {
if (isAuthorCurrentActor.value) {
return "You replied to a comment on the event {event}.";
}
return "{profile} replied to a comment on the event {event}.";
}
if (isAuthorCurrentActor.value) {
return "You posted a comment on the event {event}.";
}
return "{profile} posted a comment on the event {event}.";
case ActivityEventParticipantSubject.EVENT_NEW_PARTICIPATION:
if (isAuthorCurrentActor.value) {
return "You joined the event {event}.";
}
if (props.activity.author.preferredUsername === "anonymous") {
return "An anonymous profile joined the event {event}.";
}
return "{profile} joined the the event {event}.";
default:
return undefined;
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityEventSubject.EVENT_CREATED:
case ActivityEventCommentSubject.COMMENT_POSTED:
return "success";
case ActivityEventSubject.EVENT_UPDATED:
return "grey";
case ActivityEventSubject.EVENT_DELETED:
return "danger";
default:
return undefined;
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="activity-item">
<o-icon :icon="'cog'" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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-t :keypath="detail" v-for="detail in details" :key="detail" tag="p">
<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 as IActor
),
},
}"
>{{ 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>{{ formatTimeString(activity.insertedAt) }}</small>
</div>
</div>
</template>
<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 { computed } from "vue";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import { formatTimeString } from "@/filters/datetime";
const props = defineProps<{
activity: IActivity;
}>();
const useIsActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const useActivitySubjectParamsFct = useActivitySubjectParams();
const isAuthorCurrentActor = computed(() =>
useIsActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() =>
useActivitySubjectParamsFct(props.activity)
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
if (isAuthorCurrentActor.value) {
return "You created the group {group}.";
}
return "{profile} created the group {group}.";
case ActivityGroupSubject.GROUP_UPDATED:
if (isAuthorCurrentActor.value) {
return "You updated the group {group}.";
}
return "{profile} updated the group {group}.";
default:
return undefined;
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityGroupSubject.GROUP_CREATED:
return "success";
case ActivityGroupSubject.GROUP_UPDATED:
return "grey";
default:
return undefined;
}
});
const group = computed(() => props.activity.object as IGroup);
const details = computed((): string[] => {
const localDetails = [];
const changes = subjectParams.value.group_changes.split(",");
if (changes.includes("name") && subjectParams.value.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";
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="activity-item">
<o-icon :icon="icon" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts">
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";
const props = defineProps<{
activity: IActivity;
}>();
const isActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const activitySubjectParamsFct = useActivitySubjectParams();
const isActivityObjectCurrentActor = useIsActivityObjectCurrentActor();
const isAuthorCurrentActor = computed(() =>
isActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() => activitySubjectParamsFct(props.activity));
const member = computed(() => props.activity.object as IMember);
const isObjectMemberCurrentActor = computed(() =>
isActivityObjectCurrentActor(props.activity)
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityMemberSubject.MEMBER_REQUEST:
if (isAuthorCurrentActor.value) {
return "You requested to join the group.";
}
return "{member} requested to join the group.";
case ActivityMemberSubject.MEMBER_INVITED:
if (isAuthorCurrentActor.value) {
return "You invited {member}.";
}
return "{member} was invited by {profile}.";
case ActivityMemberSubject.MEMBER_ADDED:
if (isAuthorCurrentActor.value) {
return "You added the member {member}.";
}
return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (isAuthorCurrentActor.value) {
return "You approved {member}'s membership.";
}
if (isObjectMemberCurrentActor.value) {
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.value.member_role && subjectParams.value.old_role) {
return roleUpdate.value;
}
if (isAuthorCurrentActor.value) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}.";
case ActivityMemberSubject.MEMBER_REMOVED:
if (subjectParams.value.member_role === MemberRole.NOT_APPROVED) {
if (isAuthorCurrentActor.value) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (isAuthorCurrentActor.value) {
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.value) {
return "You accepted the invitation to join the group.";
}
return "{member} accepted the invitation to join the group.";
default:
return 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 "success";
case ActivityMemberSubject.MEMBER_REQUEST:
case ActivityMemberSubject.MEMBER_UPDATED:
return "grey";
case ActivityMemberSubject.MEMBER_REMOVED:
case ActivityMemberSubject.MEMBER_REJECTED_INVITATION:
case ActivityMemberSubject.MEMBER_QUIT:
return "danger";
default:
return undefined;
}
});
const roleUpdate = computed((): string | undefined => {
if (
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.value.member_role) &&
Object.keys(MEMBER_ROLE_VALUE).includes(subjectParams.value.old_role)
) {
if (
MEMBER_ROLE_VALUE[subjectParams.value.member_role] >
MEMBER_ROLE_VALUE[subjectParams.value.old_role]
) {
switch (subjectParams.value.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor.value) {
return "You promoted {member} to moderator.";
}
if (isObjectMemberCurrentActor.value) {
return "You were promoted to moderator by {profile}.";
}
return "{profile} promoted {member} to moderator.";
case MemberRole.ADMINISTRATOR:
if (isAuthorCurrentActor.value) {
return "You promoted {member} to administrator.";
}
if (isObjectMemberCurrentActor.value) {
return "You were promoted to administrator by {profile}.";
}
return "{profile} promoted {member} to administrator.";
default:
if (isAuthorCurrentActor.value) {
return "You promoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor.value) {
return "You were promoted to an unknown role by {profile}.";
}
return "{profile} promoted {member} to an unknown role.";
}
} else {
switch (subjectParams.value.member_role) {
case MemberRole.MODERATOR:
if (isAuthorCurrentActor.value) {
return "You demoted {member} to moderator.";
}
if (isObjectMemberCurrentActor.value) {
return "You were demoted to moderator by {profile}.";
}
return "{profile} demoted {member} to moderator.";
case MemberRole.MEMBER:
if (isAuthorCurrentActor.value) {
return "You demoted {member} to simple member.";
}
if (isObjectMemberCurrentActor.value) {
return "You were demoted to simple member by {profile}.";
}
return "{profile} demoted {member} to simple member.";
default:
if (isAuthorCurrentActor.value) {
return "You demoted the member {member} to an unknown role.";
}
if (isObjectMemberCurrentActor.value) {
return "You were demoted to an unknown role by {profile}.";
}
return "{profile} demoted {member} to an unknown role.";
}
}
} else {
if (isAuthorCurrentActor.value) {
return "You updated the member {member}.";
}
return "{profile} updated the member {member}";
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="activity-item">
<o-icon :icon="'bullhorn'" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityPostSubject } from "@/types/enums";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import { formatTimeString } from "@/filters/datetime";
import {
useIsActivityAuthorCurrentActor,
useActivitySubjectParams,
} from "@/composition/activity";
import { IActivity } from "@/types/activity.model";
import { computed } from "vue";
const props = defineProps<{
activity: IActivity;
}>();
const useIsActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const useActivitySubjectParamsFct = useActivitySubjectParams();
const isAuthorCurrentActor = computed(() =>
useIsActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() =>
useActivitySubjectParamsFct(props.activity)
);
const translation = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED:
if (isAuthorCurrentActor.value) {
return "You created the post {post}.";
}
return "The post {post} was created by {profile}.";
case ActivityPostSubject.POST_UPDATED:
if (isAuthorCurrentActor.value) {
return "You updated the post {post}.";
}
return "The post {post} was updated by {profile}.";
case ActivityPostSubject.POST_DELETED:
if (isAuthorCurrentActor.value) {
return "You deleted the post {post}.";
}
return "The post {post} was deleted by {profile}.";
default:
return undefined;
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityPostSubject.POST_CREATED:
return "success";
case ActivityPostSubject.POST_UPDATED:
return "grey";
case ActivityPostSubject.POST_DELETED:
return "danger";
default:
return undefined;
}
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="activity-item">
<o-icon :icon="'link'" :variant="iconColor" custom-size="24" />
<div class="mt-1 ml-2 prose dark:prose-invert prose-p:m-0">
<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>
<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="activity-date">{{
formatTimeString(activity.insertedAt)
}}</small>
</div>
</div>
</template>
<script lang="ts" setup>
import { usernameWithDomain } from "@/types/actor";
import { ActivityResourceSubject } from "@/types/enums";
import RouteName from "../../router/name";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
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";
const props = defineProps<{
activity: IActivity;
}>();
const useIsActivityAuthorCurrentActorFct = useIsActivityAuthorCurrentActor();
const useActivitySubjectParamsFct = useActivitySubjectParams();
const isAuthorCurrentActor = computed(() =>
useIsActivityAuthorCurrentActorFct(props.activity)
);
const subjectParams = computed(() =>
useActivitySubjectParamsFct(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.value) {
return "You created the folder {resource}.";
}
return "{profile} created the folder {resource}.";
}
if (isAuthorCurrentActor.value) {
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.value) {
return "You moved the folder {resource} to the root folder.";
}
return "{profile} moved the folder {resource} to the root folder.";
}
if (isAuthorCurrentActor.value) {
return "You moved the folder {resource} into {new_path}.";
}
return "{profile} moved the folder {resource} into {new_path}.";
}
if (parentDirectory.value === null) {
if (isAuthorCurrentActor.value) {
return "You moved the resource {resource} to the root folder.";
}
return "{profile} moved the resource {resource} to the root folder.";
}
if (isAuthorCurrentActor.value) {
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.value) {
return "You renamed the folder from {old_resource_title} to {resource}.";
}
return "{profile} renamed the folder from {old_resource_title} to {resource}.";
}
if (isAuthorCurrentActor.value) {
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.value) {
return "You deleted the folder {resource}.";
}
return "{profile} deleted the folder {resource}.";
}
if (isAuthorCurrentActor.value) {
return "You deleted the resource {resource}.";
}
return "{profile} deleted the resource {resource}.";
default:
return undefined;
}
});
const iconColor = computed((): string | undefined => {
switch (props.activity.subject) {
case ActivityResourceSubject.RESOURCE_CREATED:
return "success";
case ActivityResourceSubject.RESOURCE_MOVED:
case ActivityResourceSubject.RESOURCE_UPDATED:
return "grey";
case ActivityResourceSubject.RESOURCE_DELETED:
return "danger";
default:
return undefined;
}
});
const path = computed(() => {
const localPath = parentPath(resource.value?.path);
if (localPath === "") {
return {
name: RouteName.RESOURCE_FOLDER_ROOT,
params: {
preferredUsername: usernameWithDomain(props.activity.group),
},
};
}
return {
name: RouteName.RESOURCE_FOLDER,
params: {
path: localPath,
preferredUsername: usernameWithDomain(props.activity.group),
},
};
});
const parentPath = (parent: string | undefined): string | undefined => {
if (!parent) return undefined;
const localPath = parent.split("/");
localPath.pop();
return localPath.join("/").replace(/^\//, "");
};
const parentDirectory = computed((): string | undefined | null => {
if (subjectParams.value.resource_path) {
const parentPathResult = parentPath(subjectParams.value.resource_path);
const directory = parentPathResult?.split("/");
const res = directory?.pop();
res === "" ? null : res;
}
return null;
});
</script>
<style lang="scss" scoped>
@import "./activity.scss";
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="activity-item">
<span>
<o-skeleton circle width="32px" height="32px"></o-skeleton>
</span>
<div class="subject">
<div class="prose dark:prose-invert">
<p>
<o-skeleton active></o-skeleton>
<o-skeleton active class="datetime"></o-skeleton>
</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "./activity.scss";
div.activity-item {
flex: 1;
.subject {
flex: 1;
max-width: 600px;
.content p > div:last-child {
max-width: 50px;
}
}
}
</style>

View File

@@ -0,0 +1,12 @@
.activity-item {
display: flex;
span.o-icon {
width: 2em;
height: 2em;
box-sizing: border-box;
border-radius: 50%;
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

@@ -0,0 +1,123 @@
<template>
<address dir="auto">
<o-icon
v-if="showIcon"
:icon="poiInfos?.poiIcon.icon"
size="medium"
class="icon"
/>
<p>
<span
class="addressDescription"
:title="poiInfos.name"
v-if="poiInfos?.name"
>
{{ poiInfos.name }}
</span>
<br v-if="poiInfos?.name" />
<span>
{{ poiInfos?.alternativeName }}
</span>
<br />
<small
v-if="
userTimezoneDifferent &&
longShortTimezoneNamesDifferent &&
timezoneLongNameValid
"
>
🌐
{{
$t("{timezoneLongName} ({timezoneShortName})", {
timezoneLongName,
timezoneShortName,
})
}}
</small>
<small v-else-if="userTimezoneDifferent" class="">
🌐 {{ timezoneShortName }}
</small>
</p>
</address>
</template>
<script lang="ts" setup>
import { addressToPoiInfos, IAddress } from "@/types/address.model";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
address: IAddress;
showIcon?: boolean;
showTimezone?: boolean;
userTimezone?: string;
}>(),
{
showIcon: false,
showTimezone: false,
}
);
const poiInfos = computed(() => addressToPoiInfos(props.address));
const userTimezoneDifferent = computed((): boolean => {
return (
props.userTimezone != undefined &&
props.address.timezone != undefined &&
props.userTimezone !== props.address.timezone
);
});
const longShortTimezoneNamesDifferent = computed((): boolean => {
return (
timezoneLongName.value != undefined &&
timezoneShortName.value != undefined &&
timezoneLongName.value !== timezoneShortName.value
);
});
const timezoneLongName = computed((): string | undefined => {
return timezoneName("long");
});
const timezoneShortName = computed((): string | undefined => {
return timezoneName("short");
});
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 *;
address {
font-style: normal;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
overflow: hidden;
}
span.icon {
@include padding-right(1rem);
}
}
</style>

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

@@ -0,0 +1,35 @@
<template>
<div
class="truncate flex gap-1"
dir="auto"
:title="
isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}`
: physicalAddress.description
"
>
<MapMarker />
<span v-if="physicalAddress.locality">
{{ physicalAddress.locality }}
</span>
<span v-else>
{{ physicalAddress.description }}
</span>
</div>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import MapMarker from "vue-material-design-icons/MapMarker.vue";
import { computed } from "vue";
const props = defineProps<{
physicalAddress: IAddress;
}>();
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,83 @@
<template>
<router-link
:to="{
name: 'SEARCH',
query: {
categoryOneOf: [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()}.webp 2x, /img/categories/${category.key.toLowerCase()}.webp`"
media="(min-width: 1000px)"
/>
<source
:srcset="`/img/categories/${category.key.toLowerCase()}.webp 2x, /img/categories/${category.key.toLowerCase()}-small.webp`"
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()}.webp`"
:srcset="`/img/categories/${category.key.toLowerCase()}-small.webp `"
width="384"
height="384"
alt=""
:loading="imageLazy ? 'lazy' : undefined"
/>
</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;
imageLazy?: boolean;
}>(),
{
withDetails: false,
imageLazy: true,
}
);
const { t } = useI18n({ useScope: "global" });
</script>

View File

@@ -0,0 +1,195 @@
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,479 @@
<template>
<div>
<form
v-if="isAbleToComment"
@submit.prevent="createCommentForEvent(newCommentValue)"
class="mt-2"
>
<o-notification
v-if="isEventOrganiser && !areCommentsClosed"
:closable="false"
class="my-2"
>{{ t("Comments are closed for everybody else.") }}</o-notification
>
<article class="flex flex-wrap items-start gap-2">
<figure class="" v-if="newCommentValue.actor">
<identity-picker-wrapper
:inline="false"
v-model="newCommentValue.actor"
/>
</figure>
<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="newCommentValue.text"
:aria-label="t('Comment body')"
@submit="createCommentForEvent(newCommentValue)"
:placeholder="t('Write a new comment')"
/>
<p class="" v-if="emptyCommentError">
{{ t("Comment text can't be empty") }}
</p>
</div>
<div class="" v-if="isEventOrganiser">
<o-switch
aria-labelledby="notify-participants-toggle"
v-model="newCommentValue.isAnnouncement"
>{{ t("Notify participants") }}</o-switch
>
</div>
</div>
</div>
<div class="">
<o-button native-type="submit" variant="primary" icon-left="send">{{
t("Send")
}}</o-button>
</div>
</article>
</form>
<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 class="mt-2">
<transition-group
key="list"
name="comment-list"
v-if="
filteredOrderedComments &&
filteredOrderedComments.length &&
currentActor
"
class="comment-list"
tag="ul"
>
<event-comment
class="root-comment my-2"
:comment="comment"
:event="event"
:currentActor="currentActor"
v-for="comment in filteredOrderedComments"
:key="comment.id"
@create-comment="createCommentForEvent"
@delete-comment="
(commentToDelete) =>
deleteComment({
commentId: commentToDelete.id as string,
originCommentId: commentToDelete.originComment?.id,
})
"
/>
</transition-group>
<empty-content v-else icon="comment" key="no-comments" :inline="true">
<span>{{ t("No comments yet") }}</span>
</empty-content>
</transition-group>
</div>
</template>
<script lang="ts" setup>
import EventComment from "@/components/Comment/EventComment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { CommentModeration } from "@/types/enums";
import { CommentModel, IComment } from "../../types/comment.model";
import {
CREATE_COMMENT_FROM_EVENT,
DELETE_COMMENT,
COMMENTS_THREADS_WITH_REPLIES,
} from "../../graphql/comment";
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";
const props = defineProps<{
event: IEvent;
newComment?: IComment;
}>();
const event = computed(() => props.event);
const newCommentProps = computed(() => props.newComment);
const { currentActor } = useCurrentActorClient();
const { result: commentsResult, loading: commentsLoading } = useQuery<{
event: Pick<IEvent, "id" | "uuid" | "comments">;
}>(
COMMENTS_THREADS_WITH_REPLIES,
() => ({ eventUUID: event.value?.uuid }),
() => ({ enabled: event.value?.uuid !== undefined })
);
const comments = computed(() => commentsResult.value?.event.comments ?? []);
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const newCommentValue = ref<IComment>(new CommentModel(newCommentProps.value));
const emptyCommentError = ref(false);
const { t } = useI18n({ useScope: "global" });
watch(currentActor, () => {
newCommentValue.value.actor = currentActor.value as IPerson;
});
watch(newCommentValue, (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: event.value };
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS_WITH_REPLIES,
variables: {
eventUUID: event.value?.uuid,
},
});
if (!commentThreadsData) return;
const { event: cachedEvent } = commentThreadsData;
const oldComments = [...cachedEvent.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: {
...cachedEvent,
comments: oldComments,
},
},
variables: {
eventUUID: event.value?.uuid,
},
});
},
}));
createCommentForEventMutationDone(() => {
// and reset the new comment field
newCommentValue.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 (!event.value?.id) return;
createCommentForEventMutation({
eventId: event.value?.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: event.value?.uuid,
},
});
if (!commentsData) return;
const { event: cachedEvent } = commentsData;
let updatedComments: IComment[] = [...cachedEvent.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.debug("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: event.value?.uuid,
},
data: {
event: {
...cachedEvent,
comments: updatedComments,
},
},
});
},
}));
deleteCommentMutationError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].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)
);
}
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
);
});
const isEventOrganiser = computed((): boolean => {
const organizerId =
event.value?.organizerActor?.id || event.value?.attributedTo?.id;
return organizerId !== undefined && currentActor.value?.id === organizerId;
});
const areCommentsClosed = computed((): boolean => {
return (
currentActor.value?.id !== undefined &&
event.value?.options.commentModeration !== CommentModeration.CLOSED
);
});
const isAbleToComment = computed((): boolean => {
if (isConnected.value) {
return areCommentsClosed.value || isEventOrganiser.value;
}
return false;
});
const isConnected = computed((): boolean => {
return currentActor.value?.id != undefined;
});
</script>
<style lang="scss" scoped>
// @use "@/styles/_mixins" as *;
// 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-content {
// display: flex;
// align-items: center;
// align-content: center;
// width: min-content;
// .field {
// flex: 1;
// // @include padding-right(10px);
// margin-bottom: 0;
// &.notify-participants {
// margin-top: 0.5rem;
// }
// }
// }
// }
// }
// .no-comments {
// display: flex;
// flex-direction: column;
// span {
// text-align: center;
// margin-bottom: 10px;
// }
// img {
// max-width: 250px;
// align-self: center;
// }
// }
// ul.comment-list li {
// margin-bottom: 16px;
// }
.comment-list-enter-active,
.comment-list-leave-active,
.comment-list-move {
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
transition-property: opacity, transform;
}
.comment-list-enter {
opacity: 0;
transform: translateX(50px) scaleY(0.5);
}
.comment-list-enter-to {
opacity: 1;
transform: translateX(0) scaleY(1);
}
.comment-list-leave-active,
.comment-empty-list-active {
position: absolute;
}
.comment-list-leave-to,
.comment-empty-list-leave-to {
opacity: 0;
transform: scaleY(0);
transform-origin: center top;
}
// .comment-empty-list-enter-active {
// transition: opacity .5s;
// }
// .comment-empty-list-enter {
// opacity: 0;
// }
</style>

View File

@@ -0,0 +1,190 @@
<template>
<Story :setup-app="setupApp">
<Variant title="Basic">
<Comment
:comment="comment"
:event="event"
:currentActor="baseActor"
@create-comment="logEvent('Create comment', $event)"
@delete-comment="logEvent('Delete comment', $event)"
@report-comment="logEvent('Report comment', $event)"
/>
</Variant>
<Variant title="Announcement">
<Comment
:comment="{ ...comment, isAnnouncement: true }"
:event="event"
:currentActor="baseActor"
@create-comment="logEvent('Create comment', $event)"
@delete-comment="logEvent('Delete comment', $event)"
@report-comment="logEvent('Report comment', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IPerson } 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 "./EventComment.vue";
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
import { logEvent } from "histoire/client";
import { createMemoryHistory, createRouter } from "vue-router";
function setupApp({ app }) {
app.use(FloatingVue);
app.use(
createRouter({
history: createMemoryHistory(),
routes: [
{
path: "/event/:uuid",
name: "Event",
component: { render: () => null },
},
],
})
);
}
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 baseEvent: IEvent = {
uuid: "an-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 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().toISOString(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
{
text: "a reply to another reply!",
id: "92",
actor: baseActor,
updatedAt: new Date().toISOString(),
url: "http://somewhere.tld",
replies: [],
totalReplies: 0,
isAnnouncement: false,
local: false,
},
],
isAnnouncement: false,
updatedAt: new Date().toISOString(),
url: "http://somewhere.tld",
});
</script>

View File

@@ -0,0 +1,407 @@
<template>
<li
class="bg-white dark:bg-zinc-800 rounded p-2"
:class="{
reply: comment.inReplyToComment,
'bg-mbz-purple-50 dark:bg-mbz-purple-500': comment.isAnnouncement,
'!bg-mbz-bluegreen-50 dark:!bg-mbz-bluegreen-600': commentSelected,
'shadow-none': !rootComment,
}"
>
<article :id="commentId" dir="auto" class="mbz-comment">
<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>
<p v-else :href="commentURL">
<span>{{ t("[deleted]") }}</span>
</p>
<a :href="commentURL">
<small v-if="comment.updatedAt">{{
formatDistanceToNow(new Date(comment.updatedAt), {
locale: dateFnsLocale,
addSuffix: true,
})
}}</small>
</a>
</div>
<div
v-if="!comment.deletedAt"
v-html="comment.text"
dir="auto"
:lang="comment.language"
class="prose dark:prose-invert xl:prose-lg !max-w-full"
:class="{ 'text-black dark:text-white': comment.isAnnouncement }"
/>
<div v-else>{{ t("[This comment has been deleted]") }}</div>
<nav class="flex gap-1 mt-1" v-if="!comment.deletedAt">
<button
class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
v-if="
currentActor?.id &&
!readOnly &&
event.options.commentModeration !== CommentModeration.CLOSED &&
!comment.deletedAt
"
@click="createReplyToComment()"
>
<Reply />
<span>{{ t("Reply") }}</span>
</button>
<o-dropdown aria-role="list" v-show="!readOnly">
<template #trigger>
<button
class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
>
<DotsHorizontal />
<span class="sr-only">{{ t("More options") }}</span>
</button>
</template>
<o-dropdown-item
aria-role="listitem"
v-if="actorComment?.id === currentActor?.id"
>
<button class="flex items-center gap-1" @click="deleteComment">
<Delete :size="16" />
<span>{{ t("Delete") }}</span>
</button>
</o-dropdown-item>
<o-dropdown-item aria-role="listitem">
<button
@click="isReportModalActive = true"
class="flex items-center gap-1"
>
<Alert :size="16" />
<span>{{ t("Report") }}</span>
</button>
</o-dropdown-item>
</o-dropdown>
</nav>
<div class="" v-if="comment.totalReplies">
<button
v-if="!showReplies"
@click="showReplies = true"
class="flex cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
>
<ChevronDown />
<span>{{
t(
"View a reply",
{
totalReplies: comment.totalReplies,
},
comment.totalReplies
)
}}</span>
</button>
<button
v-else-if="comment.totalReplies && showReplies"
@click="showReplies = false"
class="flex cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
>
<ChevronUp />
<span>{{ t("Hide replies") }}</span>
</button>
</div>
</div>
</article>
<form
@submit.prevent="replyToComment"
v-if="currentActor?.id"
v-show="replyTo"
>
<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>
<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"
@submit="replyToComment"
:placeholder="t('Write a new reply')"
/>
<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>
<transition-group
name="comment-replies"
v-if="showReplies"
tag="ul"
class="flex flex-col gap-2"
>
<EventComment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:event="event"
:currentActor="currentActor"
:rootComment="false"
@create-comment="emit('create-comment', $event)"
@delete-comment="emit('delete-comment', $event)"
@report-comment="emit('report-comment', $event)"
class="ml-2"
/>
</transition-group>
<o-modal
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
:close-button-aria-label="t('Close')"
:autoFocus="false"
:trapFocus="false"
>
<ReportModal
:on-confirm="reportComment"
:title="t('Report this comment')"
:outside-domain="comment.actor?.domain"
/>
</o-modal>
</li>
</template>
<script lang="ts" setup>
import type EditorComponent from "@/components/TextEditor.vue";
import { formatDistanceToNow } from "date-fns";
import { CommentModeration } from "@/types/enums";
import { CommentModel, IComment } from "../../types/comment.model";
import { IPerson } from "../../types/actor";
import { IEvent } from "../../types/event.model";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import {
computed,
defineAsyncComponent,
inject,
onMounted,
ref,
nextTick,
} from "vue";
import { useRoute, useRouter } 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 DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import Reply from "vue-material-design-icons/Reply.vue";
import type { Locale } from "date-fns";
import ReportModal from "@/components/Report/ReportModal.vue";
import { useCreateReport } from "@/composition/apollo/report";
import { Snackbar } from "@/plugins/snackbar";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import RouteName from "@/router/name";
const router = useRouter();
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const props = withDefaults(
defineProps<{
comment: IComment;
event: IEvent;
currentActor: IPerson;
rootComment?: boolean;
readOnly?: boolean;
}>(),
{ rootComment: true, readOnly: false }
);
const event = computed(() => props.event);
const emit = defineEmits<{
(e: "create-comment", comment: IComment): void;
(e: "delete-comment", comment: IComment): void;
(e: "report-comment", comment: IComment): void;
}>();
const commentEditor = ref<typeof EditorComponent | null>(null);
const newComment = ref<IComment>(new CommentModel());
const replyTo = ref(false);
const showReplies = ref(false);
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const isReportModalActive = ref(false);
onMounted(() => {
if (route?.hash.includes(`#comment-${props.comment.uuid}`)) {
showReplies.value = true;
}
});
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();
}
};
const replyToComment = (): void => {
newComment.value.inReplyToComment = props.comment;
newComment.value.originComment = props.comment.originComment ?? props.comment;
newComment.value.actor = props.currentActor;
console.debug(newComment.value);
emit("create-comment", newComment.value);
newComment.value = new CommentModel();
replyTo.value = false;
showReplies.value = true;
};
const deleteComment = (): void => {
emit("delete-comment", props.comment);
showReplies.value = false;
};
const commentSelected = computed((): boolean => {
return `#${commentId.value}` === route?.hash;
});
const commentFromOrganizer = computed((): boolean => {
const organizerId =
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
return organizerId !== undefined && props.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}`;
});
const commentURL = computed((): string => {
if (!props.comment.local && props.comment.url) return props.comment.url;
return (
router.resolve({
name: RouteName.EVENT,
params: { uuid: event.value.uuid },
}).href + `#${commentId.value}`
);
});
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"),
// });
};
const {
mutate: createReportMutation,
onError: onCreateReportError,
onDone: oneCreateReportDone,
} = useCreateReport();
const reportComment = async (
content: string,
forward: boolean
): Promise<void> => {
if (!props.comment.actor) return;
createReportMutation({
reportedId: props.comment.actor?.id ?? "",
commentsIds: [props.comment.id ?? ""],
content,
forward,
});
};
const snackbar = inject<Snackbar>("snackbar");
const { oruga } = useProgrammatic();
onCreateReportError((e) => {
isReportModalActive.value = false;
if (e.message) {
snackbar?.open({
message: e.message,
variant: "danger",
position: "bottom",
});
}
});
oneCreateReportDone(() => {
isReportModalActive.value = false;
oruga.notification.open({
message: t("Comment from {'@'}{username} reported", {
username: props.comment.actor?.preferredUsername,
}),
variant: "success",
position: "bottom-right",
duration: 5000,
});
});
const actorComment = computed(() => props.comment.actor);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
</script>
<style>
article.mbz-comment .mention.h-card {
@apply inline-block border border-zinc-600 dark:border-zinc-300 rounded py-0.5 px-1;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<router-link
class="flex gap-4 w-full items-center px-2 py-4 border-b-stone-200 border-b bg-white dark:bg-transparent my-2 rounded"
dir="auto"
:to="{
name: RouteName.CONVERSATION,
params: { id: announcement.conversationParticipantId },
}"
>
<div class="overflow-hidden flex-1">
<div class="flex items-center justify-between">
<p>
{{
t("Sent to {count} participants", otherParticipants.length, {
count: otherParticipants.length,
})
}}
</p>
<div class="inline-flex items-center px-1.5">
<time
class="whitespace-nowrap"
:datetime="actualDate.toString()"
:title="formatDateTimeString(actualDate)"
>
{{ distanceToNow }}</time
>
</div>
</div>
<div
class="line-clamp-4 my-1"
dir="auto"
v-if="!announcement.lastComment?.deletedAt"
>
{{ htmlTextEllipsis }}
</div>
<div v-else class="">
{{ t("[This comment has been deleted]") }}
</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import { formatDistanceToNowStrict } from "date-fns";
import { IConversation } from "../../types/conversation";
import RouteName from "../../router/name";
import { computed, inject } from "vue";
import { formatDateTimeString } from "../../filters/datetime";
import type { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useCurrentActorClient } from "@/composition/apollo/actor";
const props = defineProps<{
announcement: IConversation;
}>();
const announcement = computed(() => props.announcement);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
const htmlTextEllipsis = computed((): string => {
const element = document.createElement("div");
if (announcement.value.lastComment && announcement.value.lastComment.text) {
element.innerHTML = announcement.value.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
});
const actualDate = computed((): string => {
if (
announcement.value.updatedAt === announcement.value.insertedAt &&
announcement.value.lastComment?.publishedAt
) {
return announcement.value.lastComment.publishedAt;
}
return announcement.value.updatedAt;
});
const { currentActor } = useCurrentActorClient();
const otherParticipants = computed(
() =>
announcement.value?.participants.filter(
(participant) => participant.id !== currentActor.value?.id
) ?? []
);
</script>

View File

@@ -0,0 +1,160 @@
<template>
<router-link
class="flex gap-4 w-full items-center px-2 py-4 border-b-stone-200 border-b bg-white dark:bg-transparent"
dir="auto"
:to="{
name: RouteName.CONVERSATION,
params: { id: conversation.conversationParticipantId },
}"
>
<div class="relative">
<figure
class="w-12 h-12"
v-if="
conversation.lastComment?.actor &&
conversation.lastComment.actor.avatar
"
>
<img
class="rounded-full"
:src="conversation.lastComment.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<account-circle :size="48" v-else />
<div class="flex absolute -bottom-2 left-6">
<template
v-for="extraParticipant in nonLastCommenterParticipants.slice(0, 2)"
:key="extraParticipant.id"
>
<figure class="w-6 h-6 -mr-3">
<img
v-if="extraParticipant && extraParticipant.avatar"
class="rounded-full h-6"
:src="extraParticipant.avatar.url"
alt=""
width="24"
height="24"
/>
<account-circle :size="24" v-else />
</figure>
</template>
</div>
</div>
<div class="overflow-hidden flex-1">
<div class="flex items-center justify-between">
<i18n-t
keypath="With {participants}"
tag="p"
class="truncate flex-1"
v-if="formattedListOfParticipants"
>
<template #participants>
<span v-html="formattedListOfParticipants" />
</template>
</i18n-t>
<p v-else>{{ t("With unknown participants") }}</p>
<div class="inline-flex items-center px-1.5">
<span
v-if="conversation.unread"
class="bg-primary rounded-full inline-block h-2.5 w-2.5 mx-2"
>
</span>
<time
class="whitespace-nowrap"
:datetime="actualDate.toString()"
:title="formatDateTimeString(actualDate)"
>
{{ distanceToNow }}</time
>
</div>
</div>
<div
class="line-clamp-2 my-1"
dir="auto"
v-if="!conversation.lastComment?.deletedAt"
>
{{ htmlTextEllipsis }}
</div>
<div v-else class="">
{{ t("[This comment has been deleted]") }}
</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import { formatDistanceToNowStrict } from "date-fns";
import { IConversation } from "../../types/conversation";
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";
import { formatList } from "@/utils/i18n";
import { displayName } from "@/types/actor";
import { useCurrentActorClient } from "@/composition/apollo/actor";
const props = defineProps<{
conversation: IConversation;
}>();
const conversation = computed(() => props.conversation);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
const htmlTextEllipsis = computed((): string => {
const element = document.createElement("div");
if (conversation.value.lastComment && conversation.value.lastComment.text) {
element.innerHTML = conversation.value.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
});
const actualDate = computed((): string => {
if (
conversation.value.updatedAt === conversation.value.insertedAt &&
conversation.value.lastComment?.publishedAt
) {
return conversation.value.lastComment.publishedAt;
}
return conversation.value.updatedAt;
});
const formattedListOfParticipants = computed(() => {
return formatList(
otherParticipants.value.map(
(participant) => `<b>${displayName(participant)}</b>`
)
);
});
const { currentActor } = useCurrentActorClient();
const otherParticipants = computed(
() =>
conversation.value?.participants.filter(
(participant) => participant.id !== currentActor.value?.id
) ?? []
);
const nonLastCommenterParticipants = computed(() =>
otherParticipants.value.filter(
(participant) =>
participant.id !== conversation.value.lastComment?.actor?.id
)
);
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="container mx-auto section">
<breadcrumbs-nav :links="[]" />
<section>
<h1>{{ t("Announcements") }}</h1>
<div v-if="conversations.elements.length > 0">
<announcement-list-item
:announcement="conversation"
v-for="conversation in conversations.elements"
:key="conversation.id"
/>
<o-pagination
v-show="conversations.total > CONVERSATIONS_PER_PAGE"
class="conversation-pagination"
:total="conversations.total"
v-model:current="page"
:per-page="CONVERSATIONS_PER_PAGE"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
<empty-content v-else icon="bullhorn" inline>
{{ t("There's no announcements yet") }}
</empty-content>
</section>
</div>
</template>
<script lang="ts" setup>
import AnnouncementListItem from "../../components/Conversations/AnnouncementListItem.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { useI18n } from "vue-i18n";
import { useRouteQuery, integerTransformer } from "vue-use-route-query";
import { computed } from "vue";
import { IEvent } from "../../types/event.model";
import { EVENT_CONVERSATIONS } from "../../graphql/event";
import { useQuery } from "@vue/apollo-composable";
const page = useRouteQuery("page", 1, integerTransformer);
const CONVERSATIONS_PER_PAGE = 10;
const props = defineProps<{ event: IEvent }>();
const event = computed(() => props.event);
const { t } = useI18n({ useScope: "global" });
const { result: conversationsResult } = useQuery<{
event: Pick<IEvent, "conversations">;
}>(EVENT_CONVERSATIONS, () => ({
uuid: event.value.uuid,
page: page.value,
}));
const conversations = computed(
() =>
conversationsResult.value?.event.conversations || { elements: [], total: 0 }
);
</script>

View File

@@ -0,0 +1,177 @@
<template>
<form @submit="sendForm" class="flex flex-col">
<ActorAutoComplete v-model="actorMentions" />
<Editor
v-model="text"
mode="basic"
:aria-label="t('Message body')"
v-if="currentActor"
:currentActor="currentActor"
:placeholder="t('Write a new message')"
/>
<o-notification
class="my-2"
variant="danger"
:closable="false"
v-for="error in errors"
:key="error"
>
{{ error }}
</o-notification>
<footer class="flex gap-2 py-3 mx-2 justify-end">
<o-button :disabled="!canSend" nativeType="submit">{{
t("Send")
}}</o-button>
</footer>
</form>
</template>
<script lang="ts" setup>
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import { computed, defineAsyncComponent, provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import ActorAutoComplete from "../../components/Account/ActorAutoComplete.vue";
import {
DefaultApolloClient,
provideApolloClient,
useLazyQuery,
useMutation,
} from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo";
import { PROFILE_CONVERSATIONS } from "@/graphql/event";
import { POST_PRIVATE_MESSAGE_MUTATION } from "@/graphql/conversations";
import { IConversation } from "@/types/conversation";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useRouter } from "vue-router";
import RouteName from "@/router/name";
import { FETCH_PERSON } from "@/graphql/actor";
import { FETCH_GROUP_PUBLIC } from "@/graphql/group";
const props = withDefaults(
defineProps<{
personMentions?: string[];
groupMentions?: string[];
}>(),
{ personMentions: () => [], groupMentions: () => [] }
);
provide(DefaultApolloClient, apolloClient);
const router = useRouter();
const emit = defineEmits(["close"]);
const errors = ref<string[]>([]);
const textPersonMentions = computed(() => props.personMentions);
const textGroupMentions = computed(() => props.groupMentions);
const actorMentions = ref<IActor[]>([]);
const { load: fetchPerson } = provideApolloClient(apolloClient)(() =>
useLazyQuery<{ fetchPerson: IPerson }, { username: string }>(FETCH_PERSON)
);
const { load: fetchGroup } = provideApolloClient(apolloClient)(() =>
useLazyQuery<{ group: IGroup }, { name: string }>(FETCH_GROUP_PUBLIC)
);
textPersonMentions.value.forEach(async (textPersonMention) => {
const result = await fetchPerson(FETCH_PERSON, {
username: textPersonMention,
});
if (!result) return;
actorMentions.value.push(result.fetchPerson);
});
textGroupMentions.value.forEach(async (textGroupMention) => {
const result = await fetchGroup(FETCH_GROUP_PUBLIC, {
name: textGroupMention,
});
if (!result) return;
actorMentions.value.push(result.group);
});
const { t } = useI18n({ useScope: "global" });
const text = ref("");
const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue")
);
const { currentActor } = provideApolloClient(apolloClient)(() => {
return useCurrentActorClient();
});
const canSend = computed(() => {
return actorMentions.value.length > 0 || /@.+/.test(text.value);
});
const { mutate: postPrivateMessageMutate, onError: onPrivateMessageError } =
provideApolloClient(apolloClient)(() =>
useMutation<
{
postPrivateMessage: IConversation;
},
{
text: string;
actorId: string;
language?: string;
mentions?: string[];
attributedToId?: string;
}
>(POST_PRIVATE_MESSAGE_MUTATION, {
update(cache, result) {
if (!result.data?.postPrivateMessage) return;
const cachedData = cache.readQuery<{
loggedPerson: Pick<IPerson, "conversations" | "id">;
}>({
query: PROFILE_CONVERSATIONS,
variables: {
page: 1,
},
});
if (!cachedData) return;
cache.writeQuery({
query: PROFILE_CONVERSATIONS,
variables: {
page: 1,
},
data: {
loggedPerson: {
...cachedData?.loggedPerson,
conversations: {
...cachedData.loggedPerson.conversations,
total: (cachedData.loggedPerson.conversations?.total ?? 0) + 1,
elements: [
...(cachedData.loggedPerson.conversations?.elements ?? []),
result.data.postPrivateMessage,
],
},
},
},
});
},
})
);
onPrivateMessageError((err) => {
err.graphQLErrors.forEach((error) => {
errors.value.push(error.message);
});
});
const sendForm = async (e: Event) => {
e.preventDefault();
console.debug("Sending new private message");
if (!currentActor.value?.id) return;
const result = await postPrivateMessageMutate({
actorId: currentActor.value.id,
text: text.value,
mentions: actorMentions.value.map((actor) => usernameWithDomain(actor)),
});
if (!result?.data?.postPrivateMessage.conversationParticipantId) return;
router.push({
name: RouteName.CONVERSATION,
params: { id: result?.data?.postPrivateMessage.conversationParticipantId },
});
emit("close");
};
</script>

View File

@@ -0,0 +1,25 @@
<template>
<router-link
v-if="to?.name"
:to="to"
class="bg-white dark:bg-black mb-4 shadow-md rounded p-4"
>
<NumberDashboardTile :number="number" :subtitle="subtitle" :style="false">
<template #subtitle>
<slot name="subtitle" />
</template>
</NumberDashboardTile>
</router-link>
</template>
<script lang="ts" setup>
import NumberDashboardTile from "./NumberDashboardTile.vue";
defineProps<{
number?: number;
subtitle?: string;
to?: {
name: string;
params?: Record<string, any>;
query?: Record<string, any>;
};
}>();
</script>

View File

@@ -0,0 +1,20 @@
<template>
<article
:class="{ 'bg-white dark:bg-black mb-4 shadow-md rounded p-4': style }"
>
<p class="text-violet-3 text-4xl font-bold">{{ number }}</p>
<slot name="subtitle">
{{ subtitle }}
</slot>
</article>
</template>
<script lang="ts" setup>
withDefaults(
defineProps<{
number?: number;
subtitle?: string;
style?: boolean;
}>(),
{ style: true }
);
</script>

View File

@@ -0,0 +1,50 @@
<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,
actor: null,
deletedAt: new Date().toString(),
});
</script>

View File

@@ -0,0 +1,347 @@
<template>
<article
class="flex gap-2 bg-white dark:bg-transparent border rounded-md p-2 mt-2"
>
<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>
<AccountCircle :size="48" v-else />
</div>
<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 v-if="comment.actor.name">{{ comment.actor.name }}</strong>
<small>@{{ usernameWithDomain(comment.actor) }}</small>
</div>
<span v-else class="name comment-link has-text-grey">
{{ t("[deleted]") }}
</span>
<span
class="icons"
v-if="
comment.actor &&
!comment.deletedAt &&
(comment.actor.id === currentActor.id || canReport)
"
>
<o-dropdown aria-role="list" position="bottom-left">
<template #trigger>
<DotsHorizontal class="cursor-pointer" />
</template>
<o-dropdown-item
v-if="comment.actor?.id === currentActor?.id"
@click="toggleEditMode"
aria-role="menuitem"
>
<o-icon icon="pencil"></o-icon>
{{ t("Edit") }}
</o-dropdown-item>
<o-dropdown-item
v-if="comment.actor?.id === currentActor?.id"
@click="emit('deleteComment', comment)"
aria-role="menuitem"
>
<o-icon icon="delete"></o-icon>
{{ t("Delete") }}
</o-dropdown-item>
<o-dropdown-item
v-if="canReport"
aria-role="listitem"
@click="isReportModalActive = true"
>
<o-icon icon="flag" />
{{ t("Report") }}
</o-dropdown-item>
</o-dropdown>
</span>
<div class="self-center">
<span
:title="formatDateTimeString(comment.updatedAt?.toString())"
v-if="comment.updatedAt"
>
{{
formatDistanceToNow(new Date(comment.updatedAt?.toString()), {
locale: dateFnsLocale,
}) || t("Right now")
}}</span
>
</div>
</div>
<div
v-if="!editMode && !comment.deletedAt"
class="text-wrapper"
dir="auto"
>
<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="formatDateTimeString(comment.updatedAt.toString())"
>
{{
t("Edited {ago}", {
ago: formatDistanceToNow(new Date(comment.updatedAt), {
locale: dateFnsLocale,
}),
})
}}
</p>
</div>
<div class="comment-deleted" v-else-if="!editMode">
{{ 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')"
:current-actor="currentActor"
:placeholder="t('Write a new message')"
/>
<div class="flex gap-2 mt-2">
<o-button
native-type="submit"
:disabled="['<p></p>', '', comment.text].includes(updatedComment)"
variant="primary"
>{{ t("Update") }}</o-button
>
<o-button native-type="button" @click="toggleEditMode">{{
t("Cancel")
}}</o-button>
</div>
</form>
</div>
</article>
<o-modal
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
:close-button-aria-label="t('Close')"
:autoFocus="false"
:trapFocus="false"
>
<ReportModal
:on-confirm="reportComment"
:title="t('Report this comment')"
:outside-domain="comment.actor?.domain"
/>
</o-modal>
</template>
<script lang="ts" setup>
import { formatDistanceToNow } from "date-fns";
import { IComment } from "../../types/comment.model";
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 DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import type { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useCreateReport } from "@/composition/apollo/report";
import { Snackbar } from "@/plugins/snackbar";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import ReportModal from "@/components/Report/ReportModal.vue";
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const props = withDefaults(
defineProps<{
modelValue: IComment;
currentActor: IPerson;
canReport: boolean;
}>(),
{ canReport: false }
);
const emit = defineEmits(["update:modelValue", "deleteComment"]);
const { t } = useI18n({ useScope: "global" });
const comment = computed(() => props.modelValue);
const editMode = ref(false);
const updatedComment = ref("");
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const isReportModalActive = ref(false);
const toggleEditMode = (): void => {
updatedComment.value = comment.value.text;
editMode.value = !editMode.value;
};
const updateComment = (): void => {
emit("update:modelValue", {
...comment.value,
text: updatedComment.value,
});
toggleEditMode();
};
const {
mutate: createReportMutation,
onError: onCreateReportError,
onDone: oneCreateReportDone,
} = useCreateReport();
const reportComment = async (
content: string,
forward: boolean
): Promise<void> => {
if (!props.modelValue.actor) return;
createReportMutation({
reportedId: props.modelValue.actor?.id ?? "",
commentsIds: [props.modelValue.id ?? ""],
content,
forward,
});
};
const snackbar = inject<Snackbar>("snackbar");
const { oruga } = useProgrammatic();
onCreateReportError((e) => {
isReportModalActive.value = false;
if (e.message) {
snackbar?.open({
message: e.message,
variant: "danger",
position: "bottom",
});
}
});
oneCreateReportDone(() => {
isReportModalActive.value = false;
oruga.notification.open({
message: t("Comment from {'@'}{username} reported", {
username: props.modelValue.actor?.preferredUsername,
}),
variant: "success",
position: "bottom-right",
duration: 5000,
});
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
article.comment {
display: flex;
border-top: 1px solid #e9e9e9;
div.body {
flex: 2;
margin-bottom: 2rem;
padding-top: 1rem;
.meta {
display: flex;
align-items: center;
padding: 0 1rem 0.3em;
.name {
// @include margin-right(auto);
flex: 1 1 auto;
overflow: hidden;
strong {
display: block;
line-height: 1rem;
}
}
.icons {
display: inline;
cursor: pointer;
}
}
.text-wrapper,
.comment-deleted {
padding: 0 1rem;
& > p {
font-size: 0.85rem;
font-style: italic;
}
div.description-content {
padding-bottom: 0.3rem;
:deep(h1) {
font-size: 2rem;
}
:deep(h2) {
font-size: 1.5rem;
}
:deep(h3) {
font-size: 1.25rem;
}
:deep(ul) {
list-style-type: disc;
}
:deep(li) {
margin: 10px auto 10px 2rem;
}
:deep(blockquote) {
border-left: 0.2em solid #333;
display: block;
// @include padding-left(1em);
}
:deep(p) {
margin: 10px auto;
a {
display: inline-block;
padding: 0.3rem;
color: #111;
&:empty {
display: none;
}
}
}
}
}
}
div.avatar {
padding-top: 1rem;
flex: 0;
}
.edition {
.button {
margin-top: 0.75rem;
}
}
}
</style>

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

@@ -0,0 +1,93 @@
<template>
<router-link
class="flex gap-1 w-full items-center p-2 border-b-stone-200 border-b bg-white dark:bg-transparent"
dir="auto"
:to="{
name: RouteName.DISCUSSION,
params: { slug: discussion.slug },
}"
>
<div class="">
<figure
class=""
v-if="
discussion.lastComment?.actor && discussion.lastComment.actor.avatar
"
>
<img
class="rounded-xl"
:src="discussion.lastComment.actor.avatar.url"
alt=""
width="32"
height="32"
/>
</figure>
<account-circle :size="32" v-else />
</div>
<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="line-clamp-2"
dir="auto"
v-if="!discussion.lastComment?.deletedAt"
>
{{ htmlTextEllipsis }}
</div>
<div v-else class="">
{{ t("[This comment has been deleted]") }}
</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import { formatDistanceToNowStrict } from "date-fns";
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";
const props = defineProps<{
discussion: IDiscussion;
}>();
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
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;
});
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>

View File

@@ -0,0 +1,30 @@
import { Extension } from "@tiptap/core";
/**
* Allows to set dir="auto" on top nodes
* Taken from https://github.com/ueberdosis/tiptap/issues/1621#issuecomment-918990408
*/
export const AutoDir = Extension.create({
name: "AutoDir",
addGlobalAttributes() {
return [
{
types: [
"heading",
"paragraph",
"bulletList",
"orderedList",
"blockquote",
],
attributes: {
autoDir: {
renderHTML: () => ({
dir: "auto",
}),
parseHTML: (element) => element.dir || "auto",
},
},
},
];
},
});

View File

@@ -0,0 +1,103 @@
import { UPLOAD_MEDIA } from "@/graphql/upload";
import { apolloClient } from "@/vue-apollo";
import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Image from "@tiptap/extension-image";
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
/* eslint-disable class-methods-use-this */
const CustomImage = Image.extend({
name: "image",
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
"data-media-id": {
default: null,
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleDOMEvents: {
drop(view: EditorView, event: Event) {
const realEvent = event as DragEvent;
if (
!(
realEvent.dataTransfer &&
realEvent.dataTransfer.files &&
realEvent.dataTransfer.files.length
)
) {
return false;
}
const images = Array.from(realEvent.dataTransfer.files).filter(
(file: any) =>
/image/i.test(file.type) && !/svg/i.test(file.type)
);
if (images.length === 0) {
return false;
}
realEvent.preventDefault();
const { schema } = view.state;
const coordinates = view.posAtCoords({
left: realEvent.clientX,
top: realEvent.clientY,
});
if (!coordinates) return false;
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,
});
const transaction = view.state.tr.insert(
coordinates.pos,
node
);
view.dispatch(transaction);
});
onError((error) => {
console.error(error);
return false;
});
});
return true;
},
},
},
}),
];
},
});
export default CustomImage;

View File

@@ -0,0 +1,112 @@
import { SEARCH_PERSONS } from "@/graphql/search";
import { VueRenderer } from "@tiptap/vue-3";
import tippy from "tippy.js";
import MentionList from "./MentionList.vue";
import { apolloClient } from "@/vue-apollo";
import { IPerson } from "@/types/actor";
import pDebounce from "p-debounce";
import { MentionOptions } from "@tiptap/extension-mention";
import { Editor } from "@tiptap/core";
import { provideApolloClient, useLazyQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
const fetchItems = async (query: string): Promise<IPerson[]> => {
try {
if (query === "") return [];
const res = await provideApolloClient(apolloClient)(async () => {
const { load: loadSearchPersonsQuery } = useLazyQuery<
{ searchPersons: Paginate<IPerson> },
{ searchText: string }
>(SEARCH_PERSONS);
return await loadSearchPersonsQuery(SEARCH_PERSONS, {
searchText: query,
});
});
if (!res) return [];
return res.searchPersons.elements;
} catch (e) {
console.error(e);
return [];
}
};
const debouncedFetchItems = pDebounce(fetchItems, 200);
const mentionOptions: MentionOptions = {
HTMLAttributes: {
class: "mention",
dir: "ltr",
},
renderLabel({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
suggestion: {
items: async ({
query,
}: {
query: string;
editor: Editor;
}): Promise<IPerson[]> => {
if (query === "") {
return [];
}
return await debouncedFetchItems(query);
},
render: () => {
let component: VueRenderer;
let popup: any;
return {
onStart: (props: Record<string, any>) => {
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
component.updateProps(props);
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
popup[0].hide();
return true;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return component.ref?.onKeyDown(props);
},
onExit() {
if (popup && popup[0]) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
},
};
},
},
};
export default mentionOptions;

View File

@@ -0,0 +1,80 @@
<template>
<div class="relative border overflow-hidden dark:border-transparent">
<button
class="block w-full text-start bg-white dark:bg-violet-1 border py-1 px-2 rounded dark:border-transparent"
:class="{ 'border-black dark:!border-white': index === selectedIndex }"
v-for="(item, index) in items"
:key="index"
@click="selectItem(index)"
>
<actor-inline :actor="item" />
</button>
</div>
</template>
<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";
const props = defineProps<{
items: IPerson[];
command: ({ id }: { id: string }) => any;
}>();
// @Prop({ type: Function, required: true }) command!: any;
const selectedIndex = ref(0);
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
const onKeyDown = ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
};
const upHandler = (): void => {
selectedIndex.value =
(selectedIndex.value + props.items.length - 1) % props.items.length;
};
const downHandler = (): void => {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
};
const enterHandler = (): void => {
selectItem(selectedIndex.value);
};
const selectItem = (index: number): void => {
const item = props.items[index];
if (item) {
props.command({ id: usernameWithDomain(item) });
}
};
defineExpose({
onKeyDown,
});
</script>

View File

@@ -0,0 +1,22 @@
import { Extension } from "@tiptap/vue-3";
export interface RichEditorKeyboardSubmitOptions {
submit: () => void;
}
export default Extension.create<RichEditorKeyboardSubmitOptions>({
name: "RichEditorKeyboardSubmit",
addOptions() {
return {
submit: () => ({}),
};
},
addKeyboardShortcuts() {
return {
"Ctrl-Enter": () => {
this.options.submit();
return true;
},
};
},
});

View File

@@ -0,0 +1,71 @@
/**
* From https://www.tiptap.dev/api/editor/#inject-css
* https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/style.ts
*/
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
& [contenteditable="false"] {
white-space: normal;
}
& [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
& pre {
white-space: pre-wrap;
}
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 1px !important;
height: 1px !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
&:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection {
*::selection {
background: transparent;
}
*::-moz-selection {
background: transparent;
}
* {
caret-color: transparent;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.tippy-box[data-animation="fade"][data-state="hidden"] {
opacity: 0;
}

View File

@@ -0,0 +1,211 @@
<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"
/>
<img
:src="`/img/pics/error-480w.webp`"
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 #instanceName>
<b>{{ config?.name }}</b>
</template>
<template #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>{{ 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%;
section {
margin-bottom: 2rem;
}
.picture-wrapper {
text-align: center;
}
details {
summary:hover {
cursor: pointer;
}
}
}
</style>

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

@@ -0,0 +1,70 @@
<template>
<div
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 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" setup>
import { computed } from "vue";
const props = withDefaults(
defineProps<{
date: string;
small?: boolean;
}>(),
{ small: false }
);
const dateObj = computed<Date>(() => new Date(props.date));
const month = computed<string>(() =>
dateObj.value.toLocaleString(undefined, { month: "short" })
);
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 {
width: calc(40px * var(--small));
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
height: calc(40px * var(--small));
.datetime-container-header {
height: calc(10px * var(--small));
background: #f3425f;
}
.datetime-container-content {
height: calc(30px * var(--small));
}
time {
&.month {
font-size: 12px;
line-height: 12px;
}
&.day {
font-size: calc(1rem * var(--small));
line-height: calc(1rem * var(--small));
}
}
}
</style>

View File

@@ -0,0 +1,898 @@
<template>
<div class="">
<external-participation-button
v-if="event && event.joinOptions === EventJoinOptions.EXTERNAL"
:event="event"
:current-actor="currentActor"
/>
<participation-section
v-else-if="event && anonymousParticipationConfig"
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
:currentActor="currentActor"
:identities="identities"
:anonymousParticipationConfig="anonymousParticipationConfig"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>
<div class="flex flex-col gap-1 mt-1">
<p
class="inline-flex gap-2 ml-auto"
v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
>
<TicketConfirmationOutline />
<router-link
class="participations-link"
v-if="canManageEvent && event?.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"
>
<!-- We retire one because of the event creator who is a
participant -->
<span v-if="maximumAttendeeCapacity">
{{
t(
"{available}/{capacity} available places",
{
available:
maximumAttendeeCapacity -
event.participantStats.participant,
capacity: maximumAttendeeCapacity,
},
maximumAttendeeCapacity - event.participantStats.participant
)
}}
</span>
<span v-else>
{{
t(
"No one is participating|One person participating|{going} people participating",
{
going: event.participantStats.participant,
},
event.participantStats.participant
)
}}
</span>
</router-link>
<span v-else>
<span v-if="maximumAttendeeCapacity">
{{
t(
"{available}/{capacity} available places",
{
available:
maximumAttendeeCapacity -
(event?.participantStats.participant ?? 0),
capacity: maximumAttendeeCapacity,
},
maximumAttendeeCapacity -
(event?.participantStats.participant ?? 0)
)
}}
</span>
<span v-else>
{{
t(
"No one is participating|One person participating|{going} people participating",
{
going: event?.participantStats.participant,
},
event?.participantStats.participant ?? 0
)
}}
</span>
</span>
<VTooltip v-if="event?.local === false">
<HelpCircleOutline :size="16" />
<template #popper>
{{
t(
"The actual number of participants may differ, as this event is hosted on another instance."
)
}}
</template>
</VTooltip>
</p>
<o-dropdown class="ml-auto">
<template #trigger>
<o-button icon-right="dots-horizontal">
{{ t("Actions") }}
</o-button>
</template>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
name: RouteName.EDIT_EVENT,
params: { eventId: event?.uuid },
}"
>
<Pencil />
{{ t("Edit") }}
</router-link>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
name: RouteName.DUPLICATE_EVENT,
params: { eventId: event?.uuid },
}"
>
<ContentDuplicate />
{{ t("Duplicate") }}
</router-link>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="canManageEvent || event?.draft"
@click="openDeleteEventModal"
@keyup.enter="openDeleteEventModal"
><span class="flex gap-1">
<Delete />
{{ t("Delete") }}
</span>
</o-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
aria-role="o-dropdown-item"
v-if="canManageEvent || event?.draft"
/>
<o-dropdown-item
aria-role="listitem"
v-if="event?.draft === false"
@click="triggerShare()"
@keyup.enter="triggerShare()"
class="p-1"
>
<span class="flex gap-1">
<Share />
{{ t("Share this event") }}
</span>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
@click="downloadIcsEvent()"
@keyup.enter="downloadIcsEvent()"
v-if="event?.draft === false"
>
<span class="flex gap-1">
<CalendarPlus />
{{ t("Add to my calendar") }}
</span>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
class="p-1"
>
<span class="flex gap-1">
<Flag />
{{ t("Report") }}
</span>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
<o-modal
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
:close-button-aria-label="t('Close')"
:autoFocus="false"
:trapFocus="false"
>
<ReportModal
:on-confirm="reportEvent"
:title="t('Report this event')"
:outside-domain="organizerDomain"
/>
</o-modal>
<o-modal
:close-button-aria-label="t('Close')"
v-model:active="isShareModalActive"
has-modal-card
ref="shareModal"
>
<share-event-modal
v-if="event"
:event="event"
:eventCapacityOK="eventCapacityOK"
/>
</o-modal>
<o-modal
v-model:active="isJoinModalActive"
has-modal-card
ref="participationModal"
:close-button-aria-label="t('Close')"
>
<identity-picker v-if="identity" v-model="identity">
<template #footer>
<footer class="flex gap-2">
<o-button
outlined
ref="cancelButton"
@click="isJoinModalActive = false"
@keyup.enter="isJoinModalActive = false"
>
{{ t("Cancel") }}
</o-button>
<o-button
v-if="identity"
variant="primary"
ref="confirmButton"
@click="
event?.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity as IPerson)
: joinEvent(identity as IPerson)
"
@keyup.enter="
event?.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity as IPerson)
: joinEvent(identity as IPerson)
"
>
{{ t("Confirm my particpation") }}
</o-button>
</footer>
</template>
</identity-picker>
</o-modal>
<o-modal
v-model:active="isJoinConfirmationModalActive"
has-modal-card
ref="joinConfirmationModal"
:close-button-aria-label="t('Close')"
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
{{ t("Participation confirmation") }}
</p>
</header>
<section class="modal-card-body">
<p>
{{
t(
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?"
)
}}
</p>
<form
@submit.prevent="
joinEvent(actorForConfirmation as IPerson, messageForConfirmation)
"
>
<o-field :label="t('Message')">
<o-input
type="textarea"
size="medium"
v-model="messageForConfirmation"
minlength="10"
></o-input>
</o-field>
<div class="buttons">
<o-button
native-type="button"
class="button"
ref="cancelButton"
@click="isJoinConfirmationModalActive = false"
@keyup.enter="isJoinConfirmationModalActive = false"
>{{ t("Cancel") }}
</o-button>
<o-button variant="primary" native-type="submit">
{{ t("Confirm my participation") }}
</o-button>
</div>
</form>
</section>
</div>
</o-modal>
</template>
<script lang="ts" setup>
import { IActor, IPerson } from "@/types/actor";
import { IEvent } from "@/types/event.model";
import ParticipationSection from "@/components/Participation/ParticipationSection.vue";
import ReportModal from "@/components/Report/ReportModal.vue";
import IdentityPicker from "@/views/Account/IdentityPicker.vue";
import { EventJoinOptions, ParticipantRole, MemberRole } from "@/types/enums";
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
import { computed, defineAsyncComponent, inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import Flag from "vue-material-design-icons/Flag.vue";
import CalendarPlus from "vue-material-design-icons/CalendarPlus.vue";
import ContentDuplicate from "vue-material-design-icons/ContentDuplicate.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import Pencil from "vue-material-design-icons/Pencil.vue";
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
import TicketConfirmationOutline from "vue-material-design-icons/TicketConfirmationOutline.vue";
import Share from "vue-material-design-icons/Share.vue";
import {
EVENT_PERSON_PARTICIPATION,
FETCH_EVENT,
JOIN_EVENT,
LEAVE_EVENT,
} from "@/graphql/event";
import { Notifier } from "@/plugins/notifier";
import { Dialog } from "@/plugins/dialog";
import { Snackbar } from "@/plugins/snackbar";
import RouteName from "@/router/name";
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from "@/services/AnonymousParticipationStorage";
import {
useAnonymousActorId,
useAnonymousParticipationConfig,
useAnonymousReportsConfig,
} from "@/composition/apollo/config";
import { useCurrentUserIdentities } from "@/composition/apollo/actor";
import { useRouter } from "vue-router";
import { IParticipant } from "@/types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation } from "@vue/apollo-composable";
import { useCreateReport } from "@/composition/apollo/report";
import { useDeleteEvent } from "@/composition/apollo/event";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import ExternalParticipationButton from "./ExternalParticipationButton.vue";
const ShareEventModal = defineAsyncComponent(
() => import("@/components/Event/ShareEventModal.vue")
);
const props = defineProps<{
event: IEvent;
currentActor: IPerson | undefined;
participations: IParticipant[];
person: IPerson | undefined;
}>();
const { t } = useI18n({ useScope: "global" });
const notifier = inject<Notifier>("notifier");
const dialog = inject<Dialog>("dialog");
const router = useRouter();
const { anonymousReportsConfig } = useAnonymousReportsConfig();
const { anonymousActorId } = useAnonymousActorId();
const { anonymousParticipationConfig } = useAnonymousParticipationConfig();
const { identities } = useCurrentUserIdentities();
const event = computed(() => props.event);
const identity = ref<IPerson | undefined | null>(null);
const ableToReport = computed((): boolean => {
return (
props.currentActor?.id != null ||
anonymousReportsConfig.value?.allowed === true
);
});
const organizer = computed((): IActor | null => {
if (event.value?.attributedTo?.id) {
return event.value.attributedTo;
}
if (event.value?.organizerActor) {
return event.value.organizerActor;
}
return null;
});
const organizerDomain = computed((): string | undefined => {
return organizer.value?.domain ?? undefined;
});
const reportModal = ref();
const isReportModalActive = ref(false);
const isShareModalActive = ref(false);
const isJoinModalActive = ref(false);
const isJoinConfirmationModalActive = ref(false);
const actorForConfirmation = ref<IPerson | null>(null);
const messageForConfirmation = ref("");
const anonymousParticipation = ref<boolean | null>(null);
const downloadIcsEvent = async (): Promise<void> => {
const data = await (
await fetch(`${GRAPHQL_API_ENDPOINT}/events/${event.value.uuid}/export/ics`)
).text();
const blob = new Blob([data], { type: "text/calendar" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${event.value?.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const triggerShare = (): void => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: event.value?.title,
url: event.value?.url,
})
.then(() => console.debug("Successful share"))
.catch((error: any) => console.debug("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
};
const canManageEvent = computed((): boolean => {
return actorIsOrganizer.value || hasGroupPrivileges.value;
});
// const actorIsParticipant = computed((): boolean => {
// if (actorIsOrganizer.value) return true;
// return (
// participations.value.length > 0 &&
// participations.value[0].role === ParticipantRole.PARTICIPANT
// );
// });
const actorIsOrganizer = computed((): boolean => {
return (
props.participations.length > 0 &&
props.participations[0].role === ParticipantRole.CREATOR
);
});
const hasGroupPrivileges = computed((): boolean => {
return (
props.person?.memberships !== undefined &&
props.person?.memberships?.total > 0 &&
[MemberRole.MODERATOR, MemberRole.ADMINISTRATOR].includes(
props.person?.memberships?.elements[0].role
)
);
});
const joinEventWithConfirmation = (actor: IPerson): void => {
isJoinConfirmationModalActive.value = true;
actorForConfirmation.value = actor;
};
const {
mutate: joinEventMutation,
onDone: onJoinEventMutationDone,
onError: onJoinEventMutationError,
} = useMutation<{
joinEvent: IParticipant;
}>(JOIN_EVENT, () => ({
update: (
store: ApolloCache<{
joinEvent: IParticipant;
}>,
{ data }: FetchResult
) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: event.value?.id, actorId: identity.value?.id },
});
if (participationCachedData?.person == undefined) {
console.error(
"Cannot update participation cache, because of null value."
);
return;
}
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: event.value?.id, actorId: identity.value?.id },
data: {
person: {
...participationCachedData?.person,
participations: {
elements: [data.joinEvent],
total: 1,
},
},
},
});
const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: event.value?.uuid },
});
if (cachedData == null) return;
const { event: cachedEvent } = cachedData;
if (cachedEvent === null) {
console.error(
"Cannot update event participant cache, because of null value."
);
return;
}
const participantStats = { ...cachedEvent.participantStats };
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved += 1;
} else {
participantStats.going += 1;
participantStats.participant += 1;
}
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: props.event.uuid },
data: {
event: {
...cachedEvent,
participantStats,
},
},
});
},
}));
const joinEvent = (
identityForJoin: IPerson,
message: string | null = null
): void => {
isJoinConfirmationModalActive.value = false;
isJoinModalActive.value = false;
joinEventMutation({
eventId: event.value?.id,
actorId: identityForJoin?.id,
message,
});
};
const participationRequestedMessage = () => {
notifier?.success(t("Your participation has been requested"));
};
const participationConfirmedMessage = () => {
notifier?.success(t("Your participation has been confirmed"));
};
onJoinEventMutationDone(({ data }) => {
if (data) {
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participationRequestedMessage();
} else {
participationConfirmedMessage();
}
}
});
const { oruga } = useProgrammatic();
onJoinEventMutationError((error) => {
if (error.message) {
oruga.notification.open({
message: error.message,
variant: "danger",
position: "bottom-right",
duration: 5000,
});
}
console.error(error);
});
const confirmLeave = (): void => {
dialog?.confirm({
title: t('Leaving event "{title}"', {
title: event.value?.title,
}),
message: t(
'Are you sure you want to cancel your participation at event "{title}"?',
{
title: event.value?.title,
}
),
confirmText: t("Leave event"),
cancelText: t("Cancel"),
variant: "danger",
hasIcon: true,
onConfirm: () => {
if (event.value && props.currentActor?.id) {
console.debug("calling leave event");
leaveEvent(event.value, props.currentActor.id);
}
},
});
};
const {
mutate: createReportMutation,
onDone: onCreateReportDone,
onError: onCreateReportError,
} = useCreateReport();
onCreateReportDone(() => {
notifier?.success(
t("Event {eventTitle} reported", { eventTitle: props?.event?.title })
);
});
onCreateReportError((error) => {
console.error(error);
});
const reportEvent = async (
content: string,
forward: boolean
): Promise<void> => {
isReportModalActive.value = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
reportModal.value.close();
if (!organizer.value) return;
createReportMutation({
eventsIds: [event.value?.id ?? ""],
reportedId: organizer.value?.id ?? "",
content,
forward,
});
};
const maximumAttendeeCapacity = computed((): number | undefined => {
return event.value?.options?.maximumAttendeeCapacity;
});
const eventCapacityOK = computed((): boolean => {
if (event.value?.draft) return true;
if (!maximumAttendeeCapacity.value) return true;
return (
event.value?.options?.maximumAttendeeCapacity !== undefined &&
event.value.participantStats.participant !== undefined &&
event.value?.options?.maximumAttendeeCapacity >
event.value.participantStats.participant
);
});
// const numberOfPlacesStillAvailable = computed((): number | undefined => {
// if (event.value?.draft) return maximumAttendeeCapacity.value;
// return (
// (maximumAttendeeCapacity.value ?? 0) -
// (event.value?.participantStats.participant ?? 0)
// );
// });
const {
mutate: leaveEventMutation,
onDone: onLeaveEventMutationDone,
onError: onLeaveEventMutationError,
} = useMutation<{ leaveEvent: { actor: { id: string } } }>(LEAVE_EVENT, () => ({
update: (
store: ApolloCache<{
leaveEvent: IParticipant;
}>,
{ data }: FetchResult,
{ context, variables }
) => {
if (data == null) return;
let participation;
const token = context?.token;
const actorId = variables?.actorId;
const localEventId = variables?.eventId;
const eventUUID = context?.eventUUID;
const isAnonymousParticipationConfirmed =
context?.isAnonymousParticipationConfirmed;
if (!token) {
const participationCachedData = store.readQuery<{
person: IPerson;
}>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: localEventId, actorId },
});
if (participationCachedData == null) return;
const { person: cachedPerson } = participationCachedData;
[participation] = cachedPerson?.participations?.elements ?? [undefined];
store.modify({
id: `Person:${actorId}`,
fields: {
participations() {
return {
elements: [],
total: 0,
};
},
},
});
}
const eventCachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: eventUUID },
});
if (eventCachedData == null) return;
const { event: eventCached } = eventCachedData;
if (eventCached === null) {
console.error("Cannot update event cache, because of null value.");
return;
}
const participantStats = { ...eventCached.participantStats };
if (participation && participation?.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved -= 1;
} else if (isAnonymousParticipationConfirmed === false) {
participantStats.notConfirmed -= 1;
} else {
participantStats.going -= 1;
participantStats.participant -= 1;
}
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: eventUUID },
data: {
event: {
...eventCached,
participantStats,
},
},
});
},
}));
const leaveEvent = (
eventToLeave: IEvent,
actorId: string,
token: string | null = null,
isAnonymousParticipationConfirmed: boolean | null = null
): void => {
leaveEventMutation(
{
eventId: eventToLeave.id,
actorId,
token,
},
{
context: {
token,
isAnonymousParticipationConfirmed,
eventUUID: eventToLeave.uuid,
},
}
);
};
onLeaveEventMutationDone(({ data }) => {
if (data) {
notifier?.success(t("You have cancelled your participation"));
}
});
const snackbar = inject<Snackbar>("snackbar");
onLeaveEventMutationError((error) => {
snackbar?.open({
message: error.message,
variant: "danger",
position: "bottom",
});
console.error(error);
});
const anonymousParticipationConfirmed = async (): Promise<boolean> => {
return isParticipatingInThisEvent(props.event?.uuid);
};
const cancelAnonymousParticipation = async (): Promise<void> => {
if (!event.value || !anonymousActorId.value) return;
const token = (await getLeaveTokenForParticipation(
props.event?.uuid
)) as string;
leaveEvent(event.value, anonymousActorId.value, token);
await removeAnonymousParticipation(props.event?.uuid);
anonymousParticipation.value = null;
};
onMounted(async () => {
identity.value = props.currentActor;
try {
if (window.isSecureContext) {
anonymousParticipation.value = await anonymousParticipationConfirmed();
}
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
anonymousParticipation.value = null;
} else {
console.error(e);
}
}
});
const {
mutate: deleteEvent,
onDone: onDeleteEventDone,
onError: onDeleteEventError,
} = useDeleteEvent();
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
};
const deleteEventMessage = computed(() => {
const participantsLength = event.value?.participantStats.participant;
const prefix = participantsLength
? t(
"There are {participants} participants.",
{
participants: event.value.participantStats.participant,
},
event.value.participantStats.participant
)
: "";
return `${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.value?.title,
})}`;
});
const openDeleteEventModal = () => {
dialog?.prompt({
title: t("Delete event"),
message: deleteEventMessage.value,
confirmText: t("Delete event"),
cancelText: t("Cancel"),
variant: "danger",
hasIcon: true,
hasInput: true,
inputAttrs: {
placeholder: event.value?.title,
pattern: escapeRegExp(event.value?.title ?? ""),
},
onConfirm: (result: string) => {
console.debug("calling delete event", result);
if (result.trim() === event.value?.title) {
event.value?.id ? deleteEvent({ eventId: event.value?.id }) : null;
}
},
});
};
onDeleteEventDone(() => {
router.push({ name: RouteName.MY_EVENTS });
});
onDeleteEventError((error) => {
console.error(error);
});
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex justify-center max-h-80">
<lazy-image-wrapper :picture="picture" />
</div>
</template>
<script lang="ts" setup>
import { IMedia } from "@/types/media.model";
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
withDefaults(
defineProps<{
picture: IMedia | null;
}>(),
{ picture: null }
);
</script>

View File

@@ -0,0 +1,148 @@
<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>
<Variant title="Row mode">
<EventCard :event="longEvent" mode="row" />
</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().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 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. But if it doesn't work, we really need to truncate it at some point. Definitively.",
});
const tentativeEvent = reactive<IEvent>({
...baseEvent,
status: EventStatus.TENTATIVE,
});
const cancelledEvent = reactive<IEvent>({
...baseEvent,
status: EventStatus.CANCELLED,
});
</script>

View File

@@ -0,0 +1,239 @@
<template>
<LinkOrRouterLink
class="mbz-card snap-center dark:bg-mbz-purple"
:class="{
'sm:flex sm:items-start': mode === 'row',
'sm:max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
}"
:to="to"
:isInternal="isInternal"
>
<div
class="rounded-lg"
:class="{ 'sm:w-full sm:max-w-[20rem]': mode === 'row' }"
>
<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="absolute top-3 right-0 ltr:-mr-1 rtl:-ml-1 z-10 max-w-xs no-underline flex flex-col gap-1 items-end"
v-show="mode === 'column'"
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
>
<mobilizon-tag
variant="info"
v-if="event.status === EventStatus.TENTATIVE"
>
{{ t("Tentative") }}
</mobilizon-tag>
<mobilizon-tag
variant="danger"
v-if="event.status === EventStatus.CANCELLED"
>
{{ t("Cancelled") }}
</mobilizon-tag>
<router-link
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
v-for="tag in (event.tags || []).slice(0, 3)"
:key="tag.slug"
>
<mobilizon-tag dir="auto" :with-hash-tag="true">{{
tag.title
}}</mobilizon-tag>
</router-link>
</div>
</figure>
</div>
<div class="p-2 flex-auto" :class="{ 'sm:flex-1': mode === 'row' }">
<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"
:class="{ 'sm:hidden': mode === 'row' }"
>
<date-calendar-icon
:small="true"
v-if="!mergedOptions.hideDate"
:date="event.beginsOn.toString()"
/>
</div>
<span
class="text-gray-700 dark:text-white font-semibold hidden"
:class="{ 'sm:block': mode === 'row' }"
>{{ formatDateTimeWithCurrentLocale }}</span
>
<div class="w-full flex flex-col justify-between h-full">
<h2
class="mt-0 mb-2 text-2xl line-clamp-3 font-bold text-violet-3 dark:text-white"
dir="auto"
:lang="event.language"
>
{{ event.title }}
</h2>
<div class="">
<div
class="flex items-center text-violet-3 dark:text-white"
dir="auto"
>
<figure class="" v-if="actorAvatarURL">
<img
class="rounded-xl"
:src="actorAvatarURL"
alt=""
width="24"
height="24"
loading="lazy"
/>
</figure>
<account-circle v-else />
<span class="font-semibold ltr:pl-2 rtl:pr-2">
{{ organizerDisplayName(event) }}
</span>
</div>
<inline-address
v-if="event.physicalAddress"
:physical-address="event.physicalAddress"
/>
<div
class="flex items-center text-sm"
dir="auto"
v-else-if="event.options && event.options.isOnline"
>
<Video />
<span class="ltr:pl-2 rtl:pr-2">{{ t("Online") }}</span>
</div>
<div
class="mt-1 no-underline gap-1 items-center hidden"
:class="{ 'sm:flex': mode === 'row' }"
v-if="
event.tags ||
event.status !== EventStatus.CONFIRMED ||
event.participantStats?.participant > 0
"
>
<mobilizon-tag
variant="info"
v-if="event.participantStats?.participant > 0"
>
{{
t(
"{count} participants",
{ count: event.participantStats?.participant },
event.participantStats?.participant
)
}}
</mobilizon-tag>
<mobilizon-tag
variant="info"
v-if="event.status === EventStatus.TENTATIVE"
>
{{ t("Tentative") }}
</mobilizon-tag>
<mobilizon-tag
variant="danger"
v-if="event.status === EventStatus.CANCELLED"
>
{{ t("Cancelled") }}
</mobilizon-tag>
<router-link
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
v-for="tag in (event.tags || []).slice(0, 3)"
:key="tag.slug"
>
<mobilizon-tag :with-hash-tag="true" dir="auto">{{
tag.title
}}</mobilizon-tag>
</router-link>
</div>
</div>
</div>
</div>
</div>
</LinkOrRouterLink>
</template>
<script lang="ts" setup>
import {
IEvent,
IEventCardOptions,
organizerDisplayName,
organizerAvatarUrl,
} from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { EventStatus } from "@/types/enums";
import RouteName from "../../router/name";
import InlineAddress from "@/components/Address/InlineAddress.vue";
import { computed, inject } from "vue";
import MobilizonTag from "@/components/TagElement.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Video from "vue-material-design-icons/Video.vue";
import { formatDateTimeForEvent } from "@/utils/datetime";
import type { Locale } from "date-fns";
import LinkOrRouterLink from "../core/LinkOrRouterLink.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const props = withDefaults(
defineProps<{
event: IEvent;
options?: IEventCardOptions;
mode?: "row" | "column";
}>(),
{ mode: "column" }
);
const defaultOptions: IEventCardOptions = {
hideDate: false,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
isRemoteEvent: false,
isLoggedIn: true,
};
const mergedOptions = computed<IEventCardOptions>(() => ({
...defaultOptions,
...props.options,
}));
// const actor = computed<Actor>(() => {
// return Object.assign(
// new Person(),
// props.event.organizerActor ?? mergedOptions.value.organizerActor
// );
// });
const actorAvatarURL = computed<string | null>(() =>
organizerAvatarUrl(props.event)
);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const formatDateTimeWithCurrentLocale = computed(() => {
if (!dateFnsLocale) return;
return formatDateTimeForEvent(new Date(props.event.beginsOn), dateFnsLocale);
});
const isInternal = computed(() => {
return (
mergedOptions.value.isRemoteEvent &&
mergedOptions.value.isLoggedIn === false
);
});
const to = computed(() => {
if (mergedOptions.value.isRemoteEvent) {
if (mergedOptions.value.isLoggedIn === false) {
return props.event.url;
}
return {
name: RouteName.INTERACT,
query: { uri: encodeURI(props.event.url) },
};
}
return { name: RouteName.EVENT, params: { uuid: props.event.uuid } };
});
</script>

View File

@@ -0,0 +1,187 @@
<template>
<p v-if="!endsOn">
<span>{{
formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
}}</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</o-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && showEndTime">
<span>{{
t("On {date} from {startTime} to {endTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endTime: formatTime(endsOn, timezoneToShow),
})
}}</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</o-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && !showEndTime">
{{
t("On {date} starting at {startTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
})
}}
</p>
<p v-else-if="isSameDay()">
{{ t("On {date}", { date: formatDate(beginsOn) }) }}
</p>
<p v-else-if="endsOn && showStartTime && showEndTime">
<span>
{{
t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn, timezoneToShow),
})
}}
</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ multipleTimeZones }}
</o-switch>
</p>
<p v-else-if="endsOn && showStartTime">
<span>
{{
t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endDate: formatDate(endsOn),
})
}}
</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</o-switch>
</p>
<p v-else-if="endsOn">
{{
t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn),
endDate: formatDate(endsOn),
})
}}
</p>
</template>
<script lang="ts" setup>
import {
formatDateString,
formatDateTimeString,
formatTimeString,
} from "@/filters/datetime";
import { getTimezoneOffset } from "date-fns-tz";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
beginsOn: string;
endsOn?: string;
showStartTime?: boolean;
showEndTime?: boolean;
timezone?: string;
userTimezone?: string;
}>(),
{
showStartTime: true,
showEndTime: true,
}
);
const { t } = useI18n({ useScope: "global" });
const showLocalTimezone = ref(true);
const timezoneToShow = computed((): string | undefined => {
if (showLocalTimezone.value) {
return props.timezone;
}
return userActualTimezone.value;
});
const userActualTimezone = computed((): string => {
if (props.userTimezone) {
return props.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
});
const formatDate = (value: string): string | undefined => {
return formatDateString(value);
};
const formatTime = (
value: string,
timezone: string | undefined = undefined
): string | undefined => {
return formatTimeString(value, timezone ?? "Etc/UTC");
};
const isSameDay = (): boolean => {
if (!props.endsOn) return false;
return (
beginsOnDate.value.toDateString() === new Date(props.endsOn).toDateString()
);
};
const beginsOnDate = computed((): Date => {
return new Date(props.beginsOn);
});
const differentFromUserTimezone = computed((): boolean => {
return (
!!props.timezone &&
!!userActualTimezone.value &&
getTimezoneOffset(props.timezone, beginsOnDate.value) !==
getTimezoneOffset(userActualTimezone.value, beginsOnDate.value) &&
props.timezone !== userActualTimezone.value
);
});
const singleTimeZone = computed((): string => {
if (showLocalTimezone.value) {
return t("Local time ({timezone})", {
timezone: timezoneToShow.value,
});
}
return t("Time in your timezone ({timezone})", {
timezone: timezoneToShow.value,
});
});
const multipleTimeZones = computed((): string => {
if (showLocalTimezone.value) {
return t("Local times ({timezone})", {
timezone: timezoneToShow.value,
});
}
return t("Times in your timezone ({timezone})", {
timezone: timezoneToShow.value,
});
});
</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

@@ -0,0 +1,83 @@
<template>
<article
class="bg-white dark:bg-zinc-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="mt-0 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" setup>
import { IEventCardOptions, IEvent } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
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";
withDefaults(defineProps<{ event: IEvent; options?: IEventCardOptions }>(), {
options: (): IEventCardOptions => ({
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
}),
});
</script>

View File

@@ -0,0 +1,171 @@
<template>
<div class="">
<div class="text-end">
<button @click="emit('close')">
<Close />
<span class="sr-only">{{ t("Close map") }}</span>
</button>
</div>
<section class="map">
<map-leaflet
v-if="physicalAddress?.geom"
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="flex flex-col items-center mt-4">
<p v-if="physicalAddress?.fullName" class="flex gap-2 text-xl font-bold">
<MapMarker />
{{ physicalAddress.fullName }}
</p>
<p class="mt-4">{{ t("Getting there") }}</p>
<div
class="flex gap-2"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<o-button
tag="a"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<Walk />
<span class="sr-only">{{ t("On foot") }}</span>
</o-button>
<o-button
tag="a"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<Bike />
<span class="sr-only">{{ t("By bike") }}</span>
</o-button>
<o-button
tag="a"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<Bus />
<span class="sr-only">{{ t("By transit") }}</span>
</o-button>
<o-button
tag="a"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<Car />
<span class="sr-only">{{ t("By car") }}</span>
</o-button>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { Address, IAddress } from "@/types/address.model";
import { RoutingTransportationType, RoutingType } from "@/types/enums";
import { computed, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import Car from "vue-material-design-icons/Car.vue";
import Bike from "vue-material-design-icons/Bike.vue";
import Bus from "vue-material-design-icons/Bus.vue";
import Walk from "vue-material-design-icons/Walk.vue";
import MapMarker from "vue-material-design-icons/MapMarker.vue";
import Close from "vue-material-design-icons/Close.vue";
const { t } = useI18n({ useScope: "global" });
const RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
const MapLeaflet = defineAsyncComponent(
() => import("@/components/LeafletMap.vue")
);
const props = defineProps<{
address: IAddress;
routingType: RoutingType;
}>();
const emit = defineEmits(["close"]);
const physicalAddress = computed((): Address | null => {
if (!props.address) return null;
return new Address(props.address);
});
const makeNavigationPath = (
transportationType: RoutingTransportationType
): string | undefined => {
const geometry = physicalAddress.value?.geom;
if (geometry) {
/**
* build urls to routing map
*/
if (!RoutingParamType[props.routingType][transportationType]) {
return;
}
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}`;
}
}
}
};
const addressLinkToRouteByCar = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.CAR);
});
const addressLinkToRouteByBike = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.BIKE);
});
const addressLinkToRouteByFeet = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.FOOT);
});
const addressLinkToRouteByTransit = computed((): undefined | string => {
return makeNavigationPath(RoutingTransportationType.TRANSIT);
});
</script>
<style lang="scss" scoped>
section.map {
height: 75vh;
width: 100%;
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div>
<h2>{{ title }}</h2>
<div class="flex items-center mb-3 gap-1 eventMetadataBlock">
<slot name="icon"></slot>
<!-- Custom icons -->
<!-- <span
class="icon is-medium"
v-if="icon && icon.substring(0, 7) === 'mz:icon'"
>
<img
:src="`/img/${icon.substring(8)}_monochrome.svg`"
width="32"
height="32"
/>
</span>
<o-icon v-else-if="icon" :icon="icon" size="is-medium" /> -->
<div class="content-wrapper overflow-hidden w-full">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps<{
title: string;
}>();
</script>
<style lang="scss" scoped>
div.eventMetadataBlock {
.content-wrapper {
max-width: calc(100vw - 32px - 20px);
&.padding-left {
padding: 0 20px;
a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<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="
modelValue.icon && modelValue.icon.substring(0, 7) === 'mz:icon'
"
:src="`/img/${modelValue.icon.substring(8)}_monochrome.svg`"
width="24"
height="24"
alt=""
/>
<o-icon
v-else-if="modelValue.icon"
:icon="modelValue.icon"
customSize="24"
/>
<o-icon v-else icon="help-circle" customSize="24" />
</div>
<div class="flex-1">
<b>{{ modelValue.title || modelValue.label }}</b>
<br />
<small>
{{ modelValue.description }}
</small>
<div
v-if="
modelValue.type === EventMetadataType.STRING &&
modelValue.keyType === EventMetadataKeyType.CHOICE &&
modelValue.choices
"
>
<o-field v-for="(value, key) in modelValue.choices" :key="key">
<o-radio v-model="metadataItemValue" :native-value="key">{{
value
}}</o-radio>
</o-field>
</div>
<o-field
v-else-if="
modelValue.type === EventMetadataType.STRING &&
modelValue.keyType == EventMetadataKeyType.URL
"
>
<o-input
@blur="validatePattern"
ref="urlInput"
type="url"
:pattern="
modelValue.pattern ? modelValue.pattern.source : undefined
"
:validation-message="t(`This URL doesn't seem to be valid`)"
required
v-model="metadataItemValue"
:placeholder="modelValue.placeholder"
/>
</o-field>
<o-field v-else-if="modelValue.type === EventMetadataType.STRING">
<o-input
v-model="metadataItemValue"
:placeholder="modelValue.placeholder"
/>
</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"
? modelValue?.choices?.true
: modelValue?.choices?.false
}}
</o-checkbox>
</o-field>
</div>
<o-button icon-left="close" @click="$emit('removeItem', modelValue.key)">
<span class="sr-only">
{{ t("Remove") }}
</span>
</o-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
modelValue: IEventMetadataDescription;
}>();
const emit = defineEmits(["update:modelValue", "removeItem"]);
const { t } = useI18n({ useScope: "global" });
const urlInput = ref<any>(null);
const metadataItemValue = computed({
get(): string {
return props.modelValue.value;
},
set(value: string) {
if (validate(value)) {
emit("update:modelValue", {
...props.modelValue,
value: value.toString(),
});
}
},
});
const validatePattern = (): void => {
urlInput.value?.checkHtml5Validity();
};
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;
};
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.card .media {
align-items: center;
// & > button {
// @include margin-left(1rem);
// }
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<section>
<div class="mb-4">
<div v-for="(item, index) in metadata" :key="item.key" class="my-2">
<event-metadata-item
:modelValue="metadata[index]"
@update:modelValue="updateSingleMetadata"
@removeItem="removeItem"
/>
</div>
</div>
<o-field
grouped
:label="$t('Find or add an element')"
label-for="event-metadata-autocomplete"
>
<o-autocomplete
expanded
:clear-on-select="true"
v-model="search"
ref="autocomplete"
:data="filteredDataArray"
group-field="category"
group-options="items"
open-on-focus
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
id="event-metadata-autocomplete"
@select="addElement"
dir="auto"
>
<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 &&
props.option.icon.substring(0, 7) === 'mz:icon'
"
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
width="24"
height="24"
alt=""
/>
<o-icon v-else-if="props.option.icon" :icon="props.option.icon" />
<o-icon v-else icon="help-circle" />
</div>
<div class="">
<b>{{ props.option.label }}</b>
<br />
<small>
{{ props.option.description }}
</small>
</div>
</div>
</template>
<template #empty>{{
$t("No results for {search}", { search })
}}</template>
</o-autocomplete>
<p class="control">
<o-button @click="showNewElementModal = true">
{{ $t("Add new…") }}
</o-button>
</p>
</o-field>
<o-modal
has-modal-card
v-model:active="showNewElementModal"
:close-button-aria-label="$t('Close')"
>
<div class="">
<header class="">
<h2>{{ t("Create a new metadata element") }}</h2>
<p>
{{
t(
"You can put any arbitrary content in this element. URLs will be clickable."
)
}}
</p>
</header>
<div class="">
<form @submit="addNewElement">
<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 class="mt-2" variant="primary" native-type="submit">{{
$t("Add")
}}</o-button>
</form>
</div>
</div>
</o-modal>
</section>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import cloneDeep from "lodash/cloneDeep";
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: IEventMetadataDescription[];
}>;
const props = defineProps<{
modelValue: IEventMetadataDescription[];
}>();
const emit = defineEmits(["update:modelValue"]);
const newElement = reactive({
title: "",
value: "",
});
const { t } = useI18n({ useScope: "global" });
const search = ref("");
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(newMetadata: IEventMetadataDescription[]) {
emit(
"update:modelValue",
newMetadata.filter((elem) => elem)
);
},
});
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,
};
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;
},
[]
);
});
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);
};
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);
};
const addElement = (element: IEventMetadataDescription): void => {
metadata.value = [...metadata.value, element];
};
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

@@ -0,0 +1,275 @@
<template>
<div>
<event-metadata-block
v-if="!event.options.isOnline"
:title="t('Location')"
:icon="addressPOIInfos?.poiIcon?.icon ?? 'earth'"
>
<div class="address-wrapper">
<span v-if="!physicalAddress">{{ t("No address defined") }}</span>
<div class="address" v-if="physicalAddress">
<address-info :address="physicalAddress" />
<o-button
variant="text"
class="map-show-button"
@click="$emit('showMapModal', true)"
v-if="physicalAddress.geom"
>
{{ t("Show map") }}
</o-button>
</div>
</div>
<template #icon>
<o-icon
v-if="addressPOIInfos?.poiIcon?.icon"
:icon="addressPOIInfos?.poiIcon?.icon"
customSize="36"
/>
<Earth v-else :size="36" />
</template>
</event-metadata-block>
<event-metadata-block :title="t('Date and time')">
<template #icon>
<Calendar :size="36" />
</template>
<event-full-date
:beginsOn="event.beginsOn.toString()"
:show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime"
:timezone="event.options.timezone ?? undefined"
:userTimezone="userTimezone"
:endsOn="event.endsOn?.toString()"
/>
</event-metadata-block>
<event-metadata-block
class="metadata-organized-by"
:title="t('Organized by')"
>
<router-link
v-if="event.attributedTo"
class="hover:underline"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(event.attributedTo),
},
}"
>
<actor-card
v-if="
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
"
:actor="event.attributedTo"
:inline="true"
/>
</router-link>
<actor-card
v-else-if="event.organizerActor"
:actor="event.organizerActor"
:inline="true"
/>
<actor-card
:inline="true"
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
/>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
:title="t('Website')"
>
<template #icon>
<Link :size="36" />
</template>
<a
target="_blank"
class="underline"
rel="noopener noreferrer ugc"
:href="event.onlineAddress"
:title="
t('View page on {hostname} (in a new window)', {
hostname: urlToHostname(event.onlineAddress),
})
"
>{{ simpleURL(event.onlineAddress) }}</a
>
</event-metadata-block>
<event-metadata-block
v-for="extra in extraMetadata"
:title="extra.title || extra.label"
:key="extra.key"
>
<template #icon>
<img
v-if="extra.icon && extra.icon.substring(0, 7) === 'mz:icon'"
:src="`/img/${extra.icon.substring(8)}_monochrome.svg`"
width="36"
height="36"
alt=""
/>
<o-icon v-else-if="extra.icon" :icon="extra.icon" customSize="36" />
<o-icon v-else customSize="36" icon="help-circle" />
</template>
<span
v-if="
((extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.CHOICE) ||
extra.type === EventMetadataType.BOOLEAN) &&
extra.choices &&
extra.choices[extra.value]
"
>
{{ extra.choices[extra.value] }}
</span>
<a
v-else-if="
extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.URL
"
target="_blank"
rel="noopener noreferrer ugc"
:href="extra.value"
:title="
t('View page on {hostname} (in a new window)', {
hostname: urlToHostname(extra.value),
})
"
>{{ simpleURL(extra.value) }}</a
>
<a
v-else-if="
extra.type == EventMetadataType.STRING &&
extra.keyType == EventMetadataKeyType.HANDLE
"
target="_blank"
rel="noopener noreferrer ugc"
:href="accountURL(extra)"
:title="
t('View account on {hostname} (in a new window)', {
hostname: urlToHostname(accountURL(extra)),
})
"
>{{ extra.value }}</a
>
<span v-else>{{ extra.value }}</span>
</event-metadata-block>
</div>
</template>
<script lang="ts" setup>
import { Address, addressToPoiInfos } from "@/types/address.model";
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEvent } from "@/types/event.model";
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 ActorCard from "../../components/Account/ActorCard.vue";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";
import { IUser } from "@/types/current-user.model";
import { useI18n } from "vue-i18n";
import Earth from "vue-material-design-icons/Earth.vue";
import Calendar from "vue-material-design-icons/Calendar.vue";
import Link from "vue-material-design-icons/Link.vue";
const props = withDefaults(
defineProps<{
event: IEvent;
user: IUser | undefined;
showMap?: boolean;
}>(),
{ showMap: false }
);
const { t } = useI18n({ useScope: "global" });
const physicalAddress = computed((): Address | null => {
if (!props.event.physicalAddress) return null;
return new Address(props.event.physicalAddress);
});
const addressPOIInfos = computed(() => {
if (!props.event.physicalAddress) return null;
return addressToPoiInfos(props.event.physicalAddress);
});
const extraMetadata = computed((): IEventMetadataDescription[] => {
return props.event.metadata.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
});
});
const urlToHostname = (url: string | undefined): string | null => {
if (!url) return null;
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
};
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;
}
};
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}`;
}
}
};
const userTimezone = computed((): string | undefined => {
return props.user?.settings?.timezone;
});
</script>
<style lang="scss" scoped>
:deep(.metadata-organized-by) {
.v-popover.popover .trigger {
width: 100%;
.media-content {
width: calc(100% - 32px - 1rem);
max-width: 80vw;
p.has-text-grey-dark {
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
div.address-wrapper {
display: flex;
flex: 1;
flex-wrap: wrap;
div.address {
flex: 1;
.map-show-button {
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<router-link
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 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"
class="object-cover flex-none h-40 md:w-48 rounded-t-lg md:rounded-none md:rounded-l-lg"
/>
</div>
</div>
<div class="p-2">
<h3
class="pb-2 text-lg leading-6 line-clamp-3 font-bold text-violet-title dark:text-white"
:lang="event.language"
dir="auto"
>
<tag
variant="info"
class="mr-1"
v-if="event.status === EventStatus.TENTATIVE"
>
{{ $t("Tentative") }}
</tag>
<tag
variant="danger"
class="mr-1"
v-if="event.status === EventStatus.CANCELLED"
>
{{ $t("Cancelled") }}
</tag>
<tag
class="mr-2 font-normal"
variant="warning"
size="medium"
v-if="event.draft"
>{{ $t("Draft") }}</tag
>
{{ event.title }}
</h3>
<inline-address
v-if="event.physicalAddress"
class=""
:physical-address="event.physicalAddress"
/>
<div class="" v-else-if="event.options && event.options.isOnline">
<Video />
<span>{{ $t("Online") }}</span>
</div>
<div class="flex gap-1" v-if="showOrganizer">
<figure class="" v-if="organizer(event) && organizer(event)?.avatar">
<img
class="rounded-full"
:src="organizer(event)?.avatar?.url"
alt=""
width="24"
height="24"
/>
</figure>
<AccountCircle v-else :size="24" />
<span class="">
{{ organizerDisplayName(event) }}
</span>
</div>
<p class="flex gap-1">
<AccountMultiple />
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$t(
"{available}/{capacity} available places",
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
},
event.options.maximumAttendeeCapacity -
event.participantStats.participant
)
}}
</span>
<span v-else>
{{
$t(
"{count} participants",
{
count: event.participantStats.participant,
},
event.participantStats.participant
)
}}
</span>
<span v-if="event.participantStats.notApproved > 0">
<o-button
variant="text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: event.uuid },
})
"
>
{{
$t(
"{count} requests waiting",
{
count: event.participantStats.notApproved,
},
event.participantStats.notApproved
)
}}
</o-button>
</span>
</p>
</div>
</router-link>
</template>
<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";
import Tag from "@/components/TagElement.vue";
withDefaults(
defineProps<{
event: IEvent;
showOrganizer?: boolean;
}>(),
{ showOrganizer: false }
);
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.event-minimalist-card-wrapper {
// display: grid;
// grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
// color: initial;
// @include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
// }
// .event-preview {
// & > div {
// position: relative;
// height: 120px;
// width: 100%;
// div.date-component {
// display: flex;
// position: absolute;
// bottom: 5px;
// left: 5px;
// z-index: 1;
// }
// }
// }
// .calendar-icon {
// @include margin-right(1rem);
// }
}
</style>

View File

@@ -0,0 +1,618 @@
<template>
<article
class="bg-white dark:bg-mbz-purple dark:hover:bg-mbz-purple-400 mb-5 mt-4 pb-2 md:p-0 rounded-t-lg"
>
<div
class="bg-mbz-yellow-alt-100 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="rounded"
:src="participation.actor.avatar.url"
alt=""
height="24"
width="24"
/>
</figure>
<AccountCircle class="ltr:pr-1 rtl:pl-1" v-else />
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card flex flex-col relative">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-x-1.5 md:gap-y-3 gapt-x-3"
>
<div class="mr-0 ml-0">
<div class="h-40 relative w-full">
<div class="flex absolute bottom-2 left-2 z-10">
<date-calendar-icon
:date="participation.event.beginsOn.toString()"
:small="true"
/>
</div>
<router-link
class="h-full"
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<lazy-image-wrapper
:rounded="true"
:picture="participation.event.picture"
style="
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
"
/>
</router-link>
</div>
</div>
<div class="list-card-content lg:col-span-4 flex-1 p-2">
<div class="flex items-center pt-2" dir="auto">
<Tag
variant="info"
class="mr-1 mb-1"
size="medium"
v-if="participation.event.status === EventStatus.TENTATIVE"
>
{{ t("Tentative") }}
</Tag>
<Tag
variant="danger"
class="mr-1 mb-1"
size="medium"
v-if="participation.event.status === EventStatus.CANCELLED"
>
{{ t("Cancelled") }}
</Tag>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<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"
:physical-address="participation.event.physicalAddress"
/>
<div
class="flex gap-1"
v-else-if="
participation.event.options &&
participation.event.options.isOnline
"
>
<Video />
<span>{{ t("Online") }}</span>
</div>
<div class="flex gap-1">
<figure class="" v-if="actorAvatarURL">
<img
class="rounded"
:src="actorAvatarURL"
alt=""
width="24"
height="24"
/>
</figure>
<AccountCircle v-else />
<span>
{{ organizerDisplayName(participation.event) }}
</span>
</div>
<div class="flex">
<AccountGroup :class="{ 'has-text-danger': lastSeatsLeft }" />
<span
class="flex items-center py-0 px-2"
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
>
<!-- Less than 10 seats left -->
<span class="has-text-danger" v-if="lastSeatsLeft">
{{
t("{number} seats left", {
number: seatsLeft,
})
}}
</span>
<span
v-else-if="
participation.event.options.maximumAttendeeCapacity !== 0
"
>
{{
t(
"{available}/{capacity} available places",
{
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>
{{
t(
"{count} participants",
{
count: participation.event.participantStats.participant,
},
participation.event.participantStats.participant
)
}}
</span>
<o-button
v-if="participation.event.participantStats.notApproved > 0"
variant="text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: participation.event.uuid },
})
"
>
{{
t(
"{count} requests waiting",
{
count: participation.event.participantStats.notApproved,
},
participation.event.participantStats.notApproved
)
}}
</o-button>
</span>
</div>
</div>
<o-dropdown
aria-role="list"
class="text-center self-center md:col-span-2 lg:col-span-1"
>
<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,
params: { eventId: participation.event.uuid },
})
"
>
<Pencil />
{{ t("Edit") }}
</div>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="participation.role === ParticipantRole.CREATOR"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.DUPLICATE_EVENT,
params: { eventId: participation.event.uuid },
})
"
>
<ContentDuplicate />
{{ t("Duplicate") }}
</div>
</o-dropdown-item>
<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") }}
</div>
</o-dropdown-item>
<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,
params: { eventId: participation.event.uuid },
})
"
>
<AccountMultiplePlus />
{{ t("Manage participations") }}
</div>
</o-dropdown-item>
<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" setup>
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { EventStatus, ParticipantRole } from "@/types/enums";
import { IParticipant } from "@/types/participant.model";
import {
IEvent,
IEventCardOptions,
organizerAvatarUrl,
organizerDisplayName,
} from "@/types/event.model";
import { displayNameAndUsername, 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 { 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";
import Tag from "@/components/TagElement.vue";
const props = defineProps<{
participation: IParticipant;
options?: IEventCardOptions;
}>();
const emit = defineEmits(["eventDeleted"]);
const { result: currentActorResult } = useQuery(CURRENT_ACTOR_CLIENT);
const currentActor = computed(() => currentActorResult.value?.currentActor);
const { t } = useI18n({ useScope: "global" });
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),
},
onConfirm: () => callback(event),
});
};
const { oruga } = useProgrammatic();
const snackbar = inject<Snackbar>("snackbar");
const {
mutate: deleteEvent,
onDone: onDeleteEventDone,
onError: onDeleteEventError,
} = useDeleteEvent();
onDeleteEventDone(() => {
/**
* When the event corresponding has been deleted (by the organizer).
* A notification is already triggered.
*
* @type {string}
*/
emit("eventDeleted", props.participation.event.id);
oruga.notification.open({
message: t("Event {eventTitle} deleted", {
eventTitle: props.participation.event.title,
}),
variant: "success",
position: "bottom-right",
duration: 5000,
});
});
onDeleteEventError((error) => {
snackbar?.open({
message: error.message,
variant: "danger",
position: "bottom",
});
console.error(error);
});
/**
* Delete the event
*/
const openDeleteEventModalWrapper = () => {
openDeleteEventModal(props.participation.event, (event: IEvent) =>
deleteEvent({ eventId: event.id ?? "" })
);
};
const router = useRouter();
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);
};
// 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;
// });
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;
});
const lastSeatsLeft = computed<boolean>(() => {
if (seatsLeft.value) {
return seatsLeft.value < 10;
}
return false;
});
const actorAvatarURL = computed<string | null>(() =>
organizerAvatarUrl(props.participation.event)
);
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
article.box {
// 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;
// }
// }
.list-card {
// display: flex;
// padding: 0 6px 0 0;
// position: relative;
// flex-direction: column;
.content-and-actions {
// 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 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;
// 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%;
// }
}
}
.actions {
// padding: 7px;
// cursor: pointer;
// align-self: center;
// justify-self: center;
grid-area: actions;
}
div.list-card-content {
// flex: 1;
// padding: 5px;
grid-area: body;
// .participant-stats {
// display: flex;
// align-items: center;
// padding: 0 5px;
// }
// div.title-wrapper {
// display: flex;
// align-items: center;
// padding-top: 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;
// }
// }
}
}
}
// .identity-header {
// display: flex;
// padding: 5px;
// figure,
// span.icon {
// @include padding-right(3px);
// }
// }
// & > .columns {
// padding: 1.25rem;
// }
// padding: 0;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<o-button
tag="a"
:href="
event.externalParticipationUrl
? encodeURI(`${event.externalParticipationUrl}?uuid=${event.uuid}`)
: '#'
"
rel="noopener ugc"
target="_blank"
:disabled="!event.externalParticipationUrl"
icon-right="OpenInNew"
>
{{ t("Go to booking") }}
</o-button>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { IEvent } from "../../types/event.model";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const props = defineProps<{
event: IEvent;
}>();
const event = computed(() => props.event);
</script>

View File

@@ -0,0 +1,502 @@
<template>
<div class="address-autocomplete">
<div class="">
<o-field
:label-for="id"
:message="fieldErrors"
:variant="fieldErrors ? 'danger' : ''"
class="!-mt-2"
:labelClass="labelClass"
>
<template #label>
{{ actualLabel }}
</template>
<o-button
v-if="canShowLocateMeButton"
class="!h-auto"
ref="mapMarker"
icon-right="map-marker"
@click="locateMe"
:title="t('Use my location')"
/>
<o-autocomplete
:data="addressData"
v-model="queryTextWithDefault"
:placeholder="placeholderWithDefault"
:customFormatter="(elem: IAddress) => addressFullName(elem)"
:debounceTyping="debounceDelay"
@typing="asyncData"
:icon="canShowLocateMeButton ? null : 'map-marker'"
expanded
@select="setSelected"
:id="id"
:disabled="disabled"
dir="auto"
class="!mt-0"
>
<template #default="{ option }">
<p class="flex gap-1">
<o-icon :icon="addressToPoiInfos(option).poiIcon.icon" />
<b>{{ addressToPoiInfos(option).name }}</b>
</p>
<small>{{ addressToPoiInfos(option).alternativeName }}</small>
</template>
<template #empty>
<template v-if="isFetching">{{ t("Searching") }}</template>
<template v-else-if="queryTextWithDefault.length >= 3">
<p>
{{
t('No results for "{queryText}"', {
queryText: queryTextWithDefault,
})
}}
</p>
<p>
{{
t(
"You can try another search term or add the address details manually below."
)
}}
</p>
</template>
</template>
</o-autocomplete>
<o-button
:disabled="!queryTextWithDefault"
@click="resetAddress"
class="reset-area !h-auto"
icon-left="close"
:title="t('Clear address field')"
/>
</o-field>
<p v-if="gettingLocation" class="flex gap-2">
<Loading class="animate-spin" />
{{ t("Getting location") }}
</p>
<div
class="mt-2 p-2 rounded-lg shadow-md bg-white dark:bg-violet-3"
v-if="!hideSelected && (selected?.originId || selected?.url)"
>
<div class="">
<address-info
:address="selected"
:show-icon="true"
:show-timezone="true"
:user-timezone="userTimezone"
/>
</div>
</div>
</div>
<o-collapse
v-model:open="detailsAddress"
:aria-id="`${id}-address-details`"
class="my-3"
v-if="allowManualDetails"
>
<template #trigger>
<o-button
variant="primary"
outlined
:aria-controls="`${id}-address-details`"
:icon-right="detailsAddress ? 'chevron-up' : 'chevron-down'"
>
{{ t("Details") }}
</o-button>
</template>
<form @submit.prevent="saveManualAddress">
<header>
<h2>{{ t("Manually enter address") }}</h2>
</header>
<section>
<o-field :label="t('Name')" labelFor="addressNameInput">
<o-input
aria-required="true"
required
v-model="selected.description"
id="addressNameInput"
/>
</o-field>
<o-field :label="t('Street')" labelFor="streetInput">
<o-input v-model="selected.street" id="streetInput" />
</o-field>
<o-field grouped>
<o-field :label="t('Postal Code')" labelFor="postalCodeInput">
<o-input v-model="selected.postalCode" id="postalCodeInput" />
</o-field>
<o-field :label="t('Locality')" labelFor="localityInput">
<o-input v-model="selected.locality" id="localityInput" />
</o-field>
</o-field>
<o-field grouped>
<o-field :label="t('Region')" labelFor="regionInput">
<o-input v-model="selected.region" id="regionInput" />
</o-field>
<o-field :label="t('Country')" labelFor="countryInput">
<o-input v-model="selected.country" id="countryInput" />
</o-field>
</o-field>
</section>
<footer class="mt-3 flex gap-2 items-center">
<o-button native-type="submit">
{{ t("Save") }}
</o-button>
<o-button outlined type="button" @click="resetAddress">
{{ t("Clear") }}
</o-button>
<p>
{{
t(
"You can drag and drop the marker below to the desired location"
)
}}
</p>
</footer>
</form>
</o-collapse>
<div
class="map"
v-if="!hideMap && !disabled && (selected.geom || detailsAddress)"
>
<map-leaflet
:coords="selected.geom ?? defaultCoords"
:marker="mapMarkerValue"
:updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }"
:readOnly="false"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { LatLng } from "leaflet";
import {
Address,
IAddress,
addressFullName,
addressToPoiInfos,
resetAddress as resetAddressAction,
} from "../../types/address.model";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import {
computed,
ref,
watch,
defineAsyncComponent,
onMounted,
reactive,
onBeforeMount,
} 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";
import { AddressSearchType } from "@/types/enums";
import Loading from "vue-material-design-icons/Loading.vue";
const MapLeaflet = defineAsyncComponent(
() => import("@/components/LeafletMap.vue")
);
const props = withDefaults(
defineProps<{
modelValue: IAddress | null;
defaultText?: string | null;
label?: string;
labelClass?: string;
userTimezone?: string;
disabled?: boolean;
hideMap?: boolean;
hideSelected?: boolean;
placeholder?: string;
resultType?: AddressSearchType;
defaultCoords?: string;
allowManualDetails?: boolean;
}>(),
{
defaultCoords: "0;0",
labelClass: "",
disabled: false,
hideMap: false,
hideSelected: false,
allowManualDetails: false,
}
);
const componentId = ref(0);
const emit = defineEmits(["update:modelValue"]);
const gettingLocationError = ref<string | null>(null);
const gettingLocation = ref(false);
const mapDefaultZoom = computed(() => {
if (selected.description) {
return 15;
}
return 5;
});
const addressData = ref<IAddress[]>([]);
const defaultAddress = new Address();
defaultAddress.geom = undefined;
defaultAddress.id = undefined;
const selected = reactive<IAddress>(defaultAddress);
const detailsAddress = ref(false);
const isFetching = ref(false);
const mapMarker = ref();
const placeholderWithDefault = computed(
() => props.placeholder ?? t("e.g. 10 Rue Jangot")
);
onBeforeMount(() => {
componentId.value += 1;
});
const id = computed((): string => {
return `full-address-autocomplete-${componentId.value}`;
});
const modelValue = computed(() => props.modelValue);
watch(modelValue, () => {
console.debug("modelValue changed");
setSelected(modelValue.value);
});
onMounted(() => {
setSelected(modelValue.value);
});
const setSelected = (newValue: IAddress | null) => {
if (!newValue) return;
console.debug("setting selected to model value");
Object.assign(selected, newValue);
emit("update:modelValue", selected);
};
const saveManualAddress = (): void => {
console.debug("saving address");
selected.id = undefined;
selected.originId = undefined;
selected.url = undefined;
emit("update:modelValue", selected);
detailsAddress.value = false;
};
const checkCurrentPosition = (e: LatLng): boolean => {
console.debug("checkCurrentPosition");
if (!selected?.geom || !e) return false;
const lat = parseFloat(selected?.geom.split(";")[1]);
const lon = parseFloat(selected?.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");
});
// 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 { load: searchAddress } = useLazyQuery<{
searchAddress: IAddress[];
}>(ADDRESS);
const asyncData = async (query: string): Promise<void> => {
console.debug("Finding addresses");
if (!query.length) {
addressData.value = [];
Object.assign(selected, defaultAddress);
return;
}
if (query.length < 3) {
addressData.value = [];
return;
}
isFetching.value = true;
try {
const result = await searchAddress(undefined, {
query,
locale: locale,
type: props.resultType,
});
if (!result) return;
console.debug("onAddressSearchResult", result.searchAddress);
addressData.value = result.searchAddress;
isFetching.value = false;
} catch (e) {
console.error(e);
return;
}
};
const selectedAddressText = computed(() => {
if (!selected) return undefined;
return addressFullName(selected);
});
const queryText = ref();
const queryTextWithDefault = computed({
get() {
return (
queryText.value ?? selectedAddressText.value ?? props.defaultText ?? ""
);
},
set(newValue: string) {
queryText.value = newValue;
},
});
const resetAddress = (): void => {
console.debug("resetting address");
emit("update:modelValue", null);
resetAddressAction(selected);
queryTextWithDefault.value = "";
};
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;
};
const { load: loadReverseGeocode } = useReverseGeocode();
const reverseGeoCode = async (e: LatLng, zoom: number) => {
console.debug("reverse geocode");
// If the details is opened, just update coords, don't reverse geocode
if (e && detailsAddress.value) {
selected.geom = `${e.lng};${e.lat}`;
console.debug("no reverse geocode, just setting new coords");
return;
}
// If the position has been updated through autocomplete selection, no need to geocode it!
if (!e || checkCurrentPosition(e)) return;
try {
const result = await loadReverseGeocode(undefined, {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: locale as unknown as string,
});
if (!result) return;
addressData.value = result.reverseGeocode;
if (addressData.value.length > 0) {
const foundAddress = addressData.value[0];
Object.assign(selected, foundAddress);
console.debug("reverse geocode succeded, setting new address");
queryTextWithDefault.value = addressFullName(foundAddress);
emit("update:modelValue", selected);
}
} catch (err) {
console.error("Failed to load reverse geocode", err);
}
};
// 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));
}
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));
}
);
});
};
const mapMarkerValue = computed(() => {
if (!selected.description) return undefined;
return {
text: [
addressToPoiInfos(selected).name,
addressToPoiInfos(selected).alternativeName,
],
icon: addressToPoiInfos(selected).poiIcon.icon,
};
});
const fieldErrors = computed(() => {
return gettingLocationError.value;
});
</script>
<style lang="scss">
.autocomplete {
.dropdown-menu {
z-index: 2000;
}
.dropdown-item.is-disabled {
opacity: 1 !important;
cursor: auto;
}
}
.read-only {
cursor: pointer;
}
.map {
height: 400px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4" v-for="key of keys" :key="key">
<h2 class="capitalize inline-block relative">
{{ monthName(groupEvents(key)[0]) }}
</h2>
<event-minimalist-card
v-for="event in groupEvents(key)"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { computed } from "vue";
import EventMinimalistCard from "./EventMinimalistCard.vue";
const props = withDefaults(
defineProps<{
events: IEvent[];
isCurrentActorMember?: boolean;
order: "ASC" | "DESC";
}>(),
{ isCurrentActorMember: false, order: "ASC" }
);
const monthlyGroupedEvents = computed((): Map<string, IEvent[]> => {
return props.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
const beginsOn = new Date(event.beginsOn);
const month = `${beginsOn.getUTCFullYear()}-${beginsOn.getUTCMonth()}`;
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) => {
const aParams = a.split("-").map((x) => parseInt(x, 10)) as [
number,
number,
];
const aDate = new Date(...aParams);
const bParams = b.split("-").map((x) => parseInt(x, 10)) as [
number,
number,
];
const bDate = new Date(...bParams);
return props.order === "DESC"
? bDate.getTime() - aDate.getTime()
: aDate.getTime() - bDate.getTime();
});
});
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 {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div class="etherpad">
<div class="etherpad-container" v-if="metadata">
<iframe
:src="`${metadata.value}?showChat=false&showLineNumbers=false`"
width="600"
height="400"
></iframe>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
defineProps<{ metadata: IEventMetadataDescription }>();
</script>
<style lang="scss" scoped>
.etherpad {
.etherpad-container {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="jitsi-meet">
<div class="jitsi-meet-video" v-if="metadata">
<iframe
allow="camera; microphone; fullscreen; display-capture; autoplay"
:src="metadata.value"
style="height: 100%; width: 100%; border: 0px"
></iframe>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
defineProps<{ metadata: IEventMetadataDescription }>();
</script>
<style lang="scss" scoped>
.jitsi-meet {
.jitsi-meet-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="peertube">
<div class="peertube-video" v-if="videoDetails">
<iframe
width="100%"
height="100%"
sandbox="allow-same-origin allow-scripts allow-popups"
:src="`https://${videoDetails.host}/videos/embed/${videoDetails.uuid}`"
frameborder="0"
allowfullscreen
></iframe>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { computed } from "vue";
const props = defineProps<{ metadata: IEventMetadataDescription }>();
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;
});
</script>
<style lang="scss" scoped>
.peertube {
.peertube-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="twitch">
<div class="twitch-video" v-if="channelName">
<iframe
:src="`https://player.twitch.tv/?channel=${channelName}&parent=${origin}&autoplay=false`"
frameborder="0"
scrolling="no"
allowfullscreen="true"
height="100%"
width="100%"
>
</iframe>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { computed } from "vue";
const props = defineProps<{ metadata: IEventMetadataDescription }>();
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;
});
const origin = computed((): string => {
return window.location.hostname;
});
</script>
<style lang="scss" scoped>
.twitch {
.twitch-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="youtube">
<div class="youtube-video" v-if="videoID">
<iframe
width="100%"
height="100%"
:src="`https://www.youtube.com/embed/${videoID}`"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</div>
</template>
<script lang="ts" setup>
import { IEventMetadataDescription } from "@/types/event-metadata";
import { computed } from "vue";
const props = defineProps<{ metadata: IEventMetadataDescription }>();
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;
});
</script>
<style lang="scss" scoped>
.youtube {
.youtube-video {
padding-top: 56.25%;
position: relative;
height: 0;
iframe {
position: absolute;
width: 100%;
height: 100%;
top: 0;
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div
class="grid auto-rows-[1fr] gap-x-2 gap-y-4 md:gap-x-6 grid-cols-[repeat(auto-fill,_minmax(250px,_1fr))] justify-items-center"
>
<event-card
class="flex flex-col h-full"
v-for="event in events"
:event="event"
:key="event.uuid"
/>
</div>
</template>
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import EventCard from "./EventCard.vue";
defineProps<{
events: IEvent[];
}>();
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="events-wrapper">
<event-minimalist-card
v-for="event in events"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
:showOrganizer="showOrganizer"
/>
</div>
</template>
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import EventMinimalistCard from "./EventMinimalistCard.vue";
withDefaults(
defineProps<{
events: IEvent[];
isCurrentActorMember?: boolean;
showOrganizer?: boolean;
}>(),
{
isCurrentActorMember: false,
showOrganizer: false,
}
);
</script>
<style lang="scss" scoped>
.events-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<Story :setup-app="setupApp">
<Variant>
<OrganizerPicker
v-model="actor"
:identities="identities"
v-model:actor-filter="actorFilter"
:groupMemberships="[]"
:current-actor="currentActor"
@update:actor-filter="logEvent('Actor Filter updated', $event)"
@update:model-value="logEvent('Selected actor updated', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import OrganizerPicker from "./OrganizerPicker.vue";
import { createMemoryHistory, createRouter } from "vue-router";
import { reactive, ref } from "vue";
import { ActorType } from "@/types/enums";
import { logEvent } from "histoire/client";
const currentActor = reactive({
id: "59",
preferredUsername: "me",
name: "Someone",
type: ActorType.PERSON,
});
const actor = reactive({
id: "5",
preferredUsername: "hello",
name: "Sigmund",
type: ActorType.PERSON,
});
const group = reactive({
id: "89",
preferredUsername: "congregation",
name: "College",
type: ActorType.GROUP,
});
const identities = [actor, group];
const actorFilter = ref("");
function setupApp({ app }) {
app.use(
createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", name: "home", component: { render: () => null } }],
})
);
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="max-w-md mx-auto">
<o-input
dir="auto"
:placeholder="t('Filter by profile or group name')"
v-model="actorFilterProxy"
class=""
/>
<transition-group
tag="ul"
class="grid grid-cols-1 gap-y-3 m-5 max-w-md mx-auto"
:class="{ hidden: actualFilteredAvailableActors.length === 0 }"
enter-active-class="duration-300 ease-out"
enter-from-class="transform opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="transform opacity-0"
>
<li
class="relative focus-within:shadow-lg"
v-for="availableActor in actualFilteredAvailableActors"
:key="availableActor?.id"
>
<input
class="sr-only peer"
type="radio"
:value="availableActor"
name="availableActors"
v-model="selectedActor"
:id="`availableActor-${availableActor?.id}`"
/>
<label
class="flex items-center gap-2 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="h-12 w-12" v-if="availableActor?.avatar">
<img
class="rounded-full h-full w-full object-cover"
:src="availableActor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<AccountCircle v-else :size="48" />
<div class="flex-1 w-px">
<h3 class="line-clamp-2">{{ availableActor?.name }}</h3>
<small class="flex truncate">{{
`@${availableActor?.preferredUsername}`
}}</small>
</div>
</label>
</li>
</transition-group>
</div>
</template>
<script lang="ts" setup>
import { IActor, IPerson } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
const props = withDefaults(
defineProps<{
currentActor: IPerson;
modelValue: IActor;
restrictModeratorLevel?: boolean;
identities: IActor[];
actorFilter: string;
groupMemberships: IMember[];
}>(),
{ restrictModeratorLevel: false }
);
const emit = defineEmits(["update:modelValue", "update:actorFilter"]);
const { t } = useI18n({ useScope: "global" });
const selectedActor = computed({
get(): IActor | undefined {
if (props.modelValue?.id) {
return props.modelValue;
}
if (props.currentActor) {
return props.identities.find(
(identity) => identity.id === props.currentActor?.id
);
}
return undefined;
},
set(actor: IActor | undefined) {
emit("update:modelValue", actor);
},
});
const actualMemberships = computed((): IMember[] => {
if (props.restrictModeratorLevel) {
return props.groupMemberships.filter((membership: IMember) =>
[
MemberRole.ADMINISTRATOR,
MemberRole.MODERATOR,
MemberRole.CREATOR,
].includes(membership.role)
);
}
return props.groupMemberships;
});
const actualAvailableActors = computed((): (IActor | undefined)[] => {
return [
props.currentActor,
...props.identities.filter(
(identity: IActor) => identity.id !== props.currentActor?.id
),
...actualMemberships.value.map((member) => member.parent),
].filter((elem) => elem);
});
const actualFilteredAvailableActors = computed((): (IActor | undefined)[] => {
return (actualAvailableActors.value ?? []).filter((actor) => {
if (actor === undefined) return false;
return [
actor.preferredUsername?.toLowerCase(),
actor.name?.toLowerCase(),
actor.domain?.toLowerCase(),
].some((match) => match?.includes(actorFilterProxy.value.toLowerCase()));
});
});
const actorFilterProxy = computed({
get() {
return props.actorFilter;
},
set(newActorFilter: string) {
emit("update:actorFilter", newActorFilter);
},
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
:deep(.list-item) {
box-sizing: content-box;
label.b-radio {
padding: 0.85rem 0;
.media {
padding: 0.25rem 0;
align-items: center;
// figure.image,
// span.icon.media-left {
// @include margin-right(0.5rem);
// }
// span.icon.media-left {
// @include margin-left(-0.25rem);
// }
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<Story :setup-app="setupApp">
<Variant>
<OrganizerPickerWrapper
v-model="actor"
@update:model-value="logEvent('Value', $event)"
@update:contacts="logEvent('Contacts', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import OrganizerPickerWrapper from "./OrganizerPickerWrapper.vue";
import { DefaultApolloClient } from "@vue/apollo-composable";
import { createMockClient } from "mock-apollo-client";
import { cache } from "@/apollo/memory";
import { ICurrentUserRole } from "@/types/enums";
import { PERSON_GROUP_MEMBERSHIPS } from "@/graphql/actor";
import { createMemoryHistory, createRouter } from "vue-router";
import { IDENTITIES } from "@/graphql/actor";
import { reactive } from "vue";
import { logEvent } from "histoire/client";
const actor = reactive({
id: "5",
preferredUsername: "hello",
name: "Sigmund",
});
function setupApp({ app }) {
const defaultResolvers = {
Query: {
currentUser: (): Record<string, any> => ({
email: "user@mail.com",
id: "2",
role: ICurrentUserRole.USER,
isLoggedIn: true,
__typename: "CurrentUser",
}),
currentActor: (): Record<string, any> => ({
id: "67",
preferredUsername: "someone",
name: "Personne",
avatar: null,
__typename: "CurrentActor",
}),
},
};
const mockClient = createMockClient({
cache,
resolvers: defaultResolvers,
});
mockClient.setRequestHandler(
PERSON_GROUP_MEMBERSHIPS,
() =>
new Promise((resolve) =>
resolve({
data: {
person: { id: "5", memberships: { total: 0, elements: [] } },
},
})
)
);
mockClient.setRequestHandler(
IDENTITIES,
() =>
new Promise((resolve) =>
resolve({
data: {
loggedUser: {
actors: [{ id: "9", preferredUsername: "sam", name: "Samuel" }],
},
},
})
)
);
app.provide(DefaultApolloClient, mockClient);
app.use(
createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", name: "home", component: { render: () => null } }],
})
);
}
</script>

View File

@@ -0,0 +1,324 @@
<template>
<div
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=""
dir="auto"
@click="isComponentModalActive = true"
>
<div class="flex gap-1 p-4">
<div class="">
<figure class="h-12 w-12" v-if="selectedActor.avatar">
<img
class="rounded-full h-full w-full object-cover"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt ?? ''"
height="48"
width="48"
/>
</figure>
<AccountCircle v-else :size="48" />
</div>
<div class="flex-1" v-if="selectedActor.name">
<p class="">{{ selectedActor.name }}</p>
<p class="">
{{ `@${selectedActor.preferredUsername}` }}
</p>
</div>
<div class="flex-1" v-else>
{{ `@${selectedActor.preferredUsername}` }}
</div>
<o-button type="text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</o-button>
</div>
</div>
<!-- If we have a current actor -->
<span
v-else-if="selectedActor.id"
class="block"
@click="isComponentModalActive = true"
>
<img
class="rounded"
v-if="selectedActor.avatar"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt"
width="48"
height="48"
/>
<AccountCircle v-else :size="48" />
</span>
<o-modal
v-model:active="isComponentModalActive"
has-modal-card
:close-button-aria-label="$t('Close')"
>
<div class="p-2 rounded">
<header class="">
<h2 class="">{{ $t("Pick a profile or a group") }}</h2>
</header>
<section class="">
<div class="flex flex-wrap gap-2 items-center">
<div class="max-h-[400px] overflow-y-auto flex-1">
<organizer-picker
v-if="currentActor"
:current-actor="currentActor"
:identities="identities ?? []"
v-model="selectedActor"
@update:model-value="relay"
:restrict-moderator-level="true"
:group-memberships="groupMemberships"
v-model:actorFilter="actorFilter"
/>
</div>
<div class="pl-2 max-h-[400px] overflow-y-auto">
<div v-if="isSelectedActorAGroup">
<p>{{ $t("Add a contact") }}</p>
<o-input
:placeholder="$t('Filter by name')"
:value="contactFilter"
@input="debounceSetFilterByName"
dir="auto"
/>
<div v-if="actorMembers.length > 0">
<p
class="field"
v-for="actor in filteredActorMembers"
:key="actor.id"
>
<o-checkbox
v-model="actualContacts"
:native-value="actor.id"
>
<div class="flex gap-1">
<div class="">
<figure class="" v-if="actor.avatar">
<img
class="rounded"
:src="actor.avatar.url"
:alt="actor.avatar.alt"
width="48"
height="48"
/>
</figure>
<AccountCircle v-else :size="48" />
</div>
<div class="" v-if="actor.name">
<p class="">{{ actor.name }}</p>
<p class="">
{{ `@${usernameWithDomain(actor)}` }}
</p>
</div>
<div class="" v-else>
{{ `@${usernameWithDomain(actor)}` }}
</div>
</div>
</o-checkbox>
</p>
</div>
<div
v-else-if="
actorMembers.length === 0 && contactFilter.length > 0
"
>
<empty-content icon="account-multiple" :inline="true">
{{ $t("No group member found") }}
</empty-content>
</div>
</div>
<div v-else class="">
<p>{{ $t("Your profile will be shown as contact.") }}</p>
</div>
</div>
</div>
</section>
<footer class="my-2">
<o-button variant="primary" @click="pickActor">
{{ $t("Pick") }}
</o-button>
</footer>
</div>
</o-modal>
</div>
</template>
<script lang="ts" setup>
import { IActor, IGroup, usernameWithDomain } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue";
import EmptyContent from "../Utils/EmptyContent.vue";
import {
LOGGED_USER_MEMBERSHIPS,
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";
import { IUser } from "@/types/current-user.model";
import { IMember } from "@/types/actor/member.model";
import { Paginate } from "@/types/paginate";
const MEMBER_ROLES = [
MemberRole.CREATOR,
MemberRole.ADMINISTRATOR,
MemberRole.MODERATOR,
MemberRole.MEMBER,
];
const { currentActor } = useCurrentActorClient();
const route = useRoute();
const { result: personMembershipsResult } = useQuery(
PERSON_GROUP_MEMBERSHIPS,
() => ({
id: currentActor.value?.id,
page: 1,
limit: 10,
groupId: route.query?.actorId,
}),
() => ({
enabled: currentActor.value?.id !== undefined,
})
);
const personMemberships = computed(
() =>
personMembershipsResult.value?.person.memberships ?? {
elements: [],
total: 0,
}
);
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 (currentActor.value) {
return (identities.value ?? []).find(
(identity) => identity.id === currentActor.value?.id
);
}
return undefined;
},
set(newSelectedActor: IActor | undefined) {
emit("update:modelValue", newSelectedActor);
},
});
const isComponentModalActive = ref(false);
const contactFilter = ref("");
const membersPage = ref(1);
const { result: membersResult } = useQuery<{ group: Pick<IGroup, "members"> }>(
GROUP_MEMBERS,
() => ({
groupName: usernameWithDomain(selectedActor.value),
page: membersPage.value,
limit: 10,
roles: MEMBER_ROLES.join(","),
name: contactFilter.value,
}),
() => ({ enabled: selectedActor.value?.type === ActorType.GROUP })
);
const members = computed<Paginate<IMember>>(() =>
selectedActor.value?.type === ActorType.GROUP
? membersResult.value?.group?.members ?? { elements: [], total: 0 }
: { elements: [], total: 0 }
);
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))
);
},
});
const setContactFilter = (newContactFilter: string) => {
contactFilter.value = newContactFilter;
};
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;
});
const actorFilter = ref("");
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 ?? []
);
</script>

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="logEvent('Join event', $event)"
@join-modal="logEvent('Join modal', $event)"
@confirm-leave="logEvent('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="
logEvent('Join Event with confirmation', $event)
"
@join-modal="logEvent('Join modal', $event)"
/>
</Variant>
<Variant title="Participating">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="participation"
:identities="identities"
@confirm-leave="logEvent('Confirm leave', $event)"
/>
</Variant>
<Variant title="Pending approval">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="{
...participation,
role: ParticipantRole.NOT_APPROVED,
}"
:identities="identities"
@confirm-leave="logEvent('Confirm leave', $event)"
/>
</Variant>
<Variant title="Rejected">
<ParticipationButton
:event="event"
:current-actor="currentActor"
:participation="{
...participation,
role: ParticipantRole.REJECTED,
}"
:identities="identities"
@confirm-leave="logEvent('Confirm leave', $event)"
/>
</Variant>
</Story>
</template>
<script lang="ts" setup>
import { IPerson } from "@/types/actor";
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import ParticipationButton from "./ParticipationButton.vue";
import { logEvent } 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

@@ -0,0 +1,200 @@
<template>
<div class="ml-auto w-min">
<o-dropdown
v-if="participation && participation.role === ParticipantRole.PARTICIPANT"
>
<template #trigger="{ active }">
<o-button
variant="success"
size="large"
icon-left="check"
:icon-right="active ? 'menu-up' : 'menu-down'"
>
{{ t("I participate") }}
</o-button>
</template>
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
@keyup.enter="confirmLeave"
class=""
>{{ t("Cancel my participation…") }}
</o-dropdown-item>
</o-dropdown>
<div
v-else-if="
participation && participation.role === ParticipantRole.NOT_APPROVED
"
class="flex flex-col"
>
<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>
<o-dropdown-item :value="false" aria-role="listitem">
{{ t("Change my identity…") }}
</o-dropdown-item>
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="confirmLeave"
@keyup.enter="confirmLeave"
class=""
>{{ t("Cancel my participation request…") }}</o-dropdown-item
>
</o-dropdown>
<p>{{ t("Participation requested!") }}</p>
<p>{{ t("Waiting for organization team approval.") }}</p>
</div>
<div
v-else-if="
participation && participation.role === ParticipantRole.REJECTED
"
>
<span>
{{
t(
"Unfortunately, your participation request was rejected by the organizers."
)
}}
</span>
</div>
<o-dropdown v-else-if="!participation && currentActor?.id">
<template #trigger="{ active }">
<o-button
variant="primary"
size="large"
:icon-right="active ? 'menu-up' : 'menu-down'"
>
{{ t("Participate") }}
</o-button>
</template>
<o-dropdown-item
:value="true"
aria-role="listitem"
@click="joinEvent(currentActor)"
@keyup.enter="joinEvent(currentActor)"
>
<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: displayName(currentActor),
})
}}
</span>
</div>
</div>
</o-dropdown-item>
<o-dropdown-item
:value="false"
aria-role="listitem"
@click="joinModal"
@keyup.enter="joinModal"
v-if="(identities ?? []).length > 1"
>{{ t("with another identity…") }}</o-dropdown-item
>
</o-dropdown>
<o-button
rel="nofollow"
tag="router-link"
:to="{
name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT,
params: { uuid: event.uuid },
}"
v-else-if="!participation && hasAnonymousParticipationMethods"
variant="primary"
size="large"
native-type="button"
>{{ t("Participate") }}</o-button
>
<o-button
tag="router-link"
rel="nofollow"
:to="{
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
params: { uuid: event.uuid },
}"
v-else-if="!currentActor?.id"
variant="primary"
size="large"
native-type="button"
>{{ t("Participate") }}</o-button
>
</div>
</template>
<script lang="ts" setup>
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEvent } from "../../types/event.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";
const props = defineProps<{
participation: IParticipant | undefined;
event: IEvent;
currentActor: IPerson | undefined;
identities: IPerson[] | undefined;
}>();
const emit = defineEmits([
"join-event-with-confirmation",
"join-event",
"join-modal",
"confirm-leave",
]);
const { t } = useI18n({ useScope: "global" });
const joinEvent = (actor: IPerson | undefined): void => {
if (props.event.joinOptions === EventJoinOptions.RESTRICTED) {
emit("join-event-with-confirmation", actor);
} else {
emit("join-event", actor);
}
};
const joinModal = (): void => {
emit("join-modal");
};
const confirmLeave = (): void => {
emit("confirm-leave");
};
const hasAnonymousParticipationMethods = computed((): boolean => {
return props.event.options.anonymousParticipation;
});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<p class="time">
{{
formatDistanceToNow(new Date(event.publishAt), {
locale: dateFnsLocale,
addSuffix: true,
}) || $t("Right now")
}}
</p>
<EventCard :event="event" />
</div>
</template>
<script lang="ts" setup>
import { IEvent } from "@/types/event.model";
import { formatDistanceToNow } from "date-fns";
import { inject } from "vue";
import EventCard from "./EventCard.vue";
import type { Locale } from "date-fns";
defineProps<{
event: IEvent;
}>();
const dateFnsLocale = inject<Locale>("dateFnsLocale");
</script>

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>

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