all developments of milestone 1
This commit is contained in:
@@ -168,7 +168,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: IComment;
|
||||
currentActor: IPerson;
|
||||
canReport: boolean;
|
||||
canReport?: boolean;
|
||||
}>(),
|
||||
{ canReport: false }
|
||||
);
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
<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>
|
||||
{{
|
||||
@@ -108,7 +93,7 @@ import { IAnalyticsConfig, IConfig } from "@/types/config.model";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useQuery, useQueryLoading } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { useAnalytics } from "@/composition/apollo/config";
|
||||
import { INSTANCE_NAME } from "@/graphql/config";
|
||||
const SentryFeedback = defineAsyncComponent(
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
:class="{ small }"
|
||||
:style="`--small: ${smallStyle}`"
|
||||
>
|
||||
<div class="datetime-container-header" />
|
||||
<div class="datetime-container-header">
|
||||
<time :datetime="dateObj.toISOString()" class="weekday">{{
|
||||
weekday
|
||||
}}</time>
|
||||
</div>
|
||||
<div class="datetime-container-content">
|
||||
<time :datetime="dateObj.toISOString()" class="day block font-semibold">{{
|
||||
day
|
||||
@@ -38,6 +42,10 @@ const day = computed<string>(() =>
|
||||
dateObj.value.toLocaleString(undefined, { day: "numeric" })
|
||||
);
|
||||
|
||||
const weekday = computed<string>(() =>
|
||||
dateObj.value.toLocaleString(undefined, { weekday: "short" })
|
||||
);
|
||||
|
||||
const smallStyle = computed<string>(() => (props.small ? "1.2" : "2"));
|
||||
</script>
|
||||
|
||||
@@ -51,6 +59,12 @@ div.datetime-container {
|
||||
height: calc(10px * var(--small));
|
||||
background: #f3425f;
|
||||
}
|
||||
.datetime-container-header .weekday {
|
||||
font-size: calc(9px * var(--small));
|
||||
font-weight: bold;
|
||||
vertical-align: top;
|
||||
line-height: calc(9px * var(--small));
|
||||
}
|
||||
.datetime-container-content {
|
||||
height: calc(30px * var(--small));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<p
|
||||
class="inline-flex gap-2 ml-auto"
|
||||
v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
|
||||
v-if="
|
||||
event.joinOptions !== EventJoinOptions.EXTERNAL &&
|
||||
!event.options.hideNumberOfParticipants
|
||||
"
|
||||
>
|
||||
<TicketConfirmationOutline />
|
||||
<router-link
|
||||
|
||||
@@ -12,6 +12,31 @@
|
||||
class="rounded-lg"
|
||||
:class="{ 'sm:w-full sm:max-w-[20rem]': mode === 'row' }"
|
||||
>
|
||||
<div
|
||||
class="-mt-3 h-0 mb-3 ltr:ml-0 rtl:mr-0 block relative z-10"
|
||||
:class="{
|
||||
'sm:hidden': mode === 'row',
|
||||
'calendar-simple': !isDifferentBeginsEndsDate,
|
||||
'calendar-double': isDifferentBeginsEndsDate,
|
||||
}"
|
||||
>
|
||||
<date-calendar-icon
|
||||
:small="true"
|
||||
v-if="!mergedOptions.hideDate"
|
||||
:date="event.beginsOn.toString()"
|
||||
/>
|
||||
<MenuDown
|
||||
:small="true"
|
||||
class="left-3 relative"
|
||||
v-if="!mergedOptions.hideDate && isDifferentBeginsEndsDate"
|
||||
/>
|
||||
<date-calendar-icon
|
||||
:small="true"
|
||||
v-if="!mergedOptions.hideDate && isDifferentBeginsEndsDate"
|
||||
:date="event.endsOn?.toString()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<figure class="block relative pt-40">
|
||||
<lazy-image-wrapper
|
||||
:picture="event.picture"
|
||||
@@ -49,20 +74,29 @@
|
||||
<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="-mt-3 h-0 flex mb-3 ltr:ml-0 rtl:mr-0 items-end self-end"
|
||||
:class="{ 'sm:hidden': mode === 'row' }"
|
||||
>
|
||||
<date-calendar-icon
|
||||
<start-time-icon
|
||||
:small="true"
|
||||
v-if="!mergedOptions.hideDate"
|
||||
v-if="!mergedOptions.hideDate && event.options.showStartTime"
|
||||
:date="event.beginsOn.toString()"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-gray-700 dark:text-white font-semibold hidden"
|
||||
:class="{ 'sm:block': mode === 'row' }"
|
||||
v-if="!isDifferentBeginsEndsDate"
|
||||
>{{ formatDateTimeWithCurrentLocale }}</span
|
||||
>
|
||||
<span
|
||||
class="text-gray-700 dark:text-white font-semibold hidden"
|
||||
:class="{ 'sm:block': mode === 'row' }"
|
||||
v-if="isDifferentBeginsEndsDate"
|
||||
>{{ formatBeginsOnDateWithCurrentLocale }}
|
||||
<ArrowRightThin :small="true" style="display: ruby" />
|
||||
{{ formatEndsOnDateWithCurrentLocale }}</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"
|
||||
@@ -152,6 +186,16 @@
|
||||
</div>
|
||||
</LinkOrRouterLink>
|
||||
</template>
|
||||
<style scoped>
|
||||
.calendar-simple {
|
||||
bottom: -117px;
|
||||
left: 5px;
|
||||
}
|
||||
.calendar-double {
|
||||
bottom: -45px;
|
||||
left: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
@@ -161,6 +205,9 @@ import {
|
||||
organizerAvatarUrl,
|
||||
} from "@/types/event.model";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import StartTimeIcon from "@/components/Event/StartTimeIcon.vue";
|
||||
import ArrowRightThin from "vue-material-design-icons/ArrowRightThin.vue";
|
||||
import MenuDown from "vue-material-design-icons/MenuDown.vue";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import { EventStatus } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
@@ -170,7 +217,7 @@ 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 { formatDateForEvent, formatDateTimeForEvent } from "@/utils/datetime";
|
||||
import type { Locale } from "date-fns";
|
||||
import LinkOrRouterLink from "../core/LinkOrRouterLink.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -212,6 +259,28 @@ const actorAvatarURL = computed<string | null>(() =>
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
|
||||
const isDifferentBeginsEndsDate = computed(() => {
|
||||
if (!dateFnsLocale) return;
|
||||
const beginsOnStr = formatDateForEvent(
|
||||
new Date(props.event.beginsOn),
|
||||
dateFnsLocale
|
||||
);
|
||||
const endsOnStr = props.event.endsOn
|
||||
? formatDateForEvent(new Date(props.event.endsOn), dateFnsLocale)
|
||||
: null;
|
||||
return endsOnStr && endsOnStr != beginsOnStr;
|
||||
});
|
||||
|
||||
const formatBeginsOnDateWithCurrentLocale = computed(() => {
|
||||
if (!dateFnsLocale) return;
|
||||
return formatDateForEvent(new Date(props.event.beginsOn), dateFnsLocale);
|
||||
});
|
||||
|
||||
const formatEndsOnDateWithCurrentLocale = computed(() => {
|
||||
if (!dateFnsLocale) return;
|
||||
return formatDateForEvent(new Date(props.event.endsOn), dateFnsLocale);
|
||||
});
|
||||
|
||||
const formatDateTimeWithCurrentLocale = computed(() => {
|
||||
if (!dateFnsLocale) return;
|
||||
return formatDateTimeForEvent(new Date(props.event.beginsOn), dateFnsLocale);
|
||||
|
||||
51
src/components/Event/StartTimeIcon.vue
Normal file
51
src/components/Event/StartTimeIcon.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
class="starttime-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="starttime-container-content font-semibold">
|
||||
<Clock class="clock-icon" /><time :datetime="dateObj.toISOString()">{{
|
||||
time
|
||||
}}</time>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import Clock from "vue-material-design-icons/ClockTimeTenOutline.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
small?: boolean;
|
||||
}>(),
|
||||
{ small: false }
|
||||
);
|
||||
|
||||
const dateObj = computed<Date>(() => new Date(props.date));
|
||||
|
||||
const time = computed<string>(() =>
|
||||
dateObj.value.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
|
||||
const smallStyle = computed<string>(() => (props.small ? "0.9" : "2"));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.starttime-container {
|
||||
width: auto;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.25rem 0.25rem;
|
||||
font-size: calc(1rem * var(--small));
|
||||
}
|
||||
|
||||
.clock-icon {
|
||||
vertical-align: middle;
|
||||
padding-right: 0.2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,8 @@
|
||||
</p>
|
||||
</template>
|
||||
<o-taginput
|
||||
v-model="tagsStrings"
|
||||
:modelValue="tagsStrings"
|
||||
@update:modelValue="updateTags"
|
||||
:data="filteredTags"
|
||||
:allow-autocomplete="true"
|
||||
:allow-new="true"
|
||||
@@ -34,7 +35,7 @@
|
||||
<script lang="ts" setup>
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import { ITag } from "../../types/tag.model";
|
||||
import { computed, onBeforeMount, ref } from "vue";
|
||||
import { computed, onBeforeMount, ref, watch } from "vue";
|
||||
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
|
||||
import { useFetchTags } from "@/composition/apollo/tags";
|
||||
import { FILTER_TAGS } from "@/graphql/tags";
|
||||
@@ -44,6 +45,10 @@ const props = defineProps<{
|
||||
modelValue: ITag[];
|
||||
}>();
|
||||
|
||||
const propsValue = computed(() => props.modelValue);
|
||||
|
||||
const tagsStrings = ref<string[]>([]);
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const text = ref("");
|
||||
@@ -77,7 +82,7 @@ const getFilteredTags = async (newText: string): Promise<void> => {
|
||||
};
|
||||
|
||||
const filteredTags = computed((): ITag[] => {
|
||||
return differenceBy(tags.value, props.modelValue, "id").filter(
|
||||
return differenceBy(tags.value, propsValue.value, "id").filter(
|
||||
(option) =>
|
||||
option.title.toString().toLowerCase().indexOf(text.value.toLowerCase()) >=
|
||||
0 ||
|
||||
@@ -86,19 +91,19 @@ const filteredTags = computed((): ITag[] => {
|
||||
);
|
||||
});
|
||||
|
||||
const tagsStrings = computed({
|
||||
get(): string[] {
|
||||
return props.modelValue.map((tag: ITag) => tag.title);
|
||||
},
|
||||
set(newTagsStrings: string[]) {
|
||||
console.debug("tagsStrings", newTagsStrings);
|
||||
const tagEntities = newTagsStrings.map((tag: string | ITag) => {
|
||||
if (typeof tag !== "string") {
|
||||
return tag;
|
||||
}
|
||||
return { title: tag, slug: tag } as ITag;
|
||||
});
|
||||
emit("update:modelValue", tagEntities);
|
||||
},
|
||||
watch(props.modelValue, (newValue, oldValue) => {
|
||||
if (newValue != oldValue) {
|
||||
tagsStrings.value = propsValue.value.map((tag: ITag) => tag.title);
|
||||
}
|
||||
});
|
||||
|
||||
const updateTags = (newTagsStrings: string[]) => {
|
||||
const tagEntities = newTagsStrings.map((tag: string | ITag) => {
|
||||
if (typeof tag !== "string") {
|
||||
return tag;
|
||||
}
|
||||
return { title: tag, slug: tag } as ITag;
|
||||
});
|
||||
emit("update:modelValue", tagEntities);
|
||||
};
|
||||
</script>
|
||||
|
||||
228
src/components/FullCalendar/EventsAgenda.vue
Normal file
228
src/components/FullCalendar/EventsAgenda.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<FullCalendar
|
||||
ref="calendarRef"
|
||||
:options="calendarOptions"
|
||||
class="agenda-view"
|
||||
/>
|
||||
|
||||
<div v-if="listOfEventsByDate.date" class="my-4">
|
||||
<b v-text="formatDateString(listOfEventsByDate.date)" />
|
||||
|
||||
<div v-if="listOfEventsByDate.events.length > 0">
|
||||
<div
|
||||
v-for="(event, index) in listOfEventsByDate.events"
|
||||
v-bind:key="index"
|
||||
>
|
||||
<div class="scroll-ml-6 snap-center shrink-0 my-4">
|
||||
<EventCard :event="event.event.extendedProps.event" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyContent v-else icon="calendar" :inline="true">
|
||||
<span>
|
||||
{{ t("No events found") }}
|
||||
</span>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { locale } from "@/utils/i18n";
|
||||
import { computed, ref } from "vue";
|
||||
import { useLazyQuery } from "@vue/apollo-composable";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { SEARCH_CALENDAR_EVENTS } from "@/graphql/search";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import { EventSegment } from "@fullcalendar/core";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import {
|
||||
formatDateISOStringWithoutTime,
|
||||
formatDateString,
|
||||
} from "@/filters/datetime";
|
||||
import EventCard from "../Event/EventCard.vue";
|
||||
import EmptyContent from "../Utils/EmptyContent.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const calendarRef = ref();
|
||||
|
||||
const lastSelectedDate = ref<string | undefined>(new Date().toISOString());
|
||||
|
||||
const listOfEventsByDate = ref<{ events: EventSegment[]; date?: string }>({
|
||||
events: [],
|
||||
date: undefined,
|
||||
});
|
||||
|
||||
const showEventsByDate = (dateStr: string) => {
|
||||
dateStr = formatDateISOStringWithoutTime(dateStr);
|
||||
const moreLinkElement = document.querySelectorAll(
|
||||
`td[data-date='${dateStr}'] a.fc-more-link`
|
||||
)[0] as undefined | HTMLElement;
|
||||
|
||||
if (moreLinkElement) {
|
||||
moreLinkElement.click();
|
||||
} else {
|
||||
listOfEventsByDate.value = {
|
||||
events: [],
|
||||
date: dateStr,
|
||||
};
|
||||
}
|
||||
|
||||
calendarRef.value.getApi().select(dateStr);
|
||||
};
|
||||
|
||||
if (window.location.hash.length) {
|
||||
lastSelectedDate.value = formatDateISOStringWithoutTime(
|
||||
window.location.hash.replace("#_", "")
|
||||
);
|
||||
} else {
|
||||
lastSelectedDate.value = formatDateISOStringWithoutTime(
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
const { load: searchEventsLoad, refetch: searchEventsRefetch } = useLazyQuery<{
|
||||
searchEvents: Paginate<IEvent>;
|
||||
}>(SEARCH_CALENDAR_EVENTS);
|
||||
|
||||
const calendarOptions = computed((): object => {
|
||||
return {
|
||||
plugins: [dayGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
initialDate: lastSelectedDate.value,
|
||||
events: async (
|
||||
info: { start: Date; end: Date; startStr: string; endStr: string },
|
||||
successCallback: (arg: object[]) => unknown,
|
||||
failureCallback: (err: string) => unknown
|
||||
) => {
|
||||
const queryVars = {
|
||||
limit: 999,
|
||||
beginsOn: info.start,
|
||||
endsOn: info.end,
|
||||
};
|
||||
|
||||
const result =
|
||||
(await searchEventsLoad(undefined, queryVars)) ||
|
||||
(await searchEventsRefetch(queryVars))?.data;
|
||||
|
||||
if (!result) {
|
||||
failureCallback("failed to fetch calendar events");
|
||||
return;
|
||||
}
|
||||
|
||||
successCallback(
|
||||
(result.searchEvents.elements ?? []).map((event: IEvent) => {
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.beginsOn,
|
||||
end: event.endsOn,
|
||||
startStr: event.beginsOn,
|
||||
endStr: event.endsOn,
|
||||
url: event.url,
|
||||
extendedProps: {
|
||||
event: event,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
nextDayThreshold: "09:00:00",
|
||||
dayMaxEventRows: 0,
|
||||
moreLinkClassNames: "bg-mbz-yellow dark:bg-mbz-purple dark:text-white",
|
||||
moreLinkContent: (arg: { num: number; text: string }) => {
|
||||
return "+" + arg.num.toString();
|
||||
},
|
||||
contentHeight: "auto",
|
||||
eventClassNames: "line-clamp-3 bg-mbz-yellow dark:bg-mbz-purple",
|
||||
headerToolbar: {
|
||||
left: "prev,next,customTodayButton",
|
||||
center: "",
|
||||
right: "title",
|
||||
},
|
||||
locale: locale,
|
||||
firstDay: 1,
|
||||
buttonText: {
|
||||
today: t("Today"),
|
||||
month: t("Month"),
|
||||
week: t("Week"),
|
||||
day: t("Day"),
|
||||
list: t("List"),
|
||||
},
|
||||
customButtons: {
|
||||
customTodayButton: {
|
||||
text: t("Today"),
|
||||
click: () => {
|
||||
calendarRef.value.getApi().today();
|
||||
lastSelectedDate.value = formatDateISOStringWithoutTime(
|
||||
new Date().toISOString()
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
dateClick: (info: { dateStr: string }) => {
|
||||
showEventsByDate(info.dateStr);
|
||||
},
|
||||
moreLinkClick: (info: {
|
||||
date: Date;
|
||||
allSegs: EventSegment[];
|
||||
hiddenSegs: EventSegment[];
|
||||
jsEvent: object;
|
||||
}) => {
|
||||
listOfEventsByDate.value = {
|
||||
events: info.allSegs,
|
||||
date: info.date.toISOString(),
|
||||
};
|
||||
|
||||
if (info.allSegs.length) {
|
||||
window.location.hash =
|
||||
"_" + formatDateISOStringWithoutTime(info.date.toISOString());
|
||||
}
|
||||
|
||||
return "none";
|
||||
},
|
||||
moreLinkDidMount: (arg: { el: Element }) => {
|
||||
if (
|
||||
lastSelectedDate.value &&
|
||||
arg.el.closest(`td[data-date='${lastSelectedDate.value}']`)
|
||||
) {
|
||||
showEventsByDate(lastSelectedDate.value);
|
||||
lastSelectedDate.value = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.agenda-view .fc-button {
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.agenda-view .fc-toolbar-title {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
.agenda-view .fc-daygrid-day-events {
|
||||
min-height: 1.1rem !important;
|
||||
margin-bottom: 0.2rem !important;
|
||||
margin-left: 0.1rem !important;
|
||||
}
|
||||
|
||||
.agenda-view .fc-more-link {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.clock-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.95rem !important;
|
||||
}
|
||||
</style>
|
||||
104
src/components/FullCalendar/EventsCalendar.vue
Normal file
104
src/components/FullCalendar/EventsCalendar.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions">
|
||||
<template v-slot:eventContent="arg">
|
||||
<span
|
||||
class="text-violet-3 dark:text-white font-bold m-2"
|
||||
:title="arg.event.title"
|
||||
>
|
||||
{{ arg.event.title }}
|
||||
</span>
|
||||
</template>
|
||||
</FullCalendar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { locale } from "@/utils/i18n";
|
||||
import { computed, ref } from "vue";
|
||||
import { useLazyQuery } from "@vue/apollo-composable";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { SEARCH_CALENDAR_EVENTS } from "@/graphql/search";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
|
||||
const calendarRef = ref();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { load: searchEventsLoad, refetch: searchEventsRefetch } = useLazyQuery<{
|
||||
searchEvents: Paginate<IEvent>;
|
||||
}>(SEARCH_CALENDAR_EVENTS);
|
||||
|
||||
const calendarOptions = computed((): object => {
|
||||
return {
|
||||
plugins: [dayGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
events: async (
|
||||
info: { start: Date; end: Date; startStr: string; endStr: string },
|
||||
successCallback: (arg: object[]) => unknown,
|
||||
failureCallback: (err: string) => unknown
|
||||
) => {
|
||||
const queryVars = {
|
||||
limit: 999,
|
||||
beginsOn: info.start,
|
||||
endsOn: info.end,
|
||||
};
|
||||
|
||||
const result =
|
||||
(await searchEventsLoad(undefined, queryVars)) ||
|
||||
(await searchEventsRefetch(queryVars))?.data;
|
||||
|
||||
if (!result) {
|
||||
failureCallback("failed to fetch calendar events");
|
||||
return;
|
||||
}
|
||||
|
||||
successCallback(
|
||||
(result.searchEvents.elements ?? []).map((event: IEvent) => {
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.beginsOn,
|
||||
end: event.endsOn,
|
||||
startStr: event.beginsOn,
|
||||
endStr: event.endsOn,
|
||||
url: `/events/${event.uuid}`,
|
||||
extendedProps: {
|
||||
event: event,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
nextDayThreshold: "09:00:00",
|
||||
dayMaxEventRows: 5,
|
||||
moreLinkClassNames: "bg-mbz-yellow dark:bg-mbz-purple dark:text-white p-2",
|
||||
moreLinkContent: (arg: { num: number; text: string }) => {
|
||||
return "+" + arg.num.toString();
|
||||
},
|
||||
eventClassNames: "line-clamp-3 bg-mbz-yellow dark:bg-mbz-purple",
|
||||
headerToolbar: {
|
||||
left: "prev,next,today",
|
||||
center: "title",
|
||||
right: "dayGridWeek,dayGridMonth", // user can switch between the two
|
||||
},
|
||||
locale: locale,
|
||||
firstDay: 1,
|
||||
buttonText: {
|
||||
today: t("Today"),
|
||||
month: t("Month"),
|
||||
week: t("Week"),
|
||||
day: t("Day"),
|
||||
list: t("List"),
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fc-popover-header {
|
||||
color: black !important;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ import { useGroup } from "@/composition/apollo/group";
|
||||
import { displayName } from "@/types/actor";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
|
||||
const props = defineProps<{
|
||||
preferredUsername: string;
|
||||
|
||||
@@ -3,42 +3,12 @@
|
||||
<h1 class="dark:text-white font-bold">
|
||||
{{ config.slogan ?? t("Gather ⋅ Organize ⋅ Mobilize") }}
|
||||
</h1>
|
||||
<i18n-t
|
||||
keypath="Join {instance}, a Mobilizon instance"
|
||||
tag="p"
|
||||
class="dark:text-white"
|
||||
>
|
||||
<template #instance>
|
||||
<b>{{ config.name }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
|
||||
<p class="dark:text-white mb-2">{{ config.description }}</p>
|
||||
<!-- We don't invite to find other instances yet -->
|
||||
<!-- <p v-if="!config.registrationsOpen">
|
||||
{{ t("This instance isn't opened to registrations, but you can register on other instances.") }}
|
||||
</p>-->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<o-button
|
||||
variant="primary"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.REGISTER }"
|
||||
v-if="config.registrationsOpen"
|
||||
>{{ t("Create an account") }}</o-button
|
||||
>
|
||||
<!-- We don't invite to find other instances yet -->
|
||||
<!-- <o-button v-else variant="link" tag="a" href="https://joinmastodon.org">{{ t('Find an instance') }}</o-button> -->
|
||||
<router-link
|
||||
:to="{ name: RouteName.ABOUT }"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-violet-title focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
{{ t("Learn more about {instance}", { instance: config.name }) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import RouteName from "@/router/name";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import { useDefaultPicture } from "@/composition/apollo/config";
|
||||
import LazyImage from "../Image/LazyImage.vue";
|
||||
|
||||
const { defaultPicture } = useDefaultPicture();
|
||||
|
||||
const DEFAULT_CARD_URL = "/img/mobilizon_default_card.png";
|
||||
const DEFAULT_BLURHASH = "MCHKI4El-P-U}+={R-WWoes,Iu-P=?R,xD";
|
||||
const DEFAULT_WIDTH = 630;
|
||||
@@ -38,6 +41,9 @@ const props = withDefaults(
|
||||
|
||||
const pictureOrDefault = computed(() => {
|
||||
if (props.picture === null) {
|
||||
if (defaultPicture?.value?.url) {
|
||||
return defaultPicture.value;
|
||||
}
|
||||
return DEFAULT_PICTURE;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -6,18 +6,7 @@
|
||||
v-on="attrs"
|
||||
>
|
||||
<template #title>
|
||||
{{ t("Last published events") }}
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<i18n-t
|
||||
class="text-slate-700 dark:text-slate-300"
|
||||
tag="p"
|
||||
keypath="On {instance} and other federated instances"
|
||||
>
|
||||
<template #instance>
|
||||
<b>{{ instanceName }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
{{ t("Agenda") }}
|
||||
</template>
|
||||
<template #content>
|
||||
<skeleton-event-result
|
||||
@@ -69,8 +58,8 @@ const attrs = useAttrs();
|
||||
const { result: resultEvents, loading: loadingEvents } = useQuery<{
|
||||
events: Paginate<IEvent>;
|
||||
}>(FETCH_EVENTS, {
|
||||
orderBy: EventSortField.INSERTED_AT,
|
||||
direction: SortDirection.DESC,
|
||||
orderBy: EventSortField.BEGINS_ON,
|
||||
direction: SortDirection.ASC,
|
||||
});
|
||||
const events = computed(
|
||||
() => resultEvents.value?.events ?? { total: 0, elements: [] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
class="bg-white dark:bg-zinc-900 dark:fill-white"
|
||||
v-if="!instanceLogoUrl"
|
||||
class="bg-white dark:bg-zinc-900 dark:fill-white max-h-12"
|
||||
:class="{ 'bg-gray-900': invert }"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 248.16 46.78"
|
||||
@@ -30,9 +31,14 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<img v-else alt="" class="max-h-12 w-auto" :src="instanceLogoUrl" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useInstanceLogoUrl } from "@/composition/apollo/config";
|
||||
|
||||
const { instanceLogoUrl } = useInstanceLogoUrl();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
invert?: boolean;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-900"
|
||||
id="navbar"
|
||||
>
|
||||
<div
|
||||
class="container mx-auto flex flex-wrap items-center mx-auto gap-2 sm:gap-4"
|
||||
>
|
||||
<div class="container mx-auto flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
<router-link
|
||||
:to="{ name: RouteName.HOME }"
|
||||
class="flex items-center"
|
||||
@@ -181,34 +179,92 @@
|
||||
<ul
|
||||
class="flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:font-lightbold"
|
||||
>
|
||||
<li v-if="currentActor?.id">
|
||||
<search-fields
|
||||
v-if="showMobileMenu"
|
||||
class="m-auto w-auto"
|
||||
v-model:search="search"
|
||||
v-model:location="location"
|
||||
/>
|
||||
|
||||
<li class="m-auto" v-if="islongEvents">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.SEARCH,
|
||||
query: { contentType: 'SHORTEVENTS' },
|
||||
}"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Events") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="m-auto" v-else>
|
||||
<router-link
|
||||
:to="{ name: RouteName.SEARCH, query: { contentType: 'EVENTS' } }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Events") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="m-auto" v-if="islongEvents">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.SEARCH,
|
||||
query: { contentType: 'LONGEVENTS' },
|
||||
}"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Activities") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="m-auto">
|
||||
<router-link
|
||||
:to="{ name: RouteName.SEARCH, query: { contentType: 'GROUPS' } }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Groups") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="m-auto">
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT_CALENDAR }"
|
||||
class="block relative py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Calendar")
|
||||
}}<span class="absolute right-0 text-sm"
|
||||
><br />(beta)</span
|
||||
></router-link
|
||||
>
|
||||
</li>
|
||||
<li class="m-auto" v-if="currentActor?.id">
|
||||
<router-link
|
||||
:to="{ name: RouteName.MY_EVENTS }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("My events") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li v-if="currentActor?.id">
|
||||
<li class="m-auto" v-if="currentActor?.id">
|
||||
<router-link
|
||||
:to="{ name: RouteName.MY_GROUPS }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("My groups") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li v-if="!currentActor?.id">
|
||||
<li class="m-auto" v-if="!currentActor?.id">
|
||||
<router-link
|
||||
:to="{ name: RouteName.LOGIN }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Login") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li v-if="!currentActor?.id && canRegister">
|
||||
<li class="m-auto" v-if="!currentActor?.id && canRegister">
|
||||
<router-link
|
||||
:to="{ name: RouteName.REGISTER }"
|
||||
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>{{ t("Register") }}</router-link
|
||||
>
|
||||
</li>
|
||||
|
||||
<search-fields
|
||||
v-if="!showMobileMenu"
|
||||
class="m-auto w-auto"
|
||||
v-model:search="search"
|
||||
v-model:location="location"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +275,7 @@
|
||||
import MobilizonLogo from "@/components/MobilizonLogo.vue";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { logout } from "../utils/auth";
|
||||
import { IPerson, displayName } from "../types/actor";
|
||||
import { displayName } from "../types/actor";
|
||||
import RouteName from "../router/name";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
@@ -234,7 +290,10 @@ import {
|
||||
import { useLazyQuery, useMutation } from "@vue/apollo-composable";
|
||||
import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
|
||||
import { changeIdentity } from "@/utils/identity";
|
||||
import { useRegistrationConfig } from "@/composition/apollo/config";
|
||||
import {
|
||||
useRegistrationConfig,
|
||||
useIsLongEvents,
|
||||
} from "@/composition/apollo/config";
|
||||
import { useOruga } from "@oruga-ui/oruga-next";
|
||||
import {
|
||||
UNREAD_ACTOR_CONVERSATIONS,
|
||||
@@ -242,6 +301,8 @@ import {
|
||||
} from "@/graphql/user";
|
||||
import { ICurrentUser } from "@/types/current-user.model";
|
||||
|
||||
const { islongEvents } = useIsLongEvents();
|
||||
|
||||
const { currentUser } = useCurrentUserClient();
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
<template>
|
||||
<footer
|
||||
class="bg-violet-2 color-secondary flex flex-col items-center py-2 px-3"
|
||||
class="bg-violet-2 color-secondary flex flex-col items-center py-3 px-3"
|
||||
ref="footer"
|
||||
>
|
||||
<picture class="flex max-w-xl">
|
||||
<source
|
||||
:srcset="`/img/pics/footer_${random}-1024w.webp 1x, /img/pics/footer_${random}-1920w.webp 2x`"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
:src="`/img/pics/footer_${random}-1024w.webp`"
|
||||
alt=""
|
||||
width="1024"
|
||||
height="428"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
<ul
|
||||
class="inline-flex flex-wrap justify-around gap-3 text-lg text-white underline decoration-yellow-1"
|
||||
>
|
||||
@@ -92,15 +79,11 @@ import { saveLocaleData } from "@/utils/auth";
|
||||
import { loadLanguageAsync } from "@/utils/i18n";
|
||||
import RouteName from "../router/name";
|
||||
import langs from "../i18n/langs.json";
|
||||
import { computed, watch } from "vue";
|
||||
import { watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { locale, t } = useI18n({ useScope: "global" });
|
||||
|
||||
const random = computed((): number => {
|
||||
return Math.floor(Math.random() * 4) + 1;
|
||||
});
|
||||
|
||||
watch(locale, async () => {
|
||||
if (locale) {
|
||||
console.debug("Setting locale from footer");
|
||||
@@ -113,3 +96,9 @@ const isLangSelected = (lang: string): boolean => {
|
||||
return lang === locale.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
footer > ul > li {
|
||||
margin: auto 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,7 +45,7 @@ import { LEAVE_EVENT } from "../../graphql/event";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { IActor } from "@/types/actor";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { useAnonymousActorId } from "@/composition/apollo/config";
|
||||
|
||||
@@ -70,7 +70,7 @@ import { CONFIRM_PARTICIPATION } from "../../graphql/event";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<script lang="ts" setup>
|
||||
import RedirectWithAccount from "@/components/Utils/RedirectWithAccount.vue";
|
||||
import { useFetchEvent } from "@/composition/apollo/event";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ import { useFetchEventBasic } from "@/composition/apollo/event";
|
||||
import { useAnonymousActorId } from "@/composition/apollo/config";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
|
||||
const error = ref<boolean | string>(false);
|
||||
|
||||
@@ -99,7 +99,7 @@ import { useFetchEvent } from "@/composition/apollo/event";
|
||||
import { useAnonymousParticipationConfig } from "@/composition/apollo/config";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHead } from "@unhead/vue";
|
||||
import { useHead } from "@/utils/head";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ uuid: string }>();
|
||||
|
||||
@@ -173,6 +173,8 @@ const icons: Record<string, () => Promise<any>> = {
|
||||
import(
|
||||
`../../../node_modules/vue-material-design-icons/CalendarRemove.vue`
|
||||
),
|
||||
CalendarStar: () =>
|
||||
import(`../../../node_modules/vue-material-design-icons/CalendarStar.vue`),
|
||||
FileDocumentEdit: () =>
|
||||
import(
|
||||
`../../../node_modules/vue-material-design-icons/FileDocumentEdit.vue`
|
||||
|
||||
Reference in New Issue
Block a user