merge-upstream-5.0.1 #66

Merged
778a69cd merged 80 commits from merge-upstream-5.0.1 into main 2024-12-26 12:55:41 +01:00
68 changed files with 1562 additions and 1662 deletions

View file

@ -4,7 +4,6 @@ stages:
- install
- check
- build-js
- sentry
- test
- build
- upload
@ -93,21 +92,6 @@ build-frontend:
needs:
- lint-front
sentry-commit:
stage: sentry
image: getsentry/sentry-cli
script:
- echo "Create a new release $CI_COMMIT_TAG"
- sentry-cli releases new $CI_COMMIT_TAG
- sentry-cli releases set-commits $CI_COMMIT_TAG --auto
- sentry-cli releases files $CI_COMMIT_TAG upload-sourcemaps priv/static/assets/
- sentry-cli releases finalize $CI_COMMIT_TAG
- echo "Finalized release for $CI_COMMIT_TAG"
needs:
- build-frontend
only:
- tags@framasoft/mobilizon
deps:
stage: check
before_script:
@ -162,6 +146,8 @@ vitest:
e2e:
stage: test
except:
- tags@framasoft/mobilizon
services:
- name: postgis/postgis:16-3.4
alias: postgres

View file

@ -17,7 +17,7 @@ mixRelease rec {
# This has to be kept in sync with the version in mix.exs and package.json!
# Otherwise the nginx routing isn't going to work properly.
version = "5.0.0-beta.1";
version = "5.1.0";
inherit src;

View file

@ -28,6 +28,8 @@ in
};
};
systemd.services.mobilizon-postgresql.serviceConfig.Restart = "on-failure";
services.postgresql.package = pkgs.postgresql_14;
security.pki.certificateFiles = [ certs.ca.cert ];

View file

@ -57,7 +57,8 @@ defmodule Mobilizon.GraphQL.API.Search do
current_actor_id: Map.get(args, :current_actor_id),
exclude_my_groups: Map.get(args, :exclude_my_groups, false),
exclude_stale_actors: true,
local_only: Map.get(args, :search_target, :internal) == :self
local_only: Map.get(args, :search_target, :internal) == :self,
sort_by: Map.get(args, :sort_by)
],
page,
limit

View file

@ -76,7 +76,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
limit: limit,
order_by: order_by,
direction: direction,
longevents: longevents,
long_events: long_events,
location: location,
radius: radius
},
@ -84,7 +84,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
)
when limit < @event_max_limit do
{:ok,
Events.list_events(page, limit, order_by, direction, true, longevents, location, radius)}
Events.list_events(page, limit, order_by, direction, true, long_events, location, radius)}
end
def list_events(

View file

@ -32,6 +32,11 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:description, :string, description: "The event's description")
field(:begins_on, :datetime, description: "Datetime for when the event begins")
field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:long_event, :boolean,
description: "Whether the event is a long event (activity) or not"
)
field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility")
@ -395,7 +400,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "Direction for the sort"
)
arg(:longevents, :boolean,
arg(:long_events, :boolean,
default_value: nil,
description: "if mention filter in or out long events"
)

View file

@ -17,6 +17,11 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
field(:title, :string, description: "The event's title")
field(:begins_on, :datetime, description: "Datetime for when the event begins")
field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:long_event, :boolean,
description: "Whether the event is a long event (activity) or not"
)
field(:status, :event_status, description: "Status of the event")
field(:picture, :media, description: "The event's picture")
field(:physical_address, :address, description: "The event's physical address")
@ -52,6 +57,11 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
field(:title, :string, description: "The event's title")
field(:begins_on, :datetime, description: "Datetime for when the event begins")
field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:long_event, :boolean,
description: "Whether the event is a long event (activity) or not"
)
field(:status, :event_status, description: "Status of the event")
field(:picture, :media, description: "The event's picture")
field(:physical_address, :address, description: "The event's physical address")
@ -171,7 +181,11 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
enum :search_group_sort_options do
value(:match_desc, description: "The pertinence of the result")
value(:member_count_desc, description: "The members count of the group")
value(:member_count_asc, description: "The members count of the group ascendant order")
value(:member_count_desc, description: "The members count of the group descendent order")
value(:created_at_asc, description: "When the group was created ascendant order")
value(:created_at_desc, description: "When the group was created descendent order")
value(:last_event_activity, description: "Last event activity of the group")
end
enum :search_event_sort_options do
@ -273,7 +287,7 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
description: "Radius around the location to search in"
)
arg(:longevents, :boolean, description: "if mention filter in or out long events")
arg(:long_events, :boolean, description: "if mention filter in or out long events")
arg(:bbox, :string, description: "The bbox to search events into")
arg(:zoom, :integer, description: "The zoom level for searching events")

View file

@ -13,6 +13,7 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.Crypto
alias Mobilizon.Events.Event
alias Mobilizon.Events.FeedToken
alias Mobilizon.Medias
alias Mobilizon.Service.Workers
@ -518,7 +519,6 @@ defmodule Mobilizon.Actors do
query = from(a in Actor)
query
|> distinct([q], q.id)
|> actor_by_username_or_name_query(term)
|> maybe_join_address(
Keyword.get(options, :location),
@ -532,8 +532,56 @@ defmodule Mobilizon.Actors do
|> filter_by_minimum_visibility(Keyword.get(options, :minimum_visibility, :public))
|> filter_suspended(false)
|> filter_out_anonymous_actor_id(anonymous_actor_id)
# order_by
|> actor_order(Keyword.get(options, :sort_by, :match_desc))
end
# sort by most recent id if "best match"
defp actor_order(query, :match_desc) do
query
|> order_by([q], desc: q.id)
end
defp actor_order(query, :last_event_activity) do
query
|> join(:left, [q], e in Event, on: e.attributed_to_id == q.id)
|> group_by([q, e], q.id)
|> order_by([q, e], [
# put groups with no events at the end of the list
fragment("MAX(?) IS NULL", e.updated_at),
# last edited event of the group
desc: max(e.updated_at),
# sort group with no event by id
desc: q.id
])
end
defp actor_order(query, :member_count_asc) do
query
|> join(:left, [q], m in Member, on: m.parent_id == q.id)
|> group_by([q, m], q.id)
|> order_by([q, m], asc: count(m.id), asc: q.id)
end
defp actor_order(query, :member_count_desc) do
query
|> join(:left, [q], m in Member, on: m.parent_id == q.id)
|> group_by([q, m], q.id)
|> order_by([q, m], desc: count(m.id), desc: q.id)
end
defp actor_order(query, :created_at_asc) do
query
|> order_by([q], asc: q.inserted_at)
end
defp actor_order(query, :created_at_desc) do
query
|> order_by([q], desc: q.inserted_at)
end
defp actor_order(query, _), do: query
@doc """
Gets a group by its title.
"""
@ -1394,16 +1442,6 @@ defmodule Mobilizon.Actors do
^username
)
)
|> order_by(
[a],
fragment(
"word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc",
a.preferred_username,
^username,
a.name,
^username
)
)
end
@spec maybe_join_address(

View file

@ -66,6 +66,7 @@ defmodule Mobilizon.Events.Event do
participants: [Actor.t()],
contacts: [Actor.t()],
language: String.t(),
long_event: boolean,
metadata: [EventMetadata.t()]
}
@ -89,7 +90,8 @@ defmodule Mobilizon.Events.Event do
:picture_id,
:physical_address_id,
:attributed_to_id,
:language
:language,
:long_event
]
@attrs @required_attrs ++ @optional_attrs
@ -102,6 +104,7 @@ defmodule Mobilizon.Events.Event do
field(:slug, :string)
field(:description, :string)
field(:ends_on, :utc_datetime)
field(:long_event, :boolean, virtual: true, default: nil)
field(:title, :string)
field(:status, EventStatus, default: :confirmed)
field(:draft, :boolean, default: false)

View file

@ -12,6 +12,8 @@ defmodule Mobilizon.Events do
import Mobilizon.Storage.Ecto
import Mobilizon.Events.Utils, only: [calculate_notification_time: 1]
require Logger
alias Ecto.{Changeset, Multi}
alias Mobilizon.Actors.{Actor, Follower}
@ -141,6 +143,7 @@ defmodule Mobilizon.Events do
url
|> event_by_url_query()
|> Repo.one()
|> with_virtual_fields()
end
@doc """
@ -153,6 +156,7 @@ defmodule Mobilizon.Events do
|> event_by_url_query()
|> preload_for_event()
|> Repo.one!()
|> with_virtual_fields()
end
@doc """
@ -167,6 +171,7 @@ defmodule Mobilizon.Events do
|> filter_draft()
|> preload_for_event()
|> Repo.one!()
|> with_virtual_fields()
end
@doc """
@ -180,6 +185,7 @@ defmodule Mobilizon.Events do
|> filter_draft()
|> preload_for_event()
|> Repo.one()
|> with_virtual_fields()
end
@spec check_if_event_has_instance_follow(String.t(), integer()) :: boolean()
@ -199,6 +205,7 @@ defmodule Mobilizon.Events do
|> event_by_uuid_query()
|> preload_for_event()
|> Repo.one()
|> with_virtual_fields()
end
@doc """
@ -212,6 +219,7 @@ defmodule Mobilizon.Events do
|> filter_not_event_uuid(not_event_uuid)
|> filter_draft()
|> Repo.one()
|> with_virtual_fields()
end
@doc """
@ -366,7 +374,7 @@ defmodule Mobilizon.Events do
atom,
boolean,
boolean | nil,
string | nil,
String.t() | nil,
float | nil
) :: Page.t(Event.t())
def list_events(
@ -375,7 +383,7 @@ defmodule Mobilizon.Events do
sort \\ :begins_on,
direction \\ :asc,
is_future \\ true,
longevents \\ nil,
long_events \\ nil,
location \\ nil,
radius \\ nil
) do
@ -386,12 +394,13 @@ defmodule Mobilizon.Events do
|> maybe_join_address(%{location: location, radius: radius})
|> events_for_location(%{location: location, radius: radius})
|> filter_future_events(is_future)
|> events_for_longevents(longevents)
|> events_for_long_events(long_events)
|> filter_public_visibility()
|> filter_draft()
|> filter_cancelled_events()
|> filter_local_or_from_followed_instances_events()
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@spec stream_events_for_sitemap :: Enum.t()
@ -413,6 +422,7 @@ defmodule Mobilizon.Events do
|> preload_for_event()
|> event_order_by(sort, direction)
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@doc """
@ -425,6 +435,7 @@ defmodule Mobilizon.Events do
|> events_by_tags_query(limit)
|> filter_draft()
|> Repo.all()
|> with_virtual_fields()
end
@doc """
@ -440,6 +451,7 @@ defmodule Mobilizon.Events do
actor_id
|> do_list_public_events_for_actor()
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@doc """
@ -467,6 +479,7 @@ defmodule Mobilizon.Events do
|> do_list_public_events_for_actor()
|> event_filter_begins_on(DateTime.utc_now(), nil)
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@spec do_list_public_events_for_actor(integer()) :: Ecto.Query.t()
@ -485,6 +498,7 @@ defmodule Mobilizon.Events do
|> event_for_actor_query(desc: :begins_on)
|> preload_for_event()
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@spec list_simple_organized_events_for_group(Actor.t(), integer | nil, integer | nil) ::
@ -516,10 +530,13 @@ defmodule Mobilizon.Events do
group_id
|> event_for_group_query()
|> event_filter_visibility(visibility)
|> event_filter_begins_on(after_datetime, before_datetime)
# We want future and ongoing events, so we use ends_on
# See issue #1567
|> event_filter_ends_on(after_datetime, before_datetime)
|> event_order_by(order_by, order_direction)
|> preload_for_event()
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@spec list_drafts_for_user(integer, integer | nil, integer | nil) :: Page.t(Event.t())
@ -529,6 +546,7 @@ defmodule Mobilizon.Events do
|> filter_draft(true)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@spec user_moderator_for_event?(integer | String.t(), integer | String.t()) :: boolean
@ -552,6 +570,7 @@ defmodule Mobilizon.Events do
|> close_events_query(radius)
|> filter_draft()
|> Repo.all()
|> with_virtual_fields()
end
@doc """
@ -587,7 +606,7 @@ defmodule Mobilizon.Events do
|> events_for_search_query()
|> events_for_begins_on(Map.get(args, :begins_on, DateTime.utc_now()))
|> events_for_ends_on(Map.get(args, :ends_on))
|> events_for_longevents(Map.get(args, :longevents))
|> events_for_long_events(Map.get(args, :long_events))
|> events_for_category(args)
|> events_for_categories(args)
|> events_for_languages(args)
@ -602,7 +621,8 @@ defmodule Mobilizon.Events do
|> filter_local_or_from_followed_instances_events()
|> filter_public_visibility()
|> event_order(Map.get(args, :sort_by, :match_desc), search_string)
|> Page.build_page(page, limit, :begins_on)
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@doc """
@ -978,6 +998,7 @@ defmodule Mobilizon.Events do
actor_id
|> event_participations_for_actor_query()
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@doc """
@ -993,6 +1014,7 @@ defmodule Mobilizon.Events do
actor_id
|> event_participations_for_actor_query(DateTime.utc_now())
|> Page.build_page(page, limit)
|> with_virtual_fields()
end
@doc """
@ -1394,14 +1416,14 @@ defmodule Mobilizon.Events do
end
end
@spec events_for_longevents(Ecto.Queryable.t(), Boolean.t() | nil) :: Ecto.Query.t()
defp events_for_longevents(query, longevents) do
@spec events_for_long_events(Ecto.Queryable.t(), Boolean.t() | nil) :: Ecto.Query.t()
defp events_for_long_events(query, long_events) do
duration = Config.get([:instance, :duration_of_long_event], 0)
if duration <= 0 do
query
else
case longevents do
case long_events do
nil ->
query
@ -1871,6 +1893,7 @@ defmodule Mobilizon.Events do
)
)
|> Repo.all()
|> with_virtual_fields()
end
@spec list_participations_for_user_query(integer()) :: Ecto.Query.t()
@ -2003,6 +2026,35 @@ defmodule Mobilizon.Events do
|> where([e], e.begins_on > ^after_datetime)
end
defp event_filter_ends_on(query, nil, nil), do: query
defp event_filter_ends_on(query, %DateTime{} = after_datetime, nil) do
where(
query,
[e],
(is_nil(e.ends_on) and e.begins_on >= ^after_datetime) or
e.ends_on >= ^after_datetime
)
end
defp event_filter_ends_on(query, nil, %DateTime{} = before_datetime) do
where(
query,
[e],
(is_nil(e.ends_on) and e.begins_on <= ^before_datetime) or e.ends_on <= ^before_datetime
)
end
defp event_filter_ends_on(
query,
%DateTime{} = after_datetime,
%DateTime{} = before_datetime
) do
query
|> event_filter_ends_on(after_datetime, nil)
|> event_filter_ends_on(nil, before_datetime)
end
defp event_order_by(query, order_by, direction)
when order_by in [:begins_on, :inserted_at, :updated_at] and direction in [:asc, :desc] do
order_by_instruction = Keyword.new([{direction, order_by}])
@ -2098,4 +2150,44 @@ defmodule Mobilizon.Events do
|> preload_for_event()
|> Page.chunk(chunk_size)
end
# Handling the case where Repo.XXXX() return nil
def with_virtual_fields(nil), do: nil
# if envent has no end date, it can not be a long events
def with_virtual_fields(%Event{} = event) when is_nil(event.ends_on),
do: %{event | long_event: false}
# Handling the case where there is an event
# Using Repo.one(), for example
def with_virtual_fields(%Event{} = event) do
duration = Config.get([:instance, :duration_of_long_event], 0)
event_duration = DateTime.diff(event.ends_on, event.begins_on, :day)
# duration need to be > 0 for long event to be activated
long_event = duration > 0 && event_duration > duration
%{event | long_event: long_event}
end
# Handling the case where there is a list of events
# Using Repo.all(), for example
def with_virtual_fields(events) when is_list(events) do
Enum.map(events, &with_virtual_fields/1)
end
# Handling the case of a paginated list of events
def with_virtual_fields(%Page{total: _total, elements: elements} = page) do
elements_with_virtual_fields = Enum.map(elements, &with_virtual_fields/1)
%{page | elements: elements_with_virtual_fields}
end
# In case the function is called on an element without virtual_fields
def with_virtual_fields(invalid) do
Logger.warning("with_virtual_fields called on invalid element : #{inspect(invalid)}")
# Return the element without modification
invalid
end
end

View file

@ -73,7 +73,7 @@ defmodule Mobilizon.Instances do
query
end
%Page{elements: elements} = paged_instances = Page.build_page(query, page, limit, :domain)
%Page{elements: elements} = paged_instances = Page.build_page(query, page, limit)
%Page{
paged_instances

View file

@ -19,15 +19,23 @@ defmodule Mobilizon.Storage.Page do
@doc """
Returns a Page struct for a query.
`field` is used to define the field that will be used for the count aggregate, which should be the same as the field used for order_by
See https://stackoverflow.com/q/12693089/10204399
"""
@spec build_page(Ecto.Queryable.t(), integer | nil, integer | nil, atom()) :: t(any)
def build_page(query, page, limit, field \\ :id) do
@spec build_page(Ecto.Queryable.t(), integer | nil, integer) :: t(any)
def build_page(query, page, limit) do
count_query =
query
# Exclude select because we add a new one below
|> exclude(:select)
# Exclude order_by for perf
|> exclude(:order_by)
# Exclude preloads to avoid error "cannot preload associations in subquery"
|> exclude(:preload)
|> subquery()
|> select([r], count(fragment("*")))
[total, elements] =
[
fn -> Repo.aggregate(query, :count, field) end,
fn -> Repo.one(count_query) end,
fn -> Repo.all(paginate(query, page, limit)) end
]
|> Enum.map(&Task.async/1)

View file

@ -1,7 +1,7 @@
defmodule Mobilizon.Mixfile do
use Mix.Project
@version "5.0.0-beta.1"
@version "5.1.0"
def project do
[

5
package-lock.json generated
View file

@ -1,12 +1,13 @@
{
"name": "mobilizon",
"version": "5.0.0-beta.1",
"version": "5.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mobilizon",
"version": "5.0.0-beta.1",
"version": "5.1.0",
"hasInstallScript": true,
"dependencies": {
"@apollo/client": "^3.9.5",
"@framasoft/socket": "^1.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "mobilizon",
"version": "5.0.0-beta.1",
"version": "5.1.0",
"private": true,
"scripts": {
"dev": "vite",

View file

@ -0,0 +1,11 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddEventPhysicalAddressIndex do
use Ecto.Migration
def up do
create(index("events", [:physical_address_id], name: :events_phys_addr_id))
end
def down do
drop(index("events", [:physical_address_id], name: :events_phys_addr_id))
end
end

View file

@ -2268,7 +2268,7 @@ type RootQueryType {
endsOn: DateTime
"Filter for long events in function of configuration parameter 'duration_of_long_event'"
longevents: Boolean
longEvents: Boolean
): Events
"Interact with an URI"

View file

@ -210,7 +210,7 @@ body {
}
.checkbox-check {
@apply appearance-none bg-primary border-primary;
@apply appearance-none border-primary;
}
.checkbox-checked {
@ -250,7 +250,7 @@ body {
@apply mr-2;
}
.form-radio {
@apply bg-none text-primary accent-primary;
@apply bg-none text-primary border-primary accent-primary;
}
.radio-label {
@apply pl-2;

View file

@ -110,67 +110,66 @@
{{ t("Actions") }}
</o-button>
</template>
<o-dropdown-item aria-role="listitem" has-link v-if="canManageEvent">
<router-link
class="flex gap-1"
:to="{
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent"
@click="
router.push({
name: RouteName.PARTICIPATIONS,
params: { eventId: event?.uuid },
}"
>
<AccountMultiple />
{{ t("Participations") }}
</router-link>
})
"
>
<AccountMultiple />
{{ t("Participations") }}
</o-dropdown-item>
<o-dropdown-item aria-role="listitem" has-link v-if="canManageEvent">
<router-link
class="flex gap-1"
:to="{
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent"
@click="
router.push({
name: RouteName.ANNOUNCEMENTS,
params: { eventId: event?.uuid },
}"
>
<Bullhorn />
{{ t("Announcements") }}
</router-link>
})
"
>
<Bullhorn />
{{ t("Announcements") }}
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
@click="
router.push({
name: RouteName.EDIT_EVENT,
params: { eventId: event?.uuid },
}"
>
<Pencil />
{{ t("Edit") }}
</router-link>
})
"
>
<Pencil />
{{ t("Edit") }}
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
@click="
router.push({
name: RouteName.DUPLICATE_EVENT,
params: { eventId: event?.uuid },
}"
>
<ContentDuplicate />
{{ t("Duplicate") }}
</router-link>
})
"
>
<ContentDuplicate />
{{ t("Duplicate") }}
</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") }}

View file

@ -0,0 +1,66 @@
<template>
<!--
:key is required to force rerender when time change
If not used, the input becomes empty
See : https://vuejs.org/api/built-in-special-attributes.html#key
-->
<input
:type="time ? 'datetime-local' : 'date'"
:key="time.toString()"
class="rounded invalid:border-red-500 dark:bg-zinc-600"
v-model="component"
:min="computeMin"
@blur="$emit('blur')"
/>
</template>
<script lang="ts" setup>
import { computed } from "vue";
const props = withDefaults(
defineProps<{
modelValue: Date | null;
time: boolean;
min?: Date | null | undefined;
}>(),
{
min: undefined,
}
);
const emit = defineEmits(["update:modelValue", "blur"]);
/** Format a Date to 'YYYY-MM-DDTHH:MM' based on local time zone */
const UTCToLocal = (date: Date) => {
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, props.time ? 16 : 10);
};
const component = computed({
get() {
if (!props.modelValue) {
return null;
}
return UTCToLocal(props.modelValue);
},
set(value) {
if (!value) {
emit("update:modelValue", null);
return;
}
const date = new Date(value);
if (!props.time) {
date.setHours(0, 0, 0, 0);
}
emit("update:modelValue", date);
},
});
const computeMin = computed((): string | undefined => {
if (!props.min) {
return undefined;
}
return UTCToLocal(props.min);
});
</script>

View file

@ -3,83 +3,74 @@
<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">
<!-- endsOn is set and isSameDay() -->
<template v-else-if="isSameDay()">
<p v-if="showStartTime && showEndTime">
<span>{{
t("On {date} from {startTime} to {endTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endTime: formatTime(endsOn),
})
}}</span>
</p>
<p v-else-if="showStartTime && !showEndTime">
{{
t("On {date} starting at {startTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
})
}}
</p>
<p v-else-if="!showStartTime && showEndTime">
{{
t("On {date} ending at {endTime}", {
date: formatDate(beginsOn),
endTime: formatTime(endsOn),
})
}}
</p>
<p v-else>
{{ t("On {date}", { date: formatDate(beginsOn) }) }}
</p>
</template>
<!-- endsOn is set and !isSameDay() -->
<p v-else-if="showStartTime && showEndTime">
<span>
{{
t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn, timezoneToShow),
endTime: formatTime(endsOn),
})
}}
</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ multipleTimeZones }}
</o-switch>
</p>
<p v-else-if="endsOn && showStartTime">
<p v-else-if="showStartTime && !showEndTime">
<span>
{{
t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
})
}}
</span>
<br />
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</o-switch>
</p>
<p v-else-if="endsOn">
<p v-else-if="!showStartTime && showEndTime">
<span>
{{
t("From the {startDate} to the {endDate} at {endTime}", {
startDate: formatDate(beginsOn),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn),
})
}}
</span>
</p>
<p v-else>
{{
t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn),
@ -87,6 +78,13 @@
})
}}
</p>
<o-switch
size="small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</o-switch>
</template>
<script lang="ts" setup>
import {
@ -119,7 +117,7 @@ const showLocalTimezone = ref(false);
const timezoneToShow = computed((): string | undefined => {
if (showLocalTimezone.value) {
return props.timezone;
return props.timezone ?? userActualTimezone.value;
}
return userActualTimezone.value;
});
@ -132,33 +130,43 @@ const userActualTimezone = computed((): string => {
});
const formatDate = (value: string): string | undefined => {
return formatDateString(value);
return formatDateString(value, timezoneToShow.value ?? "Etc/UTC");
};
const formatTime = (
value: string,
timezone: string | undefined = undefined
): string | undefined => {
return formatTimeString(value, timezone ?? "Etc/UTC");
const formatTime = (value: string): string | undefined => {
return formatTimeString(value, timezoneToShow.value ?? "Etc/UTC");
};
// We need to compare date after the offset is applied
// Because some date can be in the same day in a time zone, but different day in another.
// Example : From 2025-11-30 at 23:00 to 2025-12-01 01:00 in Asia/Shanghai (different days)
// It is from 2025-11-30 at 16:00 to 2025-11-30 at 18:00 in Europe/Paris (same day)
const isSameDay = (): boolean => {
if (!props.endsOn) return false;
const offset =
getTimezoneOffset(timezoneToShow.value ?? "Etc/UTC", new Date()) /
(60 * 1000);
const beginsOnOffset = new Date(props.beginsOn);
beginsOnOffset.setUTCMinutes(beginsOnOffset.getUTCMinutes() + offset);
const endsOnOffset = new Date(props.endsOn);
endsOnOffset.setUTCMinutes(endsOnOffset.getUTCMinutes() + offset);
return (
beginsOnDate.value.toDateString() === new Date(props.endsOn).toDateString()
beginsOnOffset.getUTCFullYear() === endsOnOffset.getUTCFullYear() &&
beginsOnOffset.getUTCMonth() === endsOnOffset.getUTCMonth() &&
beginsOnOffset.getUTCDate() === endsOnOffset.getUTCDate()
);
};
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) &&
getTimezoneOffset(props.timezone, new Date()) !==
getTimezoneOffset(userActualTimezone.value, new Date()) &&
props.timezone !== userActualTimezone.value
);
});
@ -173,15 +181,4 @@ const singleTimeZone = computed((): string => {
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

@ -124,9 +124,13 @@
<!-- Less than 10 seats left -->
<span class="has-text-danger" v-if="lastSeatsLeft">
{{
t("{number} seats left", {
number: seatsLeft,
})
t(
"{number} seats left",
{
number: seatsLeft,
},
seatsLeft ?? 0
)
}}
</span>
<span
@ -202,16 +206,14 @@
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
@click="
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT,
params: { eventId: participation.event.uuid },
})
"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.EDIT_EVENT,
params: { eventId: participation.event.uuid },
})
"
>
<div class="flex gap-1">
<Pencil />
{{ t("Edit") }}
</div>
@ -220,16 +222,14 @@
<o-dropdown-item
aria-role="listitem"
v-if="participation.role === ParticipantRole.CREATOR"
@click="
gotToWithCheck(participation, {
name: RouteName.DUPLICATE_EVENT,
params: { eventId: participation.event.uuid },
})
"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.DUPLICATE_EVENT,
params: { eventId: participation.event.uuid },
})
"
>
<div class="flex gap-1">
<ContentDuplicate />
{{ t("Duplicate") }}
</div>
@ -243,8 +243,9 @@
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
@click="openDeleteEventModalWrapper"
>
<div @click="openDeleteEventModalWrapper" class="flex gap-1">
<div class="flex gap-1">
<Delete />
{{ t("Delete") }}
</div>
@ -258,16 +259,14 @@
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
params: { eventId: participation.event.uuid },
})
"
>
<div
class="flex gap-1"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
params: { eventId: participation.event.uuid },
})
"
>
<div class="flex gap-1">
<AccountMultiplePlus />
{{ t("Manage participations") }}
</div>
@ -282,30 +281,28 @@
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
>
<router-link
class="flex gap-1"
:to="{
@click="
router.push({
name: RouteName.ANNOUNCEMENTS,
params: { eventId: participation.event?.uuid },
}"
>
<Bullhorn />
{{ t("Announcements") }}
</router-link>
})
"
>
<Bullhorn />
{{ t("Announcements") }}
</o-dropdown-item>
<o-dropdown-item aria-role="listitem">
<router-link
class="flex gap-1"
:to="{
<o-dropdown-item
aria-role="listitem"
@click="
router.push({
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<ViewCompact />
{{ t("View event page") }}
</router-link>
params: { eventId: participation.event.uuid },
})
"
>
<ViewCompact />
{{ t("View event page") }}
</o-dropdown-item>
</o-dropdown>
</div>

View file

@ -68,6 +68,7 @@
</template>
</template>
</o-autocomplete>
<slot></slot>
<o-button
:disabled="!selected"
@click="resetAddress"
@ -381,7 +382,7 @@ const asyncData = async (query: string): Promise<void> => {
};
const selectedAddressText = computed(() => {
if (!selected) return undefined;
if (!selected || !selected.id) return undefined;
return addressFullName(selected);
});

View file

@ -1,6 +1,7 @@
<template>
<div class="max-w-md mx-auto">
<o-input
expanded
dir="auto"
:placeholder="t('Filter by profile or group name')"
v-model="actorFilterProxy"

View file

@ -63,9 +63,10 @@
<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">
<div class="flex flex-wrap gap-2 items-center flex-col lg:flex-row">
<div class="max-h-[400px] overflow-y-auto flex-1 w-full">
<organizer-picker
class="p-5 w-3/4"
v-if="currentActor"
:current-actor="currentActor"
:identities="identities ?? []"
@ -80,6 +81,7 @@
<div v-if="isSelectedActorAGroup">
<p>{{ $t("Add a contact") }}</p>
<o-input
expanded
:placeholder="$t('Filter by name')"
:value="contactFilter"
@input="debounceSetFilterByName"
@ -137,8 +139,12 @@
</div>
</div>
</section>
<footer class="my-2">
<o-button variant="primary" @click="pickActor">
<footer class="my-2 text-center sm:text-right">
<o-button
variant="primary"
class="w-full sm:w-auto"
@click="pickActor"
>
{{ $t("Pick") }}
</o-button>
</footer>

View file

@ -12,30 +12,24 @@
</div>
</template>
<script lang="ts" setup>
import { formatTimeString } from "@/filters/datetime";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import Clock from "vue-material-design-icons/ClockTimeTenOutline.vue";
const { locale } = useI18n({ useScope: "global" });
const localeConverted = locale.replace("_", "-");
const props = withDefaults(
defineProps<{
date: string;
timezone?: string;
small?: boolean;
}>(),
{ small: false }
{ small: false, timezone: "Etc/UTC" }
);
const dateObj = computed<Date>(() => new Date(props.date));
const time = computed<string>(() =>
dateObj.value.toLocaleTimeString(localeConverted, {
hour: "2-digit",
minute: "2-digit",
})
formatTimeString(props.date, props.timezone)
);
const smallStyle = computed<string>(() => (props.small ? "0.9" : "2"));

View file

@ -1,24 +1,18 @@
<template>
<section
class="flex flex-col mb-3 border-2"
:class="{
'border-mbz-purple': privateSection,
'border-yellow-1': !privateSection,
}"
>
<section class="flex flex-col border-2 border-yellow-1 rounded-lg">
<div class="flex items-stretch py-3 px-1 bg-yellow-1 text-violet-title">
<div class="flex flex-1 gap-1">
<o-icon :icon="icon" custom-size="36" />
<h2 class="text-2xl font-medium mt-0">{{ title }}</h2>
</div>
<router-link class="self-center" :to="route">{{
<router-link v-if="route" class="self-center" :to="route">{{
t("View all")
}}</router-link>
</div>
<div class="flex-1">
<div class="flex-1 min-h-40">
<slot></slot>
</div>
<div class="flex justify-end p-2">
<div class="flex flex-wrap justify-end p-2">
<slot name="create"></slot>
</div>
</section>
@ -31,10 +25,9 @@ withDefaults(
defineProps<{
title: string;
icon: string;
privateSection?: boolean;
route: { name: string; params: { preferredUsername: string } };
}>(),
{ privateSection: true }
{ route: undefined }
);
const { t } = useI18n({ useScope: "global" });
</script>

View file

@ -1,8 +1,7 @@
<template>
<group-section
:title="t('Events')"
:title="longEvent ? t('Activities') : t('Events')"
icon="calendar"
:privateSection="false"
:route="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
@ -10,21 +9,40 @@
>
<template #default>
<div
class="flex flex-wrap gap-2 py-1"
class="flex flex-wrap gap-2 p-2"
v-if="group && group.organizedEvents.total > 0"
>
<event-minimalist-card
v-for="event in group.organizedEvents.elements.slice(0, 3)"
v-for="event in group.organizedEvents.elements
.filter((event) => (longEvent ? event.longEvent : !event.longEvent))
.slice(0, 3)"
:event="event"
:key="event.uuid"
/>
</div>
<empty-content v-else-if="group" icon="calendar" :inline="true">
{{ t("No public upcoming events") }}
<empty-content v-else-if="group" icon="calendar" :inline="true"
>{{
longEvent
? t("No public upcoming activities")
: t("No public upcoming events")
}}
</empty-content>
<!-- <o-skeleton animated v-else></o-skeleton> -->
</template>
<template #create>
<o-button
tag="router-link"
class="button"
variant="text"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: { showPassedEvents: true },
}"
>{{
longEvent ? t("+ View past activities") : t("+ View past events")
}}</o-button
>
<o-button
tag="router-link"
v-if="isModerator"
@ -33,7 +51,9 @@
query: { actorId: group?.id },
}"
class="button is-primary"
>{{ t("+ Create an event") }}</o-button
>{{
longEvent ? t("+ Create an activity") : t("+ Create an event")
}}</o-button
>
</template>
</group-section>
@ -50,5 +70,9 @@ import GroupSection from "@/components/Group/GroupSection.vue";
const { t } = useI18n({ useScope: "global" });
defineProps<{ group: IGroup; isModerator: boolean }>();
defineProps<{
group: IGroup;
isModerator: boolean;
longEvent: boolean;
}>();
</script>

View file

@ -2,16 +2,28 @@
<group-section
:title="t('Announcements')"
icon="bullhorn"
:privateSection="false"
:route="{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template #default>
<div class="p-1">
<div class="p-2">
<multi-post-list-item
v-if="group?.posts?.total ?? 0 > 0"
v-if="
!isMember &&
group?.posts.elements.filter(
(post) => !post.draft && post.visibility === PostVisibility.PUBLIC
).length > 0
"
:posts="
group?.posts.elements.filter(
(post) => !post.draft && post.visibility === PostVisibility.PUBLIC
)
"
/>
<multi-post-list-item
v-else-if="group?.posts?.total ?? 0 > 0"
:posts="(group?.posts?.elements ?? []).slice(0, 3)"
:isCurrentActorMember="isMember"
/>
@ -43,6 +55,7 @@ import { useI18n } from "vue-i18n";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import MultiPostListItem from "@/components/Post/MultiPostListItem.vue";
import GroupSection from "@/components/Group/GroupSection.vue";
import { PostVisibility } from "@/types/enums";
const { t } = useI18n({ useScope: "global" });

View file

@ -9,6 +9,7 @@
t("Keyword, event title, group name, etc.")
}}</label>
<o-input
v-if="search != null"
v-model="search"
:placeholder="t('Search')"
id="search_field_input"
@ -19,57 +20,110 @@
maxlength="1024"
expanded
/>
<o-button native-type="submit" icon-left="magnify"> </o-button>
<full-address-auto-complete
:resultType="AddressSearchType.ADMINISTRATIVE"
v-model="address"
:hide-map="true"
:hide-selected="true"
:default-text="addressDefaultText"
labelClass="sr-only"
:placeholder="t('e.g. Nantes, Berlin, Cork, …')"
v-on:update:modelValue="modelValueUpdate"
>
<o-dropdown v-model="distance" position="bottom-right" v-if="distance">
<template #trigger="{ active }">
<o-button
:title="t('Select distance')"
:icon-right="active ? 'menu-up' : 'menu-down'"
>
{{ distanceText }}
</o-button>
</template>
<o-dropdown-item
v-for="distance_item in distanceList"
:value="distance_item.distance"
:label="distance_item.label"
:key="distance_item.distance"
/>
</o-dropdown>
</full-address-auto-complete>
<o-button
class="search-Event min-w-40 mr-1 mb-1"
native-type="submit"
icon-left="calendar"
>
{{ t("Events") }}
</o-button>
<o-button
class="search-Activity min-w-40 mr-1 mb-1"
native-type="submit"
icon-left="calendar-star"
v-if="isLongEvents"
>
{{ t("Activities") }}
</o-button>
<o-button
class="search-Group min-w-40 mr-1 mb-1"
native-type="submit"
icon-left="account-multiple"
>
{{ t("Groups") }}
</o-button>
</form>
</template>
<script lang="ts" setup>
import { IAddress } from "@/types/address.model";
import { AddressSearchType } from "@/types/enums";
import { AddressSearchType, ContentType } from "@/types/enums";
import {
addressToLocation,
getLocationFromLocal,
storeLocationInLocal,
getAddressFromLocal,
storeAddressInLocal,
} from "@/utils/location";
import { computed, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter, useRoute } from "vue-router";
import RouteName from "@/router/name";
import { useIsLongEvents } from "@/composition/apollo/config";
defineAsyncComponent(
() => import("@/components/Event/FullAddressAutoComplete.vue")
);
const props = defineProps<{
location: IAddress | null;
locationDefaultText?: string | null;
search: string;
address: IAddress | null;
addressDefaultText?: string | null;
search: string | null;
distance: number | null;
fromLocalStorage?: boolean | false;
}>();
const router = useRouter();
const route = useRoute();
const { isLongEvents } = useIsLongEvents();
const emit = defineEmits<{
(event: "update:location", location: IAddress | null): void;
(event: "update:address", address: IAddress | null): void;
(event: "update:search", newSearch: string): void;
(event: "update:distance", newDistance: number): void;
(event: "submit"): void;
}>();
const location = computed({
const address = computed({
get(): IAddress | null {
if (props.location) {
return props.location;
console.debug("-- get address --", props);
if (props.address) {
return props.address;
}
if (props.fromLocalStorage) {
return getLocationFromLocal();
return getAddressFromLocal();
}
return null;
},
set(newLocation: IAddress | null) {
emit("update:location", newLocation);
set(newAddress: IAddress | null) {
emit("update:address", newAddress);
if (props.fromLocalStorage) {
storeLocationInLocal(newLocation);
storeAddressInLocal(newAddress);
}
},
});
@ -83,17 +137,79 @@ const search = computed({
},
});
const submit = () => {
const distance = computed({
get(): number {
return props.distance;
},
set(newDistance: number) {
emit("update:distance", newDistance);
},
});
const distanceText = computed(() => {
return distance.value + " km";
});
const distanceList = computed(() => {
const distances = [];
[5, 10, 25, 50, 100, 150].forEach((value) => {
distances.push({
distance: value,
label: t(
"{number} kilometers",
{
number: value,
},
value
),
});
});
return distances;
});
console.debug("initial", distance.value, search.value, address.value);
const modelValueUpdate = (newaddress: IAddress | null) => {
emit("update:address", newaddress);
};
const submit = (event) => {
emit("submit");
const { lat, lon } = addressToLocation(location.value);
const btn_classes = event.submitter.getAttribute("class").split(" ");
const search_query = {
locationName: undefined,
lat: undefined,
lon: undefined,
search: undefined,
distance: undefined,
contentType: undefined,
};
if (search.value != "") {
search_query.search = search.value;
}
if (address.value) {
const { lat, lon } = addressToLocation(address.value);
search_query.locationName = address.value.locality ?? address.value.region;
search_query.lat = lat;
search_query.lon = lon;
if (distance.value != null) {
search_query.distance = distance.value.toString() + "_km";
}
}
if (btn_classes.includes("search-Event")) {
search_query.contentType = ContentType.EVENTS;
}
if (btn_classes.includes("search-Activity")) {
search_query.contentType = ContentType.LONGEVENTS;
}
if (btn_classes.includes("search-Group")) {
search_query.contentType = ContentType.GROUPS;
}
router.push({
name: RouteName.SEARCH,
query: {
...route.query,
locationName: location.value?.locality ?? location.value?.region,
lat,
lon,
search: search.value,
...search_query,
},
});
};

View file

@ -97,6 +97,7 @@ import { EventSortField, SortDirection } from "@/types/enums";
const props = defineProps<{
userLocation: LocationType;
doingGeoloc?: boolean;
distance: number | null;
}>();
defineEmits(["doGeoLoc"]);
@ -112,17 +113,17 @@ const geoHash = computed(() => {
return geo;
});
const distance = computed<number>(() =>
userLocation.value?.isIPLocation ? 150 : 25
);
const distance = computed<number>(() => {
return props.distance | 25;
});
const eventsQuery = useQuery<{
searchEvents: Paginate<IEvent>;
}>(FETCH_EVENTS, () => ({
orderBy: EventSortField.BEGINS_ON,
direction: SortDirection.ASC,
longevents: false,
location: geoHash.value,
longEvents: false,
location: geoHash.value ?? "",
radius: distance.value,
limit: 93,
}));

View file

@ -1,116 +0,0 @@
<template>
<close-content
class="container mx-auto px-2"
v-show="loading || selectedGroups.length > 0"
@do-geo-loc="emit('doGeoLoc')"
:suggestGeoloc="userLocation.isIPLocation"
:doingGeoloc="doingGeoloc"
>
<template #title>
<template v-if="userLocationName">
{{
t("Popular groups nearby {position}", {
position: userLocationName,
})
}}
</template>
<template v-else>
{{ t("Popular groups close to you") }}
</template>
</template>
<template #content>
<skeleton-group-result
v-for="i in [...Array(6).keys()]"
class="scroll-ml-6 snap-center shrink-0 w-[18rem] my-4"
:key="i"
v-show="loading"
/>
<group-card
v-for="group in selectedGroups"
:key="group.id"
:group="group"
:mode="'column'"
:showSummary="false"
/>
<more-content
v-if="userLocationName"
:to="{
name: RouteName.SEARCH,
query: {
locationName: userLocationName,
lat: userLocation.lat?.toString(),
lon: userLocation.lon?.toString(),
contentType: 'GROUPS',
distance: `${distance}_km`,
},
}"
:picture="userLocation.picture"
>
{{
t("View more groups around {position}", {
position: userLocationName,
})
}}
</more-content>
</template>
</close-content>
</template>
<script lang="ts" setup>
import SkeletonGroupResult from "@/components/Group/SkeletonGroupResult.vue";
import sampleSize from "lodash/sampleSize";
import { LocationType } from "@/types/user-location.model";
import MoreContent from "./MoreContent.vue";
import CloseContent from "./CloseContent.vue";
import { IGroup } from "@/types/actor";
import { SEARCH_GROUPS } from "@/graphql/search";
import { useQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
import { computed } from "vue";
import GroupCard from "@/components/Group/GroupCard.vue";
import { coordsToGeoHash } from "@/utils/location";
import { useI18n } from "vue-i18n";
import RouteName from "@/router/name";
const props = defineProps<{
userLocation: LocationType;
doingGeoloc?: boolean;
}>();
const emit = defineEmits(["doGeoLoc"]);
const { t } = useI18n({ useScope: "global" });
const userLocation = computed(() => props.userLocation);
const geoHash = computed(() =>
coordsToGeoHash(userLocation.value.lat, userLocation.value.lon)
);
const distance = computed<number>(() =>
userLocation.value?.isIPLocation ? 150 : 25
);
const { result: groupsResult, loading: loadingGroups } = useQuery<{
searchGroups: Paginate<IGroup>;
}>(
SEARCH_GROUPS,
() => ({
location: geoHash.value,
radius: distance.value,
page: 1,
limit: 12,
}),
() => ({ enabled: geoHash.value !== undefined })
);
const groups = computed(
() => groupsResult.value?.searchGroups ?? { total: 0, elements: [] }
);
const selectedGroups = computed(() => sampleSize(groups.value?.elements, 5));
const userLocationName = computed(() => props?.userLocation?.name);
const loading = computed(() => props.doingGeoloc || loadingGroups.value);
</script>

View file

@ -1,68 +0,0 @@
<template>
<close-content
class="container mx-auto px-2"
v-show="loadingEvents || (events && events.total > 0)"
:suggestGeoloc="false"
v-on="attrs"
>
<template #title>
{{ t("Agenda") }}
</template>
<template #content>
<skeleton-event-result
v-for="i in 6"
class="scroll-ml-6 snap-center shrink-0 w-[18rem] my-4"
:key="i"
v-show="loadingEvents"
/>
<event-card
v-for="event in events.elements"
:event="event"
:key="event.uuid"
/>
<more-content
:to="{
name: RouteName.SEARCH,
query: {
contentType: 'EVENTS',
},
}"
>
{{ t("View more events") }}
</more-content>
</template>
</close-content>
</template>
<script lang="ts" setup>
import MoreContent from "./MoreContent.vue";
import CloseContent from "./CloseContent.vue";
import { computed, useAttrs } from "vue";
import { IEvent } from "@/types/event.model";
import { useQuery } from "@vue/apollo-composable";
import EventCard from "../Event/EventCard.vue";
import { Paginate } from "@/types/paginate";
import SkeletonEventResult from "../Event/SkeletonEventResult.vue";
import { EventSortField, SortDirection } from "@/types/enums";
import { FETCH_EVENTS } from "@/graphql/event";
import { useI18n } from "vue-i18n";
import RouteName from "@/router/name";
defineProps<{
instanceName: string;
}>();
const { t } = useI18n({ useScope: "global" });
const attrs = useAttrs();
const { result: resultEvents, loading: loadingEvents } = useQuery<{
events: Paginate<IEvent>;
}>(FETCH_EVENTS, {
orderBy: EventSortField.BEGINS_ON,
direction: SortDirection.ASC,
longevents: false,
});
const events = computed(
() => resultEvents.value?.events ?? { total: 0, elements: [] }
);
</script>

View file

@ -1,75 +0,0 @@
<template>
<close-content
class="container mx-auto px-2"
:suggest-geoloc="false"
v-show="loadingEvents || (events?.elements && events?.elements.length > 0)"
>
<template #title>
{{ $t("Online upcoming events") }}
</template>
<template #content>
<skeleton-event-result
v-for="i in [...Array(6).keys()]"
class="scroll-ml-6 snap-center shrink-0 w-[18rem] my-4"
:key="i"
v-show="loadingEvents"
/>
<event-card
class="scroll-ml-6 snap-center shrink-0 first:pl-8 last:pr-8 w-[18rem]"
v-for="event in events?.elements"
:key="event.id"
:event="event"
mode="column"
/>
<more-content
:to="{
name: RouteName.SEARCH,
query: {
contentType: 'EVENTS',
isOnline: 'true',
},
}"
:picture="{
url: '/img/online-event.webp',
author: {
name: 'Chris Montgomery',
url: 'https://unsplash.com/@cwmonty',
},
source: {
name: 'Unsplash',
url: 'https://unsplash.com/?utm_source=Mobilizon&utm_medium=referral',
},
}"
>
{{ $t("View more online events") }}
</more-content>
</template>
</close-content>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import SkeletonEventResult from "@/components/Event/SkeletonEventResult.vue";
import MoreContent from "./MoreContent.vue";
import CloseContent from "./CloseContent.vue";
import { SEARCH_EVENTS } from "@/graphql/search";
import EventCard from "@/components/Event/EventCard.vue";
import { useQuery } from "@vue/apollo-composable";
import RouteName from "@/router/name";
import { Paginate } from "@/types/paginate";
import { IEvent } from "@/types/event.model";
const EVENT_PAGE_LIMIT = 12;
const { result: searchEventResult, loading: loadingEvents } = useQuery<{
searchEvents: Paginate<IEvent>;
}>(SEARCH_EVENTS, () => ({
beginsOn: new Date(),
endsOn: undefined,
eventPage: 1,
limit: EVENT_PAGE_LIMIT,
type: "ONLINE",
}));
const events = computed(() => searchEventResult.value?.searchEvents);
</script>

View file

@ -11,8 +11,24 @@
>
<MobilizonLogo class="w-40" />
</router-link>
<div class="flex items-center md:order-2 ml-auto" v-if="currentActor?.id">
<div
class="flex items-center md:order-2 ml-auto gap-2"
v-if="currentActor?.id"
>
<router-link
:to="{ name: RouteName.CONVERSATION_LIST }"
class="flex sm:mr-3 text-sm md:mr-0 relative"
id="conversations-menu-button"
aria-expanded="false"
>
<span class="sr-only">{{ t("Open conversations") }}</span>
<Inbox :size="32" />
<span
v-show="unreadConversationsCount > 0"
class="absolute bottom-0.5 -left-2 bg-primary rounded-full inline-block h-3 w-3 mx-2"
>
</span>
</router-link>
<o-dropdown position="bottom-right">
<template #trigger>
<button
@ -163,47 +179,6 @@
<ul
class="flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:font-lightbold"
>
<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 }"
@ -242,13 +217,6 @@
>{{ 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>
@ -273,15 +241,10 @@ import {
import { useMutation } from "@vue/apollo-composable";
import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
import { changeIdentity } from "@/utils/identity";
import {
useRegistrationConfig,
useIsLongEvents,
} from "@/composition/apollo/config";
import { useRegistrationConfig } from "@/composition/apollo/config";
import { useOruga } from "@oruga-ui/oruga-next";
import SearchFields from "@/components/Home/SearchFields.vue";
const { islongEvents } = useIsLongEvents();
const { currentUser } = useCurrentUserClient();
const { currentActor } = useCurrentActorClient();

View file

@ -1,5 +1,5 @@
<template>
<div class="posts-wrapper grid gap-4">
<div class="posts-wrapper grid gap-2">
<post-list-item
v-for="post in posts"
:key="post.id"

View file

@ -41,12 +41,12 @@
</template>
<script lang="ts" setup>
// import { SnackbarProgrammatic as Snackbar } from "buefy";
import { doUpdateSetting, useUserSettings } from "@/composition/apollo/user";
import { doUpdateSetting, useLoggedUser } from "@/composition/apollo/user";
import { onMounted, ref } from "vue";
const notificationOnDay = ref(true);
const { loggedUser } = useUserSettings();
const { loggedUser } = useLoggedUser();
const updateSetting = async (
variables: Record<string, unknown>

View file

@ -55,7 +55,7 @@ import { useTimezones } from "@/composition/apollo/config";
import {
doUpdateSetting,
updateLocale,
useUserSettings,
useLoggedUser,
} from "@/composition/apollo/user";
import { saveLocaleData } from "@/utils/auth";
import { computed, onMounted, watch } from "vue";
@ -68,7 +68,7 @@ const { t, locale } = useI18n({ useScope: "global" });
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const { loggedUser } = useUserSettings();
const { loggedUser } = useLoggedUser();
const { mutate: doUpdateLocale } = updateLocale();

View file

@ -29,6 +29,10 @@ const icons: Record<string, () => Promise<any>> = {
import(`../../../node_modules/vue-material-design-icons/LinkOff.vue`),
Image: () =>
import(`../../../node_modules/vue-material-design-icons/Image.vue`),
Information: () =>
import(
`../../../node_modules/vue-material-design-icons/InformationVariant.vue`
),
FormatListBulleted: () =>
import(
`../../../node_modules/vue-material-design-icons/FormatListBulleted.vue`
@ -111,6 +115,10 @@ const icons: Record<string, () => Promise<any>> = {
Map: () => import(`../../../node_modules/vue-material-design-icons/Map.vue`),
MapMarker: () =>
import(`../../../node_modules/vue-material-design-icons/MapMarker.vue`),
MapMarkerDistance: () =>
import(
`../../../node_modules/vue-material-design-icons/MapMarkerDistance.vue`
),
Close: () =>
import(`../../../node_modules/vue-material-design-icons/Close.vue`),
Magnify: () =>

View file

@ -227,8 +227,8 @@ export function useIsLongEvents() {
config: Pick<IConfig, "longEvents">;
}>(LONG_EVENTS);
const islongEvents = computed(() => result.value?.config.longEvents);
return { islongEvents, error, loading };
const isLongEvents = computed(() => result.value?.config.longEvents);
return { isLongEvents, error, loading };
}
export function useAnalytics() {

View file

@ -1,11 +1,10 @@
import { IDENTITIES, REGISTER_PERSON } from "@/graphql/actor";
import {
CURRENT_USER_CLIENT,
LOGGED_USER,
LOGGED_USER_AND_SETTINGS,
LOGGED_USER_LOCATION,
SET_USER_SETTINGS,
UPDATE_USER_LOCALE,
USER_SETTINGS,
} from "@/graphql/user";
import { IPerson } from "@/types/actor";
import { ICurrentUser, IUser } from "@/types/current-user.model";
@ -31,25 +30,14 @@ export function useCurrentUserClient() {
export function useLoggedUser() {
const { currentUser } = useCurrentUserClient();
const { result, error, onError } = useQuery<{ loggedUser: IUser }>(
LOGGED_USER,
const { result, error, onError, loading } = useQuery<{ loggedUser: IUser }>(
LOGGED_USER_AND_SETTINGS,
{},
() => ({ enabled: currentUser.value?.id != null })
);
const loggedUser = computed(() => result.value?.loggedUser);
return { loggedUser, error, onError };
}
export function useUserSettings() {
const {
result: userSettingsResult,
error,
loading,
} = useQuery<{ loggedUser: IUser }>(USER_SETTINGS);
const loggedUser = computed(() => userSettingsResult.value?.loggedUser);
return { loggedUser, error, loading };
return { loggedUser, error, onError, loading };
}
export function useUserLocation() {

View file

@ -8,12 +8,13 @@ function formatDateISOStringWithoutTime(value: string): string {
return parseDateTime(value).toISOString().split("T")[0];
}
function formatDateString(value: string): string {
function formatDateString(value: string, timeZone?: string): string {
return parseDateTime(value).toLocaleString(locale(), {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: timeZone,
});
}
@ -21,7 +22,7 @@ function formatTimeString(value: string, timeZone?: string): string {
return parseDateTime(value).toLocaleTimeString(locale(), {
hour: "numeric",
minute: "numeric",
timeZone,
timeZone: timeZone,
});
}

View file

@ -109,7 +109,7 @@ export const FETCH_EVENTS = gql`
$direction: SortDirection
$page: Int
$limit: Int
$longevents: Boolean
$longEvents: Boolean
) {
events(
location: $location
@ -118,7 +118,7 @@ export const FETCH_EVENTS = gql`
direction: $direction
page: $page
limit: $limit
longevents: $longevents
longEvents: $longEvents
) {
total
elements {

View file

@ -150,6 +150,7 @@ export const GROUP_BASIC_FIELDS_FRAGMENTS = gql`
beginsOn
status
draft
longEvent
language
options {
maximumAttendeeCapacity

View file

@ -33,7 +33,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
$searchTarget: SearchTarget
$beginsOn: DateTime
$endsOn: DateTime
$longevents: Boolean
$longEvents: Boolean
$bbox: String
$zoom: Int
$eventPage: Int
@ -55,7 +55,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
searchTarget: $searchTarget
beginsOn: $beginsOn
endsOn: $endsOn
longevents: $longevents
longEvents: $longEvents
bbox: $bbox
zoom: $zoom
page: $eventPage
@ -70,6 +70,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
uuid
beginsOn
endsOn
longEvent
picture {
id
url
@ -155,7 +156,7 @@ export const SEARCH_EVENTS = gql`
$endsOn: DateTime
$eventPage: Int
$limit: Int
$longevents: Boolean
$longEvents: Boolean
) {
searchEvents(
location: $location
@ -168,7 +169,7 @@ export const SEARCH_EVENTS = gql`
endsOn: $endsOn
page: $eventPage
limit: $limit
longevents: $longevents
longEvents: $longEvents
) {
total
elements {
@ -218,7 +219,7 @@ export const SEARCH_CALENDAR_EVENTS = gql`
endsOn: $endsOn
page: $eventPage
limit: $limit
longevents: false
longEvents: false
) {
total
elements {

View file

@ -125,6 +125,25 @@ export const USER_SETTINGS_FRAGMENT = gql`
}
`;
export const LOGGED_USER_AND_SETTINGS = gql`
query LoggedUserQuery {
loggedUser {
id
email
locale
provider
defaultActor {
...ActorFragment
}
settings {
...UserSettingFragment
}
}
}
${ACTOR_FRAGMENT}
${USER_SETTINGS_FRAGMENT}
`;
export const USER_SETTINGS = gql`
query UserSetting {
loggedUser {

View file

@ -414,7 +414,7 @@
"Due on": "Due on",
"Organizers": "Organizers",
"(Masked)": "(Masked)",
"{available}/{capacity} available places": "No places left|{available}/{capacity} available places",
"{available}/{capacity} available places": "No places left|{available}/{capacity} available place left|{available}/{capacity} available places",
"No one is participating|One person participating|{going} people participating": "No one is participating|One person participating|{going} people participating",
"Date and time": "Date and time",
"Location": "Location",
@ -1218,7 +1218,7 @@
"You will receive notifications about this group's public activity depending on %{notification_settings}.": "You will receive notifications about this group's public activity depending on %{notification_settings}.",
"Online": "Online",
"That you follow or of which you are a member": "That you follow or of which you are a member",
"{number} seats left": "{number} seats left",
"{number} seats left": "No seat left|One seat left|{number} seats left",
"Published by {name}": "Published by {name}",
"Share this post": "Share this post",
"This post is accessible only through it's link. Be careful where you post this link.": "This post is accessible only through it's link. Be careful where you post this link.",
@ -1360,6 +1360,7 @@
"Keyword, event title, group name, etc.": "Keyword, event title, group name, etc.",
"Go!": "Go!",
"Explore!": "Explore!",
"Select distance": "Select distance",
"Join {instance}, a Mobilizon instance": "Join {instance}, a Mobilizon instance",
"Open user menu": "Open user menu",
"Open main menu": "Open main menu",

View file

@ -6,7 +6,10 @@
"+ Add a resource": "+ Ajouter une ressource",
"+ Create a post": "+ Créer un billet",
"+ Create an event": "+ Créer un événement",
"+ Create an activity": "+ Créer une activité",
"+ Start a discussion": "+ Lancer une discussion",
"+ View past activities": "+ Voir les activités passées",
"+ View past events": "+ Voir les événements passés",
"0 Bytes": "0 octets",
"<b>{contact}</b> will be displayed as contact.": "<b>{contact}</b> sera affiché·e comme contact.|<b>{contact}</b> seront affiché·e·s comme contacts.",
"@{group}": "@{group}",
@ -33,7 +36,7 @@
"A resource has been created or updated": "Une resource a été créée ou mise à jour",
"A twitter account handle to follow for event updates": "Un compte sur Twitter à suivre pour les mises à jour de l'événement",
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "Un outil convivial, émancipateur et éthique pour se rassembler, s'organiser et se mobiliser.",
"A validation email was sent to {email}": "Un email de validation a été envoyé à {email}",
"A validation email was sent to {email}": "Un e-mail de validation a été envoyé à {email}",
"API": "API",
"Abandon editing": "Abandonner la modification",
"About": "À propos",
@ -69,6 +72,7 @@
"Activate browser push notifications": "Activer les notifications push du navigateur",
"Activate notifications": "Activer les notifications",
"Activated": "Activé",
"Activities": "Activités",
"Active": "Actif·ive",
"Activity": "Activité",
"Actor": "Acteur",
@ -83,7 +87,7 @@
"Add an address": "Ajouter une adresse",
"Add an instance": "Ajouter une instance",
"Add link": "Ajouter un lien",
"Add new…": "Ajouter un nouvel élément…",
"Add new…": "Ajouter…",
"Add picture": "Ajouter une image",
"Add some tags": "Ajouter des tags",
"Add to my calendar": "Ajouter à mon agenda",
@ -112,7 +116,7 @@
"An event I'm organizing has a new pending participation": "Un événement que j'organise a une nouvelle participation en attente",
"An event from one of my groups has been published": "Un événement d'un de mes groupes a été publié",
"An event from one of my groups has been updated or deleted": "Un événement d'un de mes groupes a été mis à jour ou supprimé",
"An instance is an installed version of the Mobilizon software running on a server. An instance can be run by anyone using the {mobilizon_software} or other federated apps, aka the “fediverse”. This instance's name is {instance_name}. Mobilizon is a federated network of multiple instances (just like email servers), users registered on different instances may communicate even though they didn't register on the same instance.": "Une instance est une version du logiciel Mobilizon fonctionnant sur un serveur. Une instance peut être gérée par n'importe qui avec le {mobilizon_software} ou d'autres applications fédérées, correspondant au « fediverse ». Cette instance se nomme {instance_name}. Mobilizon est un réseau fédéré de multiples instances (tout comme des serveurs email), des utilisateur·rice·s inscrites sur différentes instances peuvent communiquer bien qu'il·elle·s ne se soient pas enregistré·e·s sur la même instance.",
"An instance is an installed version of the Mobilizon software running on a server. An instance can be run by anyone using the {mobilizon_software} or other federated apps, aka the “fediverse”. This instance's name is {instance_name}. Mobilizon is a federated network of multiple instances (just like email servers), users registered on different instances may communicate even though they didn't register on the same instance.": "Une instance est une version du logiciel Mobilizon fonctionnant sur un serveur. Une instance peut être gérée par n'importe qui avec le {mobilizon_software} ou d'autres applications fédérées, correspondant au « fediverse ». Cette instance se nomme {instance_name}. Mobilizon est un réseau fédéré de multiples instances (tout comme des serveurs e-mail), des utilisateur·rice·s inscrites sur différentes instances peuvent communiquer bien qu'il·elle·s ne se soient pas enregistré·e·s sur la même instance.",
"An “application programming interface” or “API” is a communication protocol that allows software components to communicate with each other. The Mobilizon API, for example, can allow third-party software tools to communicate with Mobilizon instances to carry out certain actions, such as posting events on your behalf, automatically and remotely.": "Une « interface de programmation dapplication » ou « API » est un protocole de communication qui permet aux composants logiciels de communiquer entre eux. L'API Mobilizon, par exemple, peut permettre à des outils logiciels tiers de communiquer avec les instances Mobilizon pour effectuer certaines actions, telles que la publication d'événements en votre nom, automatiquement et à distance.",
"An “application programming interface” or “API” is a communication protocol that allows software components to communicate with each other. The Mobilizon API, for example, can allow third-party software tools to communicate with Mobilizon instances to carry out certain actions, such as posting events, automatically and remotely.": "Une « interface de programmation d'application » ou « API » est un protocole de communication qui permet à des composants logiciels de communiquer entre eux. L'API de Mobilizon, par exemple, peut permettre à des outils logiciels tiers de communiquer avec des instances de Mobilizon pour effectuer certaines actions, comme la publication d'événements, automatiquement et à distance.",
"And {number} comments": "Et {number} commentaires",
@ -120,7 +124,7 @@
"Announcements and mentions notifications are always sent straight away.": "Les notifications d'annonces et de mentions sont toujours envoyées directement.",
"Announcements for {eventTitle}": "Annonces pour {eventTitle}",
"Anonymous participant": "Participant·e anonyme",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par e-mail.",
"Anonymous participations": "Participations anonymes",
"Any category": "N'importe quelle catégorie",
"Any day": "N'importe quand",
@ -187,7 +191,7 @@
"By {group}": "Par {group}",
"By {username}": "Par {username}",
"Calendar": "Calendrier",
"Can be an email or a link, or just plain text.": "Peut être une adresse email ou bien un lien, ou alors du simple texte brut.",
"Can be an email or a link, or just plain text.": "Peut être une adresse e-mail ou bien un lien, ou alors du simple texte brut.",
"Cancel": "Annuler",
"Cancel anonymous participation": "Annuler ma participation anonyme",
"Cancel creation": "Annuler la création",
@ -205,14 +209,14 @@
"Category illustrations credits": "Crédits des illustrations des catégories",
"Category list": "Liste des catégories",
"Change": "Modifier",
"Change email": "Changer l'email",
"Change email": "Changer l'e-mail",
"Change my email": "Changer mon adresse e-mail",
"Change my identity…": "Changer mon identité…",
"Change my password": "Modifier mon mot de passe",
"Change role": "Changer le role",
"Change the filters.": "Changez les filtres.",
"Change timezone": "Changer de fuseau horaire",
"Change user email": "Modifier l'email de l'utilisateur·ice",
"Change user email": "Modifier l'e-mail de l'utilisateur·ice",
"Change user role": "Changer le role de l'utilisateur",
"Check your device to continue. You may now close this window.": "Vérifiez votre appareil pour continuer. Vous pouvez maintenant fermer cette fenêtre.",
"Check your inbox (and your junk mail folder).": "Vérifiez votre boîte de réception (et votre dossier des indésirables).",
@ -305,6 +309,8 @@
"Deactivate notifications": "Désactiver les notifications",
"Decline": "Refuser",
"Decrease": "Baisser",
"Decreasing creation date": "Nouveaux groupes",
"Decreasing number of members": "Le plus de membres",
"Default": "Défaut",
"Default Mobilizon privacy policy": "Politique de confidentialité par défaut de Mobilizon",
"Default Mobilizon terms": "Conditions d'utilisation par défaut de Mobilizon",
@ -369,21 +375,21 @@
"Edit": "Modifier",
"Edit post": "Éditer le billet",
"Edit profile {profile}": "Éditer le profil {profile}",
"Edit user email": "Éditer l'email de l'utilisateur·ice",
"Edit user email": "Éditer l'e-mail de l'utilisateur·ice",
"Edited {ago}": "Édité il y a {ago}",
"Edited {relative_time} ago": "Édité il y a {relative_time}",
"Eg: Stockholm, Dance, Chess…": "Par exemple : Lyon, Danse, Bridge…",
"Either on the {instance} instance or on another instance.": "Sur l'instance {instance} ou bien sur une autre instance.",
"Either the account is already validated, either the validation token is incorrect.": "Soit le compte est déjà validé, soit le jeton de validation est incorrect.",
"Either the email has already been changed, either the validation token is incorrect.": "Soit l'adresse email a déjà été modifiée, soit le jeton de validation est incorrect.",
"Either the email has already been changed, either the validation token is incorrect.": "Soit l'adresse e-mail a déjà été modifiée, soit le jeton de validation est incorrect.",
"Either the participation request has already been validated, either the validation token is incorrect.": "Soit la demande de participation a déjà été validée, soit le jeton de validation est incorrect.",
"Either your participation has already been cancelled, either the validation token is incorrect.": "Soit votre participation a déjà été annulée, soit le jeton de validation est incorrect.",
"Element title": "Titre de l'élement",
"Element value": "Valeur de l'élement",
"Email": "Courriel",
"Email address": "Adresse email",
"Email validate": "Validation de l'email",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les emails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe.",
"Email address": "Adresse e-mail",
"Email validate": "Validation de l'e-mail",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les e-mails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe.",
"Enabled": "Activé",
"Ends on…": "Se termine le…",
"Enter the code displayed on your device": "Saisissez le code affiché sur votre appareil",
@ -397,7 +403,7 @@
"Error stacktrace": "Trace d'appels de l'erreur",
"Error while adding tag: {error}": "Erreur lors de l'ajout d'un tag : {error}",
"Error while cancelling your participation": "Erreur lors de l'annulation de votre participation",
"Error while changing email": "Erreur lors de la modification de l'adresse email",
"Error while changing email": "Erreur lors de la modification de l'adresse e-mail",
"Error while loading the preview": "Erreur lors du chargement de l'aperçu",
"Error while login with {provider}. Retry or login another way.": "Erreur lors de la connexion avec {provider}. Réessayez ou bien connectez vous autrement.",
"Error while login with {provider}. This login provider doesn't exist.": "Erreur lors de la connexion avec {provider}. Cette méthode de connexion n'existe pas.",
@ -484,6 +490,7 @@
"From the {startDate} at {startTime} to the {endDate}": "Du {startDate} à {startTime} jusqu'au {endDate}",
"From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}",
"From the {startDate} to the {endDate}": "Du {startDate} au {endDate}",
"From the {startDate} to the {endDate} at {endTime}": "Du {startDate} au {endDate} à {endTime}",
"From this instance only": "Depuis cette instance uniquement",
"From yourself": "De vous",
"Fully accessible with a wheelchair": "Entièrement accessible avec un fauteuil roulant",
@ -527,10 +534,12 @@
"Headline picture": "Image à la une",
"Hide filters": "Masquer les filtres",
"Hide replies": "Masquer les réponses",
"Hide the number of participants":"Cacher le nombre de participants",
"Home": "Accueil",
"Home to {number} users": "Abrite {number} utilisateur·rice·s",
"Homepage": "Page d'accueil",
"Hourly email summary": "E-mail récapitulatif chaque heure",
"How to register":"Gestion des participants",
"I agree to the {instanceRules} and {termsOfService}": "J'accepte les {instanceRules} et les {termsOfService}",
"I create an identity": "Je crée une identité",
"I don't have a Mobilizon account": "Je n'ai pas de compte Mobilizon",
@ -540,7 +549,8 @@
"I participate": "Je participe",
"I want to allow people to participate without an account.": "Je veux permettre aux gens de participer sans avoir un compte.",
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
"I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
"I want to manage the registration on Mobilizon": "Je souhaite gérer les inscriptions avec Mobilizon",
"I want to manage the registration with an external provider": "Je souhaite gérer les inscriptions auprès d'un fournisseur externe",
"I've been mentionned in a comment under an event": "J'ai été mentionné·e dans un commentaire sous un événement",
"I've been mentionned in a conversation": "J'ai été mentionnée dans une conversation",
"I've been mentionned in a group discussion": "J'ai été mentionné·e dans une discussion d'un groupe",
@ -553,10 +563,10 @@
"Identity {displayName} deleted": "Identité {displayName} supprimée",
"Identity {displayName} updated": "Identité {displayName} mise à jour",
"If allowed by organizer": "Si autorisé par l'organisateur·rice",
"If an account with this email exists, we just sent another confirmation email to {email}": "Si un compte avec un tel email existe, nous venons juste d'envoyer un nouvel email de confirmation à {email}",
"If an account with this email exists, we just sent another confirmation email to {email}": "Si un compte avec un tel e-mail existe, nous venons juste d'envoyer un nouvel e-mail de confirmation à {email}",
"If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "Si cette identité est la seule administratrice de certains groupes, vous devez les supprimer avant de pouvoir supprimer cette identité.",
"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:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :",
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.": "Si vous avez opté pour la validation manuelle des participantes, Mobilizon vous enverra un email pour vous informer des nouvelles participations à traiter. Vous pouvez choisir la fréquence de ces notifications ci-dessous.",
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.": "Si vous avez opté pour la validation manuelle des participantes, Mobilizon vous enverra un e-mail pour vous informer des nouvelles participations à traiter. Vous pouvez choisir la fréquence de ces notifications ci-dessous.",
"If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur·ice de l'événement ci-dessous.",
"Ignore": "Ignorer",
"Illustration picture for “{category}” by {author} on {source} ({license})": "Image d'illustration pour “{category}” par {author} sur {source} ({license})",
@ -565,6 +575,8 @@
"In the past": "Dans le passé",
"In this instance's network": "Dans le réseau de cette instance",
"Increase": "Augmenter",
"Increasing creation date": "Date de création croissante",
"Increasing number of members": "Nombre croissant de membres",
"Instance": "Instance",
"Instance Long Description": "Description longue de l'instance",
"Instance Name": "Nom de l'instance",
@ -605,6 +617,7 @@
"Keyword, event title, group name, etc.": "Mot clé, titre d'un événement, nom d'un groupe, etc.",
"Language": "Langue",
"Languages": "Langues",
"Last event activity": "Événements récents",
"Last IP adress": "Dernière adresse IP",
"Last group created": "Dernier groupe créé",
"Last published event": "Dernier événement publié",
@ -633,8 +646,10 @@
"Live": "Direct",
"Load more": "Voir plus",
"Load more activities": "Charger plus d'activités",
"Loading…": "Chargement…",
"Loading comments…": "Chargement des commentaires…",
"Loading map": "Chargement de la carte",
"Loading search results...": "Chargement des résultats...",
"Local": "Local·e",
"Local time ({timezone})": "Heure locale ({timezone})",
"Local times ({timezone})": "Heures locales ({timezone})",
@ -682,7 +697,7 @@
"Mobilizon software": "logiciel Mobilizon",
"Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon utilise un système de profils pour compartimenter vos activités. Vous pourrez créer autant de profils que vous voulez.",
"Mobilizon version": "Version de Mobilizon",
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc.": "Mobilizon vous enverra un email lors de changements importants pour les événements auxquels vous participez : date et heure, adresse, confirmation ou annulation, etc.",
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc.": "Mobilizon vous enverra un e-mail lors de changements importants pour les événements auxquels vous participez : date et heure, adresse, confirmation ou annulation, etc.",
"Moderate new members": "Modérer les nouvelles et nouveaux membres",
"Moderated comments (shown after approval)": "Commentaires modérés (affichés après validation)",
"Moderation": "Modération",
@ -720,7 +735,8 @@
"Next month": "Le mois-prochain",
"Next page": "Page suivante",
"Next week": "La semaine prochaine",
"No activities found": "Aucun activité trouvé",
"No about content yet":"À propos n'est pas encore renseigné",
"No activities found": "Aucune activité trouvée",
"No address defined": "Aucune adresse définie",
"No apps authorized yet": "Aucune application autorisée pour le moment",
"No categories with public upcoming events on this instance were found.": "Aucune catégorie avec des événements publics à venir n'a été trouvée.",
@ -747,12 +763,13 @@
"No instance to remove|Remove instance|Remove {number} instances": "Pas d'instances à supprimer|Supprimer une instance|Supprimer {number} instances",
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
"No languages found": "Aucune langue trouvée",
"No location yet":"Localisation non renseignée",
"No member matches the filters": "Aucun·e membre ne correspond aux filtres",
"No members found": "Aucun·e membre trouvé·e",
"No memberships found": "Aucune adhésion trouvée",
"No message": "Pas de message",
"No moderation logs yet": "Pas encore de journaux de modération",
"No more activity to display.": "Il n'y a plus d'activités à afficher.",
"No more activity to display.": "Il n'y a plus d'activité à afficher.",
"No one is participating|One person participating|{going} people participating": "Personne ne participe|Une personne participe|{going} personnes participent",
"No open reports yet": "Aucun signalement ouvert pour le moment",
"No organized events found": "Aucun événement organisé trouvé",
@ -764,6 +781,7 @@
"No posts found": "Aucun billet trouvé",
"No posts yet": "Pas encore de billets",
"No profile matches the filters": "Aucun profil ne correspond aux filtres",
"No public upcoming activities": "Aucune activité publique à venir",
"No public upcoming events": "Aucun événement public à venir",
"No resolved reports yet": "Aucun signalement résolu pour le moment",
"No resources in this folder": "Aucune ressource dans ce dossier",
@ -796,7 +814,7 @@
"On foot": "À pied",
"On the Fediverse": "Dans le fediverse",
"On {date}": "Le {date}",
"On {date} ending at {endTime}": "Le {date}, se terminant à {endTime}",
"On {date} ending at {endTime}": "Le {date} jusqu'à {endTime}",
"On {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
"On {date} starting at {startTime}": "Le {date} à partir de {startTime}",
"On {instance} and other federated instances": "Sur {instance} et d'autres instances fédérées",
@ -847,7 +865,7 @@
"Participants": "Participant·e·s",
"Participants to {eventTitle}": "Participant·es à {eventTitle}",
"Participate": "Participer",
"Participate using your email address": "Participer en utilisant votre adresse email",
"Participate using your email address": "Participer en utilisant votre adresse e-mail",
"Participation approval": "Validation des participations",
"Participation confirmation": "Confirmation de votre participation",
"Participation notifications": "Notifications de participation",
@ -869,7 +887,7 @@
"Pick an identity": "Choisissez une identité",
"Pick an instance": "Choisir une instance",
"Please add as many details as possible to help identify the problem.": "Merci d'ajouter un maximum de détails afin d'aider à identifier le problème.",
"Please check your spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'email.",
"Please check your spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'e-mail.",
"Please contact this instance's Mobilizon admin if you think this is a mistake.": "Veuillez contacter l'administrateur·rice de cette instance Mobilizon si vous pensez quil sagit dune erreur.",
"Please do not use it in any real way.": "Merci de ne pas en faire une utilisation réelle.",
"Please enter your password to confirm this action.": "Merci d'entrer votre mot de passe pour confirmer cette action.",
@ -890,7 +908,7 @@
"Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}.": "Propulsé par {mobilizon}. © 2018 - {date} Les contributeur·rice·s Mobilizon - Fait avec le soutien financier de {contributors}.",
"Preferences": "Préférences",
"Previous": "Précédent",
"Previous email": "Email précédent",
"Previous email": "E-mail précédent",
"Previous month": "Mois précédent",
"Previous page": "Page précédente",
"Price sheet": "Feuille des prix",
@ -985,8 +1003,8 @@
"Reports": "Signalements",
"Reports list": "Liste des signalements",
"Request for participation confirmation sent": "Demande de confirmation de participation envoyée",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Resent confirmation email": "Réenvoi de l'email de confirmation",
"Resend confirmation email": "Envoyer à nouveau l'e-mail de confirmation",
"Resent confirmation email": "Réenvoi de l'e-mail de confirmation",
"Reset": "Remettre à zéro",
"Reset filters": "Réinitialiser les filtres",
"Reset my password": "Réinitialiser mon mot de passe",
@ -1016,15 +1034,16 @@
"Select a radius": "Sélectionnez un rayon",
"Select a timezone": "Selectionnez un fuseau horaire",
"Select all resources": "Sélectionner toutes les ressources",
"Select distance": "Sélectionner la distance",
"Select languages": "Choisissez une langue",
"Select the activities for which you wish to receive an email or a push notification.": "Sélectionnez les activités pour lesquelles vous souhaitez recevoir un email ou une notification push.",
"Select the activities for which you wish to receive an email or a push notification.": "Sélectionnez les activités pour lesquelles vous souhaitez recevoir un e-mail ou une notification push.",
"Select this resource": "Sélectionner cette ressource",
"Send": "Envoyer",
"Send email": "Envoyer un email",
"Send email": "Envoyer un e-mail",
"Send feedback": "Envoyer vos remarques",
"Send notification e-mails": "Envoyer des e-mails de notification",
"Send password reset": "Envoi de la réinitalisation du mot de passe",
"Send the confirmation email again": "Envoyer l'email de confirmation à nouveau",
"Send the confirmation email again": "Envoyer l'e-mail de confirmation à nouveau",
"Send the report": "Envoyer le signalement",
"Sent to {count} participants": "Envoyé à aucun·e participant·e|Envoyé à une participant·e|Envoyé à {count} participant·es",
"Set an URL to a page with your own privacy policy.": "Entrez une URL vers une page web avec votre propre politique de confidentialité.",
@ -1043,6 +1062,7 @@
"Show the time when the event ends": "Afficher l'heure de fin de l'événement",
"Showing events before": "Afficher les événements avant",
"Showing events starting on": "Afficher les événements à partir de",
"Showing participants":"Affichage des participants",
"Sign Language": "Langue des signes",
"Sign in with": "Se connecter avec",
"Sign up": "S'enregistrer",
@ -1090,7 +1110,7 @@
"The URL where the event can be watched live": "L'URL où l'événement peut être visionné en direct",
"The URL where the event live can be watched again after it has ended": "L'URL où le direct de l'événement peut être visionné à nouveau une fois terminé",
"The Zoom video teleconference URL": "L'URL de visio-conférence Zoom",
"The account's email address was changed. Check your emails to verify it.": "L'adresse email du compte a été modifiée. Vérifiez vos emails pour confirmer le changement.",
"The account's email address was changed. Check your emails to verify it.": "L'adresse e-mail du compte a été modifiée. Vérifiez vos e-mails pour confirmer le changement.",
"The actual number of participants may differ, as this event is hosted on another instance.": "Le nombre réel de participant·e·s peut être différent, car cet événement provient d'une autre instance.",
"The calc will be created on {service}": "Le calc sera créé sur {service}",
"The content came from another server. Transfer an anonymous copy of the report?": "Le contenu provient d'une autre instance. Transférer une copie anonyme du signalement ?",
@ -1161,7 +1181,7 @@
"These events may interest you": "Ces événements peuvent vous intéresser",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un·e participant·e ou un·e créateur·ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un·e participant·e ou un·e créateur·ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
"This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.": "Cette instance Mobilizon et l'organisateur·ice de l'événement autorise les participations anonymes, mais requiert une validation à travers une confirmation par email.",
"This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.": "Cette instance Mobilizon et l'organisateur·ice de l'événement autorise les participations anonymes, mais requiert une validation à travers une confirmation par e-mail.",
"This URL doesn't seem to be valid": "Cette URL ne semble pas être valide",
"This URL is not supported": "Cette URL n'est pas supportée",
"This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.": "Cette annonce sera envoyée à tous les participant·es ayant le statut sélectionné ci-dessous. Iels ne pourront pas répondre à votre annonce, mais iels peuvent créer une nouvelle conversation avec vous.",
@ -1170,7 +1190,7 @@
"This application will be able to access all of your informations and post content. Make sure you only approve applications you trust.": "Cette application sera capable d'accéder à toutes vos informations et poster du contenu. Assurez-vous d'approuver uniquement des applications en lesquelles vous avez confiance.",
"This application will be allowed to access all of the groups you're a member of": "Cette application pourra accéder à tous les groupes dont vous êtes membres",
"This application will be allowed to access group activities in all of the groups you're a member of": "This application will be allowed to access group activities in all of the groups you're a member of",
"This application will be allowed to access your user activity settings": "Cette application sera autorisée a accéder à vos paramètres utilisateur·ice d'activité",
"This application will be allowed to access your user activity settings": "Cette application sera autorisée à accéder à vos paramètres utilisateur·ice d'activité",
"This application will be allowed to access your user settings": "Cette application sera autorisée a accéder à vos paramètres utilisateur·ice",
"This application will be allowed to create feed tokens": "This application will be allowed to create feed tokens",
"This application will be allowed to create group discussions": "This application will be allowed to create group discussions",
@ -1248,6 +1268,7 @@
"Time in your timezone ({timezone})": "Heure dans votre fuseau horaire ({timezone})",
"Times in your timezone ({timezone})": "Heures dans votre fuseau horaire ({timezone})",
"Timezone": "Fuseau horaire",
"Timezone parameters": "Paramétrer le fuseau horaire",
"Timezone detected as {timezone}.": "Fuseau horaire détecté en tant que {timezone}.",
"Title": "Titre",
"To activate more notifications, head over to the notification settings.": "Pour activer plus de notifications, rendez-vous dans vos paramètres de notification.",
@ -1321,7 +1342,7 @@
"Username": "Identifiant",
"Users": "Utilisateur·rice·s",
"Validating account": "Validation du compte",
"Validating email": "Validation de l'email",
"Validating email": "Validation de l'e-mail",
"Video Conference": "Visio-conférence",
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses",
"View account on {hostname} (in a new window)": "Voir le compte sur {hostname} (dans une nouvelle fenêtre)",
@ -1339,6 +1360,7 @@
"View more groups around {position}": "Voir plus de groupes près de {position}",
"View more online events": "Voir plus d'événements en ligne",
"View page on {hostname} (in a new window)": "Voir la page sur {hostname} (dans une nouvelle fenêtre)",
"View past activities": "Voir les activités passées",
"View past events": "Voir les événements passés",
"View the group profile on the original instance": "Afficher le profil du groupe sur l'instance d'origine",
"Visibility was set to an unknown value.": "La visibilité a été définie à une valeur inconnue.",
@ -1352,11 +1374,11 @@
"We collect your feedback and the error information in order to improve this service.": "Nous recueillons vos réactions et les informations sur les erreurs afin d'améliorer ce service.",
"We couldn't save your participation inside this browser. Not to worry, you have successfully confirmed your participation, we just couldn't save it's status in this browser because of a technical issue.": "Nous n'avons pas pu sauvegarder votre participation dans ce navigateur. Aucune inquiétude, vous avez bien confirmé votre participation, nous n'avons juste pas pu enregistrer son statut dans ce navigateur à cause d'un souci technique.",
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):": "Nous améliorons ce logiciel grâce à vos retours. Pour nous avertir de ce problème, vous avez deux possibilités (les deux requièrent toutefois la création d'un compte) :",
"We just sent an email to {email}": "Nous venons d'envoyer un email à {email}",
"We just sent an email to {email}": "Nous venons d'envoyer un e-mail à {email}",
"We use your timezone to make sure you get notifications for an event at the correct time.": "Nous utilisons votre fuseau horaire pour nous assurer que vous recevez les notifications pour un événement au bon moment.",
"We will redirect you to your instance in order to interact with this event": "Nous vous redirigerons vers votre instance pour interagir avec cet événement",
"We will redirect you to your instance in order to interact with this group": "Nous vous redirigerons vers votre instance afin que vous puissiez interagir avec ce groupe",
"We'll send you an email one hour before the event begins, to be sure you won't forget about it.": "Nous vous enverrons un email une heure avant que l'événement débute, pour être sûr que vous ne l'oubliez pas.",
"We'll send you an email one hour before the event begins, to be sure you won't forget about it.": "Nous vous enverrons un e-mail une heure avant que l'événement débute, pour être sûr que vous ne l'oubliez pas.",
"We'll use your timezone settings to send a recap of the morning of the event.": "Nous prendrons en compte votre fuseau horaire pour vous envoyer un récapitulatif de vos événements le matin.",
"Website": "Site web",
"Website / URL": "Site web / URL",
@ -1408,7 +1430,7 @@
"You can try another search term or drag and drop the marker on the map": "Vous pouvez essayer avec d'autres termes de recherche ou bien glisser et déposer le marqueur sur la carte",
"You can't change your password because you are registered through {provider}.": "Vous ne pouvez pas changer votre mot de passe car vous vous êtes enregistré via {provider}.",
"You can't use push notifications in this browser.": "Vous ne pouvez pas utiliser les notifications push dans ce navigateur.",
"You changed your email or password": "Vous avez modifié votre email ou votre mot de passe",
"You changed your email or password": "Vous avez modifié votre e-mail ou votre mot de passe",
"You created the discussion {discussion}.": "Vous avez créé la discussion {discussion}.",
"You created the event {event}.": "Vous avez créé l'événement {event}.",
"You created the folder {resource}.": "Vous avez créé le dossier {resource}.",
@ -1481,7 +1503,7 @@
"You'll get a weekly recap every Monday for upcoming events, if you have any.": "Vous recevrez un récapitulatif hebdomadaire chaque lundi pour les événements de la semaine, si vous en avez.",
"You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
"You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines.": "Vous aurez besoin de transmettre l'URL du groupe pour que d'autres personnes accèdent au profil du groupe. Le groupe ne sera pas trouvable dans la recherche de Mobilizon ni dans les moteurs de recherche habituels.",
"You'll receive a confirmation email.": "Vous recevrez un email de confirmation.",
"You'll receive a confirmation email.": "Vous recevrez un e-mail de confirmation.",
"YouTube live": "Direct sur YouTube",
"YouTube replay": "Replay sur YouTube",
"Your account has been successfully deleted": "Votre compte a été supprimé avec succès",
@ -1492,10 +1514,10 @@
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area.": "Votre ville ou région et le rayon seront uniquement utilisés pour vous suggérer des événements proches. Le rayon des événements proches sera calculé par rapport au centre administratif de la zone.",
"Your current email is {email}. You use it to log in.": "Votre adresse e-mail actuelle est {email}. Vous l'utilisez pour vous connecter.",
"Your email": "Votre adresse e-mail",
"Your email address was automatically set based on your {provider} account.": "Votre adresse email a été définie automatiquement en se basant sur votre compte {provider}.",
"Your email has been changed": "Votre adresse email a bien été modifiée",
"Your email is being changed": "Votre adresse email est en train d'être modifiée",
"Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.": "Votre email sera uniquement utilisé pour confirmer que vous êtes bien une personne réelle et vous envoyer des éventuelles mises à jour pour cet événement. Il ne sera PAS transmis à d'autres instances ou à l'organisateur de l'événement.",
"Your email address was automatically set based on your {provider} account.": "Votre adresse e-mail a été définie automatiquement en se basant sur votre compte {provider}.",
"Your email has been changed": "Votre adresse e-mail a bien été modifiée",
"Your email is being changed": "Votre adresse e-mail est en train d'être modifiée",
"Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.": "Votre e-mail sera uniquement utilisé pour confirmer que vous êtes bien une personne réelle et vous envoyer des éventuelles mises à jour pour cet événement. Il ne sera PAS transmis à d'autres instances ou à l'organisateur de l'événement.",
"Your federated identity": "Votre identité fédérée",
"Your membership is pending approval": "Votre adhésion est en attente d'approbation",
"Your membership was approved by {profile}.": "Votre demande d'adhésion a été approuvée par {profile}.",
@ -1531,6 +1553,7 @@
"as {identity}": "en tant que {identity}",
"contact uninformed": "contact non renseigné",
"create a group": "créer un groupe",
"create an activity": "créer une activité",
"create an event": "créer un événement",
"default Mobilizon privacy policy": "politique de confidentialité par défaut de Mobilizon",
"default Mobilizon terms": "conditions d'utilisation par défaut de Mobilizon",
@ -1562,14 +1585,14 @@
"{'@'}{username}": "{'@'}{username}",
"{'@'}{username} ({role})": "{'@'}{username} ({role})",
"{approved} / {total} seats": "{approved} / {total} places",
"{available}/{capacity} available places": "Pas de places restantes|{available}/{capacity} places restantes|{available}/{capacity} places restantes",
"{available}/{capacity} available places": "Pas de places restantes|{available}/{capacity} place restante|{available}/{capacity} places restantes",
"{count} events": "{count} événements",
"{count} km": "{count} km",
"{count} members": "Aucun membre|Un·e membre|{count} membres",
"{count} members or followers": "Aucun·e membre ou abonné·e|Un·e membre ou abonné·e|{count} membres ou abonné·es",
"{count} participants": "Aucun·e participant·e | Un·e participant·e | {count} participant·e·s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{eventsCount} activities found": "Aucune activité trouvé|Une activité trouvé|{eventsCount} activités trouvés",
"{eventsCount} activities found": "Aucune activité trouvée|Une activité trouvée|{eventsCount} activités trouvées",
"{eventsCount} events found": "Aucun événement trouvé|Un événement trouvé|{eventsCount} événements trouvés",
"{folder} - Resources": "{folder} - Ressources",
"{groupsCount} groups found": "Aucun groupe trouvé|Un groupe trouvé|{groupsCount} groupes trouvés",
@ -1608,7 +1631,7 @@
"{number} organized events": "Aucun événement organisé|Un événement organisé|{number} événements organisés",
"{number} participations": "Aucune participation|Une participation|{number} participations",
"{number} posts": "Aucun billet|Un billet|{number} billets",
"{number} seats left": "{number} places restantes",
"{number} seats left": "Aucune place restante|Une place restante|{number} places restantes",
"{old_group_name} was renamed to {group}.": "{old_group_name} a été renommé en {group}.",
"{profileName} (suspended)": "{profileName} (suspendu·e)",
"{profile} (by default)": "{profile} (par défault)",

View file

@ -132,9 +132,7 @@ export enum SearchTabs {
}
export enum ContentType {
ALL = "ALL",
EVENTS = "EVENTS",
SHORTEVENTS = "SHORTEVENTS",
LONGEVENTS = "LONGEVENTS",
GROUPS = "GROUPS",
}

View file

@ -71,6 +71,7 @@ export interface IEvent {
beginsOn: string;
endsOn: string | null;
publishAt: string;
longEvent: boolean;
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
@ -144,6 +145,8 @@ export class EventModel implements IEvent {
publishAt = new Date().toISOString();
longEvent = false;
language = "und";
participantStats = {

View file

@ -9,9 +9,19 @@ const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
export const addressToLocation = (
address: IAddress
): LocationType | undefined => {
if (!address.geom) return undefined;
if (!address.geom)
return {
lon: undefined,
lat: undefined,
name: undefined,
};
const arr = address.geom.split(";");
if (arr.length < 2) return undefined;
if (arr.length < 2)
return {
lon: undefined,
lat: undefined,
name: undefined,
};
return {
lon: parseFloat(arr[0]),
lat: parseFloat(arr[1]),
@ -19,6 +29,17 @@ export const addressToLocation = (
};
};
export const locationToAddress = (location: LocationType): IAddress | null => {
if (location.lon && location.lat) {
const new_add = new Address();
new_add.geom = location.lon.toString() + ";" + location.lat.toString();
new_add.description = location.name || "";
console.debug("locationToAddress", location, new_add);
return new_add;
}
return null;
};
export const coordsToGeoHash = (
lat: number | undefined,
lon: number | undefined,
@ -38,44 +59,24 @@ export const geoHashToCoords = (
return latitude && longitude ? { latitude, longitude } : undefined;
};
export const storeLocationInLocal = (location: IAddress | null): undefined => {
if (location) {
window.localStorage.setItem("location", JSON.stringify(location));
export const storeAddressInLocal = (address: IAddress | null): undefined => {
if (address) {
window.localStorage.setItem("address", JSON.stringify(address));
} else {
window.localStorage.removeItem("location");
window.localStorage.removeItem("address");
}
};
export const getLocationFromLocal = (): IAddress | null => {
const locationString = window.localStorage.getItem("location");
if (!locationString) {
export const getAddressFromLocal = (): IAddress | null => {
const addressString = window.localStorage.getItem("address");
if (!addressString) {
return null;
}
const location = JSON.parse(locationString) as IAddress;
if (!location.description || !location.geom) {
const address = JSON.parse(addressString) as IAddress;
if (!address.description || !address.geom) {
return null;
}
return location;
};
export const storeRadiusInLocal = (radius: number | null): undefined => {
if (radius) {
window.localStorage.setItem("radius", radius.toString());
} else {
window.localStorage.removeItem("radius");
}
};
export const getRadiusFromLocal = (): IAddress | null => {
const locationString = window.localStorage.getItem("location");
if (!locationString) {
return null;
}
const location = JSON.parse(locationString) as IAddress;
if (!location.description || !location.geom) {
return null;
}
return location;
return address;
};
export const storeUserLocationAndRadiusFromUserSettings = (
@ -84,18 +85,13 @@ export const storeUserLocationAndRadiusFromUserSettings = (
if (location) {
const latlon = geoHashToCoords(location.geohash);
if (latlon) {
storeLocationInLocal({
storeAddressInLocal({
...new Address(),
geom: `${latlon.longitude};${latlon.latitude}`,
description: location.name || "",
type: "administrative",
});
}
if (location.range) {
storeRadiusInLocal(location.range);
} else {
console.debug("user has not set a radius");
}
} else {
console.debug("user has not set a location");
}

View file

@ -61,56 +61,46 @@
</div>
<o-field
horizontal
grouped
groupMultiline
:label="t('Starts on…')"
class="items-center"
label-for="begins-on-field"
>
<o-datetimepicker
class="datepicker starts-on"
:placeholder="t('Type or select a date…')"
icon="calendar-today"
:locale="$i18n.locale.replace('_', '-')"
<event-date-picker
:time="showStartTime"
v-model="beginsOn"
horizontal-time-picker
:tz-offset="tzOffset(beginsOn)"
:first-day-of-week="firstDayOfWeek"
:datepicker="{
id: 'begins-on-field',
'aria-next-label': t('Next month'),
'aria-previous-label': t('Previous month'),
}"
>
</o-datetimepicker>
@blur="consistencyBeginsOnBeforeEndsOn"
></event-date-picker>
<div class="my-2">
<o-switch v-model="showStartTime">{{
t("Show the time when the event begins")
}}</o-switch>
</div>
</o-field>
<o-field
horizontal
grouped
groupMultiline
:label="t('Ends on…')"
label-for="ends-on-field"
class="items-center"
>
<o-datetimepicker
class="datepicker ends-on"
:placeholder="t('Type or select a date…')"
icon="calendar-today"
:locale="$i18n.locale.replace('_', '-')"
<event-date-picker
:time="showEndTime"
v-model="endsOn"
horizontal-time-picker
:min-datetime="beginsOn"
:tz-offset="tzOffset(endsOn)"
:first-day-of-week="firstDayOfWeek"
:datepicker="{
id: 'ends-on-field',
'aria-next-label': t('Next month'),
'aria-previous-label': t('Previous month'),
}"
>
</o-datetimepicker>
@blur="consistencyBeginsOnBeforeEndsOn"
:min="beginsOn"
></event-date-picker>
<div class="my-2">
<o-switch v-model="showEndTime">{{
t("Show the time when the event ends")
}}</o-switch>
</div>
</o-field>
<o-button class="block" variant="text" @click="dateSettingsIsOpen = true">
{{ t("Date parameters") }}
{{ t("Timezone parameters") }}
</o-button>
<div class="my-6">
@ -396,7 +386,7 @@
<section class="my-4">
<h2>{{ t("Status") }}</h2>
<fieldset>
<fieldset id="status">
<legend>
{{
t(
@ -476,7 +466,7 @@
>
<form class="p-3">
<header class="">
<h2 class="">{{ t("Date and time settings") }}</h2>
<h2 class="">{{ t("Timezone") }}</h2>
</header>
<section class="">
<p>
@ -486,7 +476,7 @@
)
}}
</p>
<o-field :label="t('Timezone')" label-for="timezone" expanded>
<o-field expanded>
<o-select
:placeholder="t('Select a timezone')"
:loading="timezoneLoading"
@ -517,16 +507,6 @@
:title="t('Clear timezone field')"
/>
</o-field>
<o-field :label="t('Event page settings')">
<o-switch v-model="eventOptions.showStartTime">{{
t("Show the time when the event begins")
}}</o-switch>
</o-field>
<o-field>
<o-switch v-model="eventOptions.showEndTime">{{
t("Show the time when the event ends")
}}</o-switch>
</o-field>
</section>
<footer class="mt-2">
<o-button @click="dateSettingsIsOpen = false">
@ -544,40 +524,45 @@
v-if="hasCurrentActorPermissionsToEdit"
>
<div class="container mx-auto">
<div class="flex justify-between items-center">
<span class="dark:text-gray-900" v-if="isEventModified">
<div class="lg:flex lg:justify-between lg:items-center lg:flex-wrap">
<div
class="text-red-900 text-center w-full margin m-1 lg:m-0 lg:w-auto lg:text-left"
v-if="isEventModified"
>
{{ t("Unsaved changes") }}
</span>
</div>
<div class="flex flex-wrap gap-3 items-center justify-end">
<o-button
expanded
variant="text"
@click="confirmGoBack"
class="dark:!text-black ml-auto"
>{{ t("Cancel") }}</o-button
>
<!-- If an event has been published we can't make it draft anymore -->
<span class="" v-if="event.draft === true">
<o-button
variant="primary"
class="!text-black hover:!text-white"
outlined
@click="createOrUpdateDraft"
:disabled="saving"
>{{ t("Save draft") }}</o-button
>
</span>
<span class="ml-auto">
<o-button
variant="primary"
:disabled="saving"
@click="createOrUpdatePublish"
@keyup.enter="createOrUpdatePublish"
>
<span v-if="isUpdate === false">{{ t("Create my event") }}</span>
<span v-else-if="event.draft === true">{{ t("Publish") }}</span>
<span v-else>{{ t("Update my event") }}</span>
</o-button>
</span>
<o-button
v-if="event.draft === true"
expanded
variant="primary"
class="!text-black hover:!text-white"
outlined
@click="createOrUpdateDraft"
:disabled="saving"
:loading="saving"
>{{ t("Save draft") }}</o-button
>
<o-button
expanded
variant="primary"
:disabled="saving"
:loading="saving"
@click="createOrUpdatePublish"
@keyup.enter="createOrUpdatePublish"
>
<span v-if="isUpdate === false">{{ t("Create my event") }}</span>
<span v-else-if="event.draft === true">{{ t("Publish") }}</span>
<span v-else>{{ t("Update my event") }}</span>
</o-button>
</div>
</div>
</div>
@ -649,7 +634,7 @@ import {
useCurrentUserIdentities,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { useUserSettings } from "@/composition/apollo/user";
import { useLoggedUser } from "@/composition/apollo/user";
import {
computed,
inject,
@ -672,16 +657,16 @@ import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
import { useHead } from "@/utils/head";
import { useOruga } from "@oruga-ui/oruga-next";
import type { Locale } from "date-fns";
import sortBy from "lodash/sortBy";
import { escapeHtml } from "@/utils/html";
import EventDatePicker from "@/components/Event/EventDatePicker.vue";
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
const { eventCategories } = useEventCategories();
const { anonymousParticipationConfig } = useAnonymousParticipationConfig();
const { currentActor } = useCurrentActorClient();
const { loggedUser } = useUserSettings();
const { loggedUser } = useLoggedUser();
const { identities } = useCurrentUserIdentities();
const { features } = useFeatures();
@ -724,11 +709,27 @@ const dateSettingsIsOpen = ref(false);
const saving = ref(false);
const initializeEvent = () => {
const setEventTimezoneToUserTimezoneIfUnset = () => {
if (userTimezone.value && event.value.options.timezone == null) {
event.value.options.timezone = userTimezone.value;
}
};
// usefull if the page is loaded from scratch
watch(loggedUser, setEventTimezoneToUserTimezoneIfUnset);
const initializeNewEvent = () => {
// usefull if the data is already cached
setEventTimezoneToUserTimezoneIfUnset();
// Default values for beginsOn and endsOn
const roundUpTo15Minutes = (time: Date) => {
time.setMilliseconds(Math.round(time.getMilliseconds() / 1000) * 1000);
time.setSeconds(Math.round(time.getSeconds() / 60) * 60);
time.setMinutes(Math.round(time.getMinutes() / 15) * 15);
time.setUTCMilliseconds(
Math.round(time.getUTCMilliseconds() / 1000) * 1000
);
time.setUTCSeconds(Math.round(time.getUTCSeconds() / 60) * 60);
time.setUTCMinutes(Math.round(time.getUTCMinutes() / 15) * 15);
return time;
};
@ -737,8 +738,15 @@ const initializeEvent = () => {
end.setUTCHours(now.getUTCHours() + 3);
event.value.beginsOn = now.toISOString();
event.value.endsOn = end.toISOString();
beginsOn.value = now;
endsOn.value = end;
// Default values for showStartTime and showEndTime
showStartTime.value = false;
showEndTime.value = false;
// Default values for hideParticipants
hideParticipants.value = true;
};
const organizerActor = computed({
@ -796,7 +804,7 @@ onMounted(async () => {
pictureFile.value = await buildFileFromIMedia(event.value.picture);
limitedPlaces.value = eventOptions.value.maximumAttendeeCapacity > 0;
if (!(props.isUpdate || props.isDuplicate)) {
initializeEvent();
initializeNewEvent();
} else {
event.value = new EventModel({
...event.value,
@ -1209,42 +1217,93 @@ const isEventModified = computed((): boolean => {
);
});
const beginsOn = computed({
get(): Date | null {
// if (this.timezone && this.event.beginsOn) {
// return utcToZonedTime(this.event.beginsOn, this.timezone);
// }
return event.value.beginsOn ? new Date(event.value.beginsOn) : null;
const showStartTime = computed({
get(): boolean {
return event.value.options.showStartTime;
},
set(newBeginsOn: Date | null) {
event.value.beginsOn = newBeginsOn?.toISOString() ?? null;
if (!event.value.endsOn || !newBeginsOn) return;
const dateBeginsOn = new Date(newBeginsOn);
const dateEndsOn = new Date(event.value.endsOn);
let endsOn = new Date(event.value.endsOn);
if (dateEndsOn < dateBeginsOn) {
endsOn = dateBeginsOn;
endsOn.setHours(dateBeginsOn.getHours() + 1);
}
if (dateEndsOn === dateBeginsOn) {
endsOn.setHours(dateEndsOn.getHours() + 1);
}
event.value.endsOn = endsOn.toISOString();
set(newShowStartTime: boolean) {
event.value.options = {
...event.value.options,
showStartTime: newShowStartTime,
};
},
});
const endsOn = computed({
get(): Date | null {
// if (this.event.endsOn && this.timezone) {
// return utcToZonedTime(this.event.endsOn, this.timezone);
// }
return event.value.endsOn ? new Date(event.value.endsOn) : null;
const showEndTime = computed({
get(): boolean {
return event.value.options.showEndTime;
},
set(newEndsOn: Date | null) {
event.value.endsOn = newEndsOn?.toISOString() ?? null;
set(newshowEndTime: boolean) {
event.value.options = {
...event.value.options,
showEndTime: newshowEndTime,
};
},
});
const beginsOn = ref(new Date());
const endsOn = ref(new Date());
const updateEventDateRelatedToTimezone = () => {
// update event.value.beginsOn taking care of timezone
if (beginsOn.value) {
const dateBeginsOn = new Date(beginsOn.value.getTime());
dateBeginsOn.setUTCMinutes(dateBeginsOn.getUTCMinutes() - tzOffset.value);
event.value.beginsOn = dateBeginsOn.toISOString();
}
if (endsOn.value) {
// update event.value.endsOn taking care of timezone
const dateEndsOn = new Date(endsOn.value.getTime());
dateEndsOn.setUTCMinutes(dateEndsOn.getUTCMinutes() - tzOffset.value);
event.value.endsOn = dateEndsOn.toISOString();
}
};
watch(beginsOn, (newBeginsOn) => {
if (!newBeginsOn) {
event.value.beginsOn = null;
return;
}
// usefull for comparaison
newBeginsOn.setUTCSeconds(0);
newBeginsOn.setUTCMilliseconds(0);
// update event.value.beginsOn taking care of timezone
updateEventDateRelatedToTimezone();
});
watch(endsOn, (newEndsOn) => {
if (!newEndsOn) {
event.value.endsOn = null;
return;
}
// usefull for comparaison
newEndsOn.setUTCSeconds(0);
newEndsOn.setUTCMilliseconds(0);
// update event.value.endsOn taking care of timezone
updateEventDateRelatedToTimezone();
});
/*
For endsOn, we need to check consistencyBeginsOnBeforeEndsOn() at blur
because the datetime-local component update itself immediately
Ex : your event start at 10:00 and stops at 12:00
To type "10" hours, you will first have "1" hours, then "10" hours
So you cannot check consistensy in real time, only onBlur because of the moment we falsely have "1:00"
*/
const consistencyBeginsOnBeforeEndsOn = () => {
// Update endsOn to make sure endsOn is later than beginsOn
if (endsOn.value && beginsOn.value && endsOn.value <= beginsOn.value) {
const newEndsOn = new Date(beginsOn.value);
newEndsOn.setUTCHours(beginsOn.value.getUTCHours() + 1);
endsOn.value = newEndsOn;
}
};
const { timezones: rawTimezones, loading: timezoneLoading } = useTimezones();
const timezones = computed((): Record<string, string[]> => {
@ -1295,25 +1354,44 @@ const timezone = computed({
},
});
// Timezone specified in user settings
const userTimezone = computed((): string | undefined => {
return loggedUser.value?.settings?.timezone;
});
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Timezone specified in user settings or local timezone browser if unavailable
const userActualTimezone = computed((): string => {
if (userTimezone.value) {
return userTimezone.value;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
return browserTimeZone;
});
const tzOffset = (date: Date | null): number => {
if (timezone.value && date) {
const eventUTCOffset = getTimezoneOffset(timezone.value, date);
const localUTCOffset = getTimezoneOffset(userActualTimezone.value, date);
return (eventUTCOffset - localUTCOffset) / (60 * 1000);
const tzOffset = computed((): number => {
if (!timezone.value) {
return 0;
}
return 0;
};
const date = new Date();
// diff between UTC and selected timezone
// example: Asia/Shanghai is + 8 hours
const eventUTCOffset = getTimezoneOffset(timezone.value, date);
// diff between UTC and local browser timezone
// example: Europe/Paris is + 1 hour (or +2 depending of daylight saving time)
const localUTCOffset = getTimezoneOffset(browserTimeZone, date);
// example : offset is 8-1=7
return (eventUTCOffset - localUTCOffset) / (60 * 1000);
});
watch(tzOffset, () => {
// tzOffset has been changed, we need to update the event dates
updateEventDateRelatedToTimezone();
});
const eventPhysicalAddress = computed({
get(): IAddress | null {
@ -1356,16 +1434,24 @@ const maximumAttendeeCapacity = computed({
},
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const firstDayOfWeek = computed((): number => {
return dateFnsLocale?.options?.weekStartsOn || 0;
});
const { event: fetchedEvent, onResult: onFetchEventResult } = useFetchEvent(
eventId.value
);
// update the date components if the event changed (after fetching it, for example)
watch(event, () => {
if (event.value.beginsOn) {
const date = new Date(event.value.beginsOn);
date.setUTCMinutes(date.getUTCMinutes() + tzOffset.value);
beginsOn.value = date;
}
if (event.value.endsOn) {
const date = new Date(event.value.endsOn);
date.setUTCMinutes(date.getUTCMinutes() + tzOffset.value);
endsOn.value = date;
}
});
watch(
fetchedEvent,
() => {
@ -1434,4 +1520,27 @@ const registerOption = computed({
padding-left: 3px;
}
}
#status .o-field--addons {
flex-wrap: wrap;
gap: 5px;
}
#status .o-field--addons > label {
flex: 1 1 0;
margin: 0;
}
#status .o-field--addons .mr-2 {
margin: 0;
}
#status .o-field--addons > label .o-radio__label {
width: 100%;
}
@media screen and (max-width: 700px) {
#status .o-field--addons {
flex-direction: column;
}
}
</style>

View file

@ -24,6 +24,7 @@
>
<start-time-icon
:date="event.beginsOn.toString()"
:timezone="event.options.timezone ?? undefined"
class="absolute right-3 -top-16"
/>
</div>

View file

@ -18,7 +18,6 @@
>{{ t("Create event") }}</o-button
>
</div>
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
<div class="flex flex-wrap gap-4 items-start">
<div
class="rounded p-3 flex-auto md:flex-none bg-zinc-300 dark:bg-zinc-700"
@ -33,11 +32,11 @@
"
labelFor="events-start-datepicker"
>
<o-datepicker
v-model="datePick"
:first-day-of-week="firstDayOfWeek"
<event-date-picker
id="events-start-datepicker"
/>
:time="false"
v-model="datePick"
></event-date-picker>
<o-button
@click="datePick = new Date()"
class="reset-area !h-auto"
@ -137,13 +136,18 @@
>
</div>
</section>
<section v-if="loading">
<div class="text-center prose dark:prose-invert max-w-full">
<p>{{ t("Loading…") }}</p>
</div>
</section>
<section
class="text-center not-found"
v-if="
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.size === 0 &&
true // !$apollo.loading
!loading
"
>
<div class="text-center prose dark:prose-invert max-w-full">
@ -221,17 +225,17 @@ import {
LOGGED_USER_UPCOMING_EVENTS,
} from "@/graphql/participant";
import { useApolloClient, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, defineAsyncComponent } from "vue";
import { computed, ref, defineAsyncComponent } from "vue";
import { IUser } from "@/types/current-user.model";
import {
booleanTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useRestrictions } from "@/composition/apollo/config";
import { useHead } from "@/utils/head";
import EventDatePicker from "@/components/Event/EventDatePicker.vue";
const EventParticipationCard = defineAsyncComponent(
() => import("@/components/Event/EventParticipationCard.vue")
@ -246,7 +250,7 @@ const pastPage = ref(1);
const limit = ref(10);
function startOfDay(d: Date): string {
const pad = (n: int): string => {
const pad = (n: number): string => {
return (n > 9 ? "" : "0") + n.toString();
};
return (
@ -291,6 +295,7 @@ const hasMorePastParticipations = ref(true);
const {
result: loggedUserUpcomingEventsResult,
fetchMore: fetchMoreUpcomingEvents,
loading,
} = useQuery<{
loggedUser: IUser;
}>(LOGGED_USER_UPCOMING_EVENTS, () => ({
@ -376,7 +381,7 @@ const monthlyFutureEvents = computed((): Map<string, Eventable[]> => {
});
const monthlyPastParticipations = computed((): Map<string, Eventable[]> => {
return monthlyEvents(pastParticipations.value.elements, true);
return monthlyEvents(pastParticipations.value.elements);
});
const monthParticipationsIds = (elements: Eventable[]): string[] => {
@ -490,12 +495,6 @@ const hideCreateEventButton = computed((): boolean => {
return restrictions.value?.onlyGroupsCanCreateEvents === true;
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const firstDayOfWeek = computed((): number => {
return dateFnsLocale?.options?.weekStartsOn ?? 0;
});
useHead({
title: computed(() => t("My events")),
});

View file

@ -200,7 +200,13 @@
</o-checkbox>
</fieldset>
<o-button variant="primary" native-type="submit" class="mt-3">
<o-button
variant="primary"
:disabled="loading"
:loading="loading"
native-type="submit"
class="mt-3"
>
{{ t("Create my group") }}
</o-button>
</form>
@ -371,7 +377,7 @@ const preferredUsernameErrors = computed(() => {
return [message, type];
});
const { onDone, onError, mutate } = useCreateGroup();
const { onDone, onError, mutate, loading } = useCreateGroup();
onDone(() => {
notifier?.success(

View file

@ -166,9 +166,12 @@
/>
<div class="flex flex-wrap gap-2 my-2">
<o-button native-type="submit" variant="primary">{{
t("Update group")
}}</o-button>
<o-button
:loading="loadingUpdateGroup"
native-type="submit"
variant="primary"
>{{ t("Update group") }}</o-button
>
<o-button @click="confirmDeleteGroup" variant="danger">{{
t("Delete group")
}}</o-button>
@ -244,7 +247,12 @@ const showCopiedTooltip = ref(false);
const editableGroup = ref<IGroup>();
const { onDone, onError, mutate: updateGroup } = useUpdateGroup();
const {
onDone,
onError,
mutate: updateGroup,
loading: loadingUpdateGroup,
} = useUpdateGroup();
onDone(() => {
notifier?.success(t("Group settings saved"));

View file

@ -1,8 +1,13 @@
<template>
<div class="container mx-auto is-widescreen">
<div class="header flex flex-col">
<o-notification v-if="groupLoading" variant="info">
{{ t("Loading…") }}
</o-notification>
<o-notification v-if="!group && groupLoading === false" variant="danger">
{{ t("No group found") }}
</o-notification>
<div class="header flex flex-col" v-if="group">
<breadcrumbs-nav
v-if="group"
:links="[
{ name: RouteName.MY_GROUPS, text: t('My groups') },
{
@ -12,8 +17,7 @@
},
]"
/>
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
<header class="block-container presentation" v-if="group">
<header class="block-container presentation">
<div class="banner-container">
<lazy-image-wrapper :picture="group.banner" />
</div>
@ -32,64 +36,14 @@
<AccountGroup v-else :size="128" />
</div>
<div class="title-container flex flex-1 flex-col text-center">
<h1 class="m-0" v-if="group.name">
<h1 class="m-1" v-if="group.name">
{{ group.name }}
</h1>
<!-- <o-skeleton v-else :animated="true" /> -->
<span dir="ltr" class="" v-if="group.preferredUsername"
<span dir="ltr" class="m-1" v-if="group.preferredUsername"
>@{{ usernameWithDomain(group) }}</span
>
<!-- <o-skeleton v-else :animated="true" /> -->
<br />
</div>
<div class="flex flex-wrap justify-center flex-col md:flex-row">
<div
class="flex flex-col items-center flex-1 m-0"
v-if="isCurrentActorAGroupMember && !previewPublic && members"
>
<div class="flex">
<figure
:title="
t(`{'@'}{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in members.elements"
:key="member.actor.id"
class="-mr-3"
>
<img
class="rounded-full h-8"
:src="member.actor.avatar.url"
v-if="member.actor.avatar"
alt=""
width="32"
height="32"
/>
<AccountCircle v-else :size="32" />
</figure>
</div>
<p>
{{
t(
"{count} members",
{
count: group.members?.total,
},
group.members?.total
)
}}
<router-link
v-if="isCurrentActorAGroupAdmin"
:to="{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ t("Add / Remove…") }}</router-link
>
</p>
</div>
<div class="flex flex-wrap gap-3 justify-center">
<o-button
outlined
@ -376,12 +330,14 @@
:invitations="[groupMember]"
/>
<o-notification
class="my-2"
v-if="isCurrentActorARejectedGroupMember"
variant="danger"
>
{{ t("You have been removed from this group's members.") }}
</o-notification>
<o-notification
class="my-2"
v-if="
isCurrentActorAGroupMember &&
isCurrentActorARecentMember &&
@ -395,47 +351,11 @@
)
}}
</o-notification>
</div>
</header>
</div>
<div
v-if="isCurrentActorAGroupMember && !previewPublic && group"
class="block-container flex gap-2 flex-wrap mt-3"
>
<!-- Private things -->
<div class="flex-1 m-0 flex flex-col flex-wrap gap-2">
<!-- Group discussions -->
<Discussions :group="discussionGroup ?? group" class="flex-1" />
<!-- Resources -->
<Resources :group="resourcesGroup ?? group" class="flex-1" />
</div>
<!-- Public things -->
<div class="flex-1 m-0 flex flex-col flex-wrap gap-2">
<!-- Events -->
<Events
:group="group"
:isModerator="isCurrentActorAGroupModerator"
class="flex-1"
/>
<!-- Posts -->
<Posts
:group="group"
:isModerator="isCurrentActorAGroupModerator"
:isMember="isCurrentActorAGroupMember"
class="flex-1"
/>
</div>
</div>
<o-notification
v-else-if="!group && groupLoading === false"
variant="danger"
>
{{ t("No group found") }}
</o-notification>
<div v-else-if="group" class="public-container flex flex-col">
<aside class="group-metadata">
<div class="sticky">
<o-notification v-if="group.domain && !isCurrentActorAGroupMember">
<o-notification
class="my-2"
v-if="group && group.domain && !isCurrentActorAGroupMember"
variant="info"
>
<p>
{{
t(
@ -451,49 +371,120 @@
>{{ t("View full profile") }}</o-button
>
</o-notification>
<event-metadata-block
:title="t('About')"
v-if="group.summary && group.summary !== '<p></p>'"
>
</div>
</header>
</div>
<div v-if="group" class="grid grid-cols-1 md:grid-cols-3 gap-2 mb-2">
<!-- Public thing: Members -->
<group-section :title="t('Members')" icon="account-group">
<template #default>
<div class="flex flex-col justify-center h-full">
<div
dir="auto"
class="prose lg:prose-xl dark:prose-invert"
v-html="group.summary"
/>
</event-metadata-block>
<event-metadata-block :title="t('Members')">
<template #icon>
<AccountGroup :size="48" />
</template>
{{
t(
"{count} members",
{
count: group.members?.total,
},
group.members?.total
)
}}
</event-metadata-block>
<event-metadata-block
v-if="physicalAddress && physicalAddress.url"
:title="t('Location')"
class="flex flex-col items-center"
v-if="isCurrentActorAGroupMember && !previewPublic && members"
>
<div class="flex">
<figure
:title="
t(`{'@'}{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in members.elements"
:key="member.actor.id"
class="-mr-3"
>
<img
class="rounded-full h-8"
:src="member.actor.avatar.url"
v-if="member.actor.avatar"
alt=""
width="32"
height="32"
/>
<AccountCircle v-else :size="32" />
</figure>
</div>
</div>
<div class="">
<h2 class="text-center">
{{
t(
"{count} members",
{
count: group.members?.total,
},
group.members?.total
)
}}
</h2>
</div>
</div></template
>
<template #create>
<o-button
v-if="isCurrentActorAGroupAdmin && !previewPublic"
tag="router-link"
:to="{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ t("Add / Remove…") }}</o-button
>
<template #icon>
<o-icon
v-if="physicalAddress.poiInfos.poiIcon.icon"
:icon="physicalAddress.poiInfos.poiIcon.icon"
customSize="48"
/>
<Earth v-else :size="48" />
</template>
</template>
</group-section>
<!-- Public thing: About -->
<group-section :title="t('About')" icon="information">
<template #default>
<div
v-if="group.summary"
dir="auto"
class="prose lg:prose-xl dark:prose-invert p-2"
v-html="group.summary"
></div>
<empty-content
v-else
icon="information"
:inline="true"
:center="true"
>
{{ t("No about content yet") }}
</empty-content>
</template>
<template #create>
<o-button
v-if="isCurrentActorAGroupAdmin && !previewPublic"
tag="router-link"
:to="{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ t("Edit") }}</o-button
>
</template>
</group-section>
<!-- Public thing: Location -->
<group-section :title="t('Location')" icon="earth">
<template #default
><div
class="flex flex-col justify-center h-full"
v-if="physicalAddress && physicalAddress.url"
>
<o-icon
v-if="physicalAddress.poiInfos.poiIcon.icon"
:icon="physicalAddress.poiInfos.poiIcon.icon"
customSize="48"
/>
<Earth v-else :size="48" />
<div class="address-wrapper">
<span
v-if="!physicalAddress || !addressFullName(physicalAddress)"
>{{ t("No address defined") }}</span
>
<div class="address" v-if="physicalAddress">
<div>
<div class="address">
<div class="text-center">
<span v-if="!addressFullName(physicalAddress)">{{
t("No address defined")
}}</span>
<address dir="auto">
<p
class="addressDescription"
@ -506,118 +497,90 @@
</p>
</address>
</div>
<o-button
class="map-show-button"
variant="text"
@click="showMap = !showMap"
@keyup.enter="showMap = !showMap"
v-if="physicalAddress.geom"
>
{{ t("Show map") }}
</o-button>
</div>
</div>
</event-metadata-block>
</div>
</aside>
<div class="main-content min-w-min flex-auto py-0 px-2">
<section>
<h2 class="text-2xl font-bold">{{ t("Upcoming events") }}</h2>
<div
class="flex flex-col gap-3"
v-if="group && organizedEvents.elements.length > 0"
>
<event-minimalist-card
v-for="event in organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
class="organized-event"
/>
</div>
<empty-content
v-else-if="group"
icon="calendar"
:inline="true"
description-classes="flex flex-col items-stretch"
>
{{ t("No public upcoming events") }}
<template #desc>
<template v-if="isCurrentActorFollowing">
<i18n-t
keypath="You will receive notifications about this group's public activity depending on %{notification_settings}."
>
<template #notification_settings>
<router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
t("your notification settings")
}}</router-link>
</template>
</i18n-t>
</template>
<o-button
tag="router-link"
class="my-2 self-center"
variant="text"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: { showPassedEvents: true },
}"
>{{ t("View past events") }}</o-button
>
</template>
</empty-content>
<!-- <o-skeleton animated v-else-if="$apollo.loading"></o-skeleton> -->
<div class="flex justify-center">
<o-button
tag="router-link"
class="my-4"
variant="text"
v-if="organizedEvents.total > 0"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: {
showPassedEvents: organizedEvents.elements.length === 0,
},
}"
>{{ t("View all events") }}</o-button
>
</div>
</section>
<section class="flex flex-col items-stretch">
<h2 class="ml-0 text-2xl font-bold">{{ t("Latest posts") }}</h2>
<multi-post-list-item
v-if="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
).length > 0
"
:posts="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
)
"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ t("No posts yet") }}
</empty-content>
<!-- <o-skeleton animated v-else-if="$apollo.loading"></o-skeleton> -->
<empty-content v-else icon="earth" :inline="true" :center="true">
{{ t("No location yet") }}
</empty-content></template
>
<template #create>
<o-button
class="self-center my-2"
v-if="posts.total > 0"
tag="router-link"
v-if="physicalAddress && physicalAddress.geom"
variant="text"
@click="showMap = !showMap"
@keyup.enter="showMap = !showMap"
>
{{ t("Show map") }}
</o-button>
<o-button
v-if="isCurrentActorAGroupAdmin && !previewPublic"
tag="router-link"
:to="{
name: RouteName.POSTS,
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ t("View all posts") }}</o-button
class="button is-primary"
>{{ t("Edit") }}</o-button
>
</section>
</template>
</group-section>
</div>
<div v-if="group">
<div
:class="[
'grid grid-cols-1 gap-2 mb-2',
{ 'xl:grid-cols-3': isLongEvents, 'md:grid-cols-2': !isLongEvents },
]"
>
<!-- Public thing: Long Events -->
<Events
v-if="isLongEvents"
:group="group"
:isModerator="isCurrentActorAGroupModerator && !previewPublic"
:longEvent="true"
/>
<!-- Public thing: Events -->
<Events
:group="group"
:isModerator="isCurrentActorAGroupModerator && !previewPublic"
:longEvent="false"
/>
<!-- Public thing: Posts -->
<Posts
:group="group"
:isModerator="isCurrentActorAGroupModerator && !previewPublic"
:isMember="isCurrentActorAGroupMember && !previewPublic"
/>
</div>
<div class="grid grid-cols-1 gap-2 mb-2 md:grid-cols-2">
<!-- Private thing: Group discussions -->
<Discussions
v-if="isCurrentActorAGroupMember && !previewPublic"
:group="discussionGroup ?? group"
/>
<!-- Private thing: Resources -->
<Resources
v-if="isCurrentActorAGroupMember && !previewPublic"
:group="resourcesGroup ?? group"
/>
</div>
</div>
<div class="my-2">
<template v-if="isCurrentActorFollowing">
<i18n-t
class="my-2"
keypath="You will receive notifications about this group's public activity depending on %{notification_settings}."
>
<template #notification_settings>
<router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
t("your notification settings")
}}</router-link>
</template>
</i18n-t>
</template>
</div>
<div v-if="group" class="public-container flex flex-col">
<o-modal
v-if="physicalAddress && physicalAddress.geom"
v-model:active="showMap"
@ -655,7 +618,6 @@
</template>
<script lang="ts" setup>
// import EventCard from "@/components/Event/EventCard.vue";
import {
displayName,
IActor,
@ -663,14 +625,11 @@ import {
IPerson,
usernameWithDomain,
} from "@/types/actor";
// import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import MultiPostListItem from "@/components/Post/MultiPostListItem.vue";
import { Address, addressFullName } from "@/types/address.model";
import InvitationsList from "@/components/Group/InvitationsList.vue";
import { addMinutes } from "date-fns";
import { JOIN_GROUP } from "@/graphql/member";
import { MemberRole, Openness, PostVisibility } from "@/types/enums";
import { MemberRole, Openness } from "@/types/enums";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
import ReportModal from "@/components/Report/ReportModal.vue";
@ -679,11 +638,7 @@ import {
PERSON_STATUS_GROUP,
} from "@/graphql/actor";
import LazyImageWrapper from "../../components/Image/LazyImageWrapper.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { Paginate } from "@/types/paginate";
import { IEvent } from "@/types/event.model";
import { IPost } from "@/types/post.model";
import {
FOLLOW_GROUP,
UNFOLLOW_GROUP,
@ -716,6 +671,8 @@ import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
import { useGroupResourcesList } from "@/composition/apollo/resources";
import { useGroupMembers } from "@/composition/apollo/members";
import GroupSection from "@/components/Group/GroupSection.vue";
import { useIsLongEvents } from "@/composition/apollo/config";
const props = defineProps<{
preferredUsername: string;
@ -740,6 +697,8 @@ const { group: resourcesGroup } = useGroupResourcesList(preferredUsername, {
const { t } = useI18n({ useScope: "global" });
const { isLongEvents } = useIsLongEvents();
// const { person } = usePersonStatusGroup(group);
const { result, subscribeToMore } = useQuery<{
@ -1093,32 +1052,6 @@ const ableToReport = computed((): boolean => {
);
});
const organizedEvents = computed((): Paginate<IEvent> => {
return {
total: group.value?.organizedEvents.total ?? 0,
elements:
group.value?.organizedEvents.elements.filter((event: IEvent) => {
if (previewPublic.value) {
return !event.draft; // TODO when events get visibility access add visibility constraint like below for posts
}
return true;
}) ?? [],
};
});
const posts = computed((): Paginate<IPost> => {
return {
total: group.value?.posts.total ?? 0,
elements:
group.value?.posts.elements.filter((post: IPost) => {
if (previewPublic.value || !isCurrentActorAGroupMember.value) {
return !post.draft && post.visibility == PostVisibility.PUBLIC;
}
return true;
}) ?? [],
};
});
const showFollowButton = computed((): boolean => {
return !isCurrentActorFollowing.value || previewPublic.value;
});
@ -1255,10 +1188,6 @@ div.container {
justify-content: flex-end;
display: flex;
.map-show-button {
cursor: pointer;
}
address {
font-style: normal;

View file

@ -4,12 +4,13 @@
<!-- Search fields -->
<search-fields
v-model:search="search"
v-model:location="location"
:locationDefaultText="location?.description"
v-model:address="userAddress"
v-model:distance="distance"
v-on:update:address="updateAddress"
:fromLocalStorage="true"
:addressDefaultText="userLocation?.name"
:key="increated"
/>
<!-- Categories preview
<categories-preview /> -->
<!-- Welcome back -->
<section
class="container mx-auto"
@ -121,6 +122,7 @@
@doGeoLoc="performGeoLocation()"
:userLocation="userLocation"
:doingGeoloc="doingGeoloc"
:distance="distance"
/>
</template>
@ -154,11 +156,16 @@ import {
UPDATE_CURRENT_USER_LOCATION_CLIENT,
} from "@/graphql/location";
import { LocationType } from "@/types/user-location.model";
import CategoriesPreview from "@/components/Home/CategoriesPreview.vue";
import UnloggedIntroduction from "@/components/Home/UnloggedIntroduction.vue";
import SearchFields from "@/components/Home/SearchFields.vue";
import { useHead } from "@unhead/vue";
import { addressToLocation, geoHashToCoords } from "@/utils/location";
import {
addressToLocation,
geoHashToCoords,
getAddressFromLocal,
locationToAddress,
storeAddressInLocal,
} from "@/utils/location";
import { useServerProvidedLocation } from "@/composition/apollo/config";
import { ABOUT } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
@ -211,11 +218,14 @@ const currentUserParticipations = computed(
() => loggedUser.value?.participations.elements
);
const location = ref(null);
const search = ref("");
const increated = ref(0);
const address = ref(null);
const search = ref(null);
const noAddress = ref(false);
const current_distance = ref(null);
watch(location, (newLoc, oldLoc) =>
console.debug("LOCATION UPDATED from", { ...oldLoc }, " to ", { ...newLoc })
watch(address, (newAdd, oldAdd) =>
console.debug("ADDRESS UPDATED from", { ...oldAdd }, " to ", { ...newAdd })
);
const isToday = (date: string): boolean => {
@ -372,15 +382,25 @@ const { result: reverseGeocodeResult } = useQuery<{
}));
const userSettingsLocation = computed(() => {
const address = reverseGeocodeResult.value?.reverseGeocode[0];
const placeName = address?.locality ?? address?.region ?? address?.country;
return {
lat: coords.value?.latitude,
lon: coords.value?.longitude,
name: placeName,
picture: address?.pictureInfo,
isIPLocation: coords.value?.isIPLocation,
};
const location = reverseGeocodeResult.value?.reverseGeocode[0];
const placeName = location?.locality ?? location?.region ?? location?.country;
console.debug(
"userSettingsLocation from reverseGeocode",
reverseGeocodeResult.value,
coords.value,
placeName
);
if (placeName) {
return {
lat: coords.value?.latitude,
lon: coords.value?.longitude,
name: placeName,
picture: location?.pictureInfo,
isIPLocation: coords.value?.isIPLocation,
};
} else {
return {};
}
});
const { result: currentUserLocationResult } = useQuery<{
@ -389,6 +409,10 @@ const { result: currentUserLocationResult } = useQuery<{
// The user's location currently in the Apollo cache
const currentUserLocation = computed(() => {
console.debug(
"currentUserLocation from LocationType",
currentUserLocationResult.value
);
return {
...(currentUserLocationResult.value?.currentUserLocation ?? {
lat: undefined,
@ -404,9 +428,20 @@ const currentUserLocation = computed(() => {
const userLocation = computed(() => {
console.debug("new userLocation");
if (location.value) {
if (noAddress.value) {
return {
lon: null,
lat: null,
name: null,
};
}
if (address.value) {
console.debug("userLocation is typed location");
return addressToLocation(location.value);
return addressToLocation(address.value);
}
const local_address = getAddressFromLocal();
if (local_address) {
return addressToLocation(local_address);
}
if (
!userSettingsLocation.value ||
@ -418,6 +453,47 @@ const userLocation = computed(() => {
return userSettingsLocation.value;
});
const userAddress = computed({
get(): IAddress | null {
if (noAddress.value) {
return null;
}
if (address.value) {
return address.value;
}
const local_address = getAddressFromLocal();
if (local_address) {
return local_address;
}
if (
!userSettingsLocation.value ||
(userSettingsLocation.value?.isIPLocation &&
currentUserLocation.value?.name)
) {
return locationToAddress(currentUserLocation.value);
}
return locationToAddress(userSettingsLocation.value);
},
set(newAddress: IAddress | null) {
address.value = newAddress;
noAddress.value = newAddress == null;
},
});
const distance = computed({
get(): number | null {
if (noAddress.value || !userLocation.value?.name) {
return null;
} else if (current_distance.value == null) {
return userLocation.value?.isIPLocation ? 150 : 25;
}
return current_distance.value;
},
set(newDistance: number) {
current_distance.value = newDistance;
},
});
const { mutate: saveCurrentUserLocation } = useMutation<any, LocationType>(
UPDATE_CURRENT_USER_LOCATION_CLIENT
);
@ -478,6 +554,15 @@ const performGeoLocation = () => {
);
};
const updateAddress = (newAddress: IAddress | null) => {
if (address.value?.geom != newAddress?.geom || newAddress == null) {
increated.value += 1;
storeAddressInLocal(newAddress);
}
address.value = newAddress;
noAddress.value = newAddress == null;
};
/**
* View Head
*/

View file

@ -3,8 +3,9 @@
<search-fields
class="md:ml-10 mr-2"
v-model:search="search"
v-model:location="location"
:locationDefaultText="locationName"
v-model:address="address"
v-model:distance="radius"
:addressDefaultText="addressName"
:fromLocalStorage="true"
/>
</div>
@ -27,51 +28,6 @@
:class="{ hidden: filtersPanelOpened }"
class="lg:block mt-4 px-2"
>
<p class="sr-only">{{ t("Type") }}</p>
<ul
class="font-medium text-gray-900 dark:text-slate-100 space-y-4 pb-4 border-b border-gray-200 dark:border-gray-500"
>
<li
v-for="content in contentTypeMapping"
:key="content.contentType"
class="flex gap-1"
>
<Magnify
v-if="content.contentType === ContentType.ALL"
:size="24"
/>
<Calendar
v-if="content.contentType === ContentType.EVENTS"
:size="24"
/>
<Calendar
v-if="content.contentType === ContentType.SHORTEVENTS"
:size="24"
/>
<CalendarStar
v-if="content.contentType === ContentType.LONGEVENTS"
:size="24"
/>
<AccountMultiple
v-if="content.contentType === ContentType.GROUPS"
:size="24"
/>
<router-link
:to="{
...$route,
query: { ...$route.query, contentType: content.contentType },
}"
>
{{ content.label }}
</router-link>
</li>
</ul>
<div
class="py-4 border-b border-gray-200 dark:border-gray-500"
v-show="globalSearchEnabled"
@ -90,7 +46,7 @@
/>
<label
for="selfTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("From this instance only") }}</label
>
</div>
@ -106,25 +62,10 @@
/>
<label
for="internalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("In this instance's network") }}</label
>
</div>
<div>
<input
id="globalTarget"
v-model="searchTarget"
type="radio"
name="searchTarget"
:value="SearchTargets.GLOBAL"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="globalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("On the Fediverse") }}</label
>
</div>
</fieldset>
</div>
@ -157,7 +98,7 @@
/>
<label
:for="key"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ eventStartDateRangeOption.label }}</label
>
</div>
@ -175,43 +116,6 @@
</template>
</filter-section>
<filter-section
v-show="!isOnline"
v-model:opened="searchFilterSectionsOpenStatus.eventDistance"
:title="t('Distance')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Distance") }}</legend>
<div
v-for="distanceOption in eventDistance"
:key="distanceOption.id"
>
<input
:id="distanceOption.id"
v-model="distance"
type="radio"
name="eventDistance"
:value="distanceOption.id"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="distanceOption.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ distanceOption.label }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
>
{{ eventDistance.find(({ id }) => id === distance)?.label }}
</span>
</template>
</filter-section>
<filter-section
v-show="contentType !== 'GROUPS'"
v-model:opened="searchFilterSectionsOpenStatus.eventCategory"
@ -231,7 +135,7 @@
/>
<label
:for="category.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ category.label }}</label
>
</div>
@ -292,7 +196,7 @@
/>
<label
:for="eventStatusOption.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ eventStatusOption.label }}</label
>
</div>
@ -339,7 +243,7 @@
/>
<label
:for="key"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
class="cursor-pointer ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ language }}</label
>
</div>
@ -375,61 +279,6 @@
</template>
</filter-section>
<!--
<div class="">
<label v-translate class="font-bold" for="host">Mobilizon instance</label>
<input
id="host"
v-model="formHost"
type="text"
name="host"
placeholder="mobilizon.fr"
class="dark:text-black md:max-w-fit w-full"
/>
</div>
<div class="">
<label v-translate class="inline font-bold" for="tagsAllOf">All of these tags</label>
<button
v-if="formTagsAllOf.length !== 0"
v-translate
class="text-sm ml-2"
@click="resetField('tagsAllOf')"
>
Reset
</button>
<vue-tags-input
v-model="formTagAllOf"
:placeholder="tagsPlaceholder"
:tags="formTagsAllOf"
@tags-changed="(newTags) => (formTagsAllOf = newTags)"
/>
</div>
<div>
<div>
<label v-translate class="inline font-bold" for="tagsOneOf">One of these tags</label>
<button
v-if="formTagsOneOf.length !== 0"
v-translate
class="text-sm ml-2"
@click="resetField('tagsOneOf')"
>
Reset
</button>
</div>
<vue-tags-input
v-model="formTagOneOf"
:placeholder="tagsPlaceholder"
:tags="formTagsOneOf"
@tags-changed="(newTags) => (formTagsOneOf = newTags)"
/>
</div>-->
<div class="sr-only">
<button
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
@ -453,14 +302,11 @@
id="results-anchor"
class="hidden sm:flex items-center justify-between dark:text-slate-100 mb-2"
>
<p v-if="totalCount === 0">
<span
v-if="
contentType === ContentType.EVENTS ||
contentType === ContentType.SHORTEVENTS
"
>{{ t("No events found") }}</span
>
<p v-if="searchLoading">{{ t("Loading search results...") }}</p>
<p v-else-if="totalCount === 0">
<span v-if="contentType === ContentType.EVENTS">{{
t("No events found")
}}</span>
<span v-else-if="contentType === ContentType.LONGEVENTS">{{
t("No activities found")
}}</span>
@ -470,12 +316,7 @@
<span v-else>{{ t("No results found") }}</span>
</p>
<p v-else>
<span
v-if="
contentType === ContentType.EVENTS ||
contentType === ContentType.SHORTEVENTS
"
>
<span v-if="contentType === ContentType.EVENTS">
{{
t(
"{eventsCount} events found",
@ -517,12 +358,27 @@
t("Sort by")
}}</label>
<o-select
:placeholder="t('Sort by')"
v-model="sortBy"
id="sortOptionSelect"
v-if="contentType !== ContentType.GROUPS"
:placeholder="t('Sort by events')"
v-model="sortByEvents"
id="sortOptionSelectEvents"
>
<option
v-for="sortOption in sortOptions"
v-for="sortOption in sortOptionsEvents"
:key="sortOption.key"
:value="sortOption.key"
>
{{ sortOption.label }}
</option>
</o-select>
<o-select
v-if="contentType === ContentType.GROUPS"
:placeholder="t('Sort by groups')"
v-model="sortByGroups"
id="sortOptionSelectGroups"
>
<option
v-for="sortOption in sortOptionsGroups"
:key="sortOption.key"
:value="sortOption.key"
>
@ -547,92 +403,9 @@
</div>
</div>
<div v-if="mode === ViewMode.LIST">
<template v-if="contentType === ContentType.ALL">
<template v-if="searchLoading">
<SkeletonGroupResultList v-for="i in 2" :key="i" />
<SkeletonEventResultList v-for="i in 4" :key="i" />
</template>
<o-notification v-if="features && !features.groups" variant="danger">
{{ t("Groups are not enabled on this instance.") }}
</o-notification>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<GroupCard
class="my-2"
v-for="group in searchGroups?.elements"
:group="group"
:key="group.id"
:isRemoteGroup="group.__typename === 'GroupResult'"
:isLoggedIn="currentUser?.isLoggedIn"
mode="row"
/>
</div>
<div v-if="searchEvents && searchEvents.total > 0">
<event-card
mode="row"
v-for="event in searchEvents?.elements"
:event="event"
:key="event.uuid"
:options="{
isRemoteEvent: event.__typename === 'EventResult',
isLoggedIn: currentUser?.isLoggedIn,
}"
class="my-4"
/>
</div>
<EmptyContent v-else-if="searchLoading === false" icon="magnify">
<span v-if="searchIsUrl">
{{ t("No event found at this address") }}
</span>
<span v-else-if="!search">
{{ t("No results found") }}
</span>
<i18n-t keypath="No results found for {search}" tag="span" v-else>
<template #search>
<b class="">{{ search }}</b>
</template>
</i18n-t>
<template #desc v-if="searchIsUrl && !currentUser?.id">
{{
t(
"Only registered users may fetch remote events from their URL."
)
}}
</template>
<template #desc v-else>
<p class="my-2 text-start">
{{ t("Suggestions:") }}
</p>
<ul class="list-disc list-inside text-start">
<li>
{{ t("Make sure that all words are spelled correctly.") }}
</li>
<li>{{ t("Try different keywords.") }}</li>
<li>{{ t("Try more general keywords.") }}</li>
<li>{{ t("Try fewer keywords.") }}</li>
<li>{{ t("Change the filters.") }}</li>
</ul>
</template>
</EmptyContent>
<o-pagination
v-if="
(searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT) ||
(searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT)
"
:total="
Math.max(searchEvents?.total ?? 0, searchGroups?.total ?? 0)
"
v-model:current="page"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
/>
</template>
<template
v-else-if="
v-if="
contentType === ContentType.EVENTS ||
contentType === ContentType.SHORTEVENTS ||
contentType === ContentType.LONGEVENTS
"
>
@ -771,7 +544,7 @@
:contentType="contentType"
:latitude="latitude"
:longitude="longitude"
:locationName="locationName"
:locationName="addressName"
@map-updated="setBounds"
:events="searchEvents"
:groups="searchGroups"
@ -814,10 +587,6 @@ import {
enumTransformer,
booleanTransformer,
} from "vue-use-route-query";
import Calendar from "vue-material-design-icons/Calendar.vue";
import CalendarStar from "vue-material-design-icons/CalendarStar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
import Magnify from "vue-material-design-icons/Magnify.vue";
import { useHead } from "@/utils/head";
import type { Locale } from "date-fns";
@ -827,7 +596,6 @@ import langs from "@/i18n/langs.json";
import {
useEventCategories,
useFeatures,
useIsLongEvents,
useSearchConfig,
} from "@/composition/apollo/config";
import { coordsToGeoHash } from "@/utils/location";
@ -849,25 +617,25 @@ const EventMarkerMap = defineAsyncComponent(
const search = useRouteQuery("search", "");
const searchDebounced = refDebounced(search, 1000);
const locationName = useRouteQuery("locationName", null);
const location = ref<IAddress | null>(null);
const addressName = useRouteQuery("locationName", null);
const address = ref<IAddress | null>(null);
watch(location, (newLocation) => {
console.debug("location change", newLocation);
if (newLocation?.geom) {
latitude.value = parseFloat(newLocation?.geom.split(";")[1]);
longitude.value = parseFloat(newLocation?.geom.split(";")[0]);
locationName.value = newLocation?.description;
console.debug("set location", [
watch(address, (newAddress: IAddress) => {
console.debug("address change", newAddress);
if (newAddress?.geom) {
latitude.value = parseFloat(newAddress?.geom.split(";")[1]);
longitude.value = parseFloat(newAddress?.geom.split(";")[0]);
addressName.value = newAddress?.description;
console.debug("set address", [
latitude.value,
longitude.value,
locationName.value,
addressName.value,
]);
} else {
console.debug("location emptied");
console.debug("address emptied");
latitude.value = undefined;
longitude.value = undefined;
locationName.value = null;
addressName.value = null;
}
});
@ -883,27 +651,17 @@ enum ViewMode {
}
enum EventSortValues {
MATCH_DESC = "MATCH_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
CREATED_AT_DESC = "CREATED_AT_DESC",
START_TIME_ASC = "START_TIME_ASC",
START_TIME_DESC = "START_TIME_DESC",
CREATED_AT_DESC = "CREATED_AT_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
PARTICIPANT_COUNT_DESC = "PARTICIPANT_COUNT_DESC",
}
enum GroupSortValues {
MATCH_DESC = "MATCH_DESC",
MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC",
}
enum SortValues {
MATCH_DESC = "MATCH_DESC",
START_TIME_ASC = "START_TIME_ASC",
START_TIME_DESC = "START_TIME_DESC",
CREATED_AT_DESC = "CREATED_AT_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
PARTICIPANT_COUNT_DESC = "PARTICIPANT_COUNT_DESC",
MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC",
LAST_EVENT_ACTIVITY = "LAST_EVENT_ACTIVITY",
}
const props = defineProps<{
@ -911,24 +669,19 @@ const props = defineProps<{
}>();
const tag = computed(() => props.tag);
const page = useRouteQuery("page", 1, integerTransformer);
const eventPage = useRouteQuery("eventPage", 1, integerTransformer);
const groupPage = useRouteQuery("groupPage", 1, integerTransformer);
const latitude = useRouteQuery("lat", undefined, floatTransformer);
const longitude = useRouteQuery("lon", undefined, floatTransformer);
// TODO
// This should be updated with getRadiusFromLocal if we want to use user's
// preferences
const distance = useRouteQuery("distance", "10_km");
const when = useRouteQuery("when", "any");
const contentType = useRouteQuery(
"contentType",
tag.value ? ContentType.EVENTS : ContentType.ALL,
ContentType.EVENTS,
enumTransformer(ContentType)
);
const isOnline = useRouteQuery("isOnline", false, booleanTransformer);
const categoryOneOf = useRouteQuery("categoryOneOf", [], arrayTransformer);
const statusOneOf = useRouteQuery(
@ -937,16 +690,22 @@ const statusOneOf = useRouteQuery(
arrayTransformer
);
const languageOneOf = useRouteQuery("languageOneOf", [], arrayTransformer);
const searchTarget = useRouteQuery(
"target",
SearchTargets.INTERNAL,
enumTransformer(SearchTargets)
);
const mode = useRouteQuery("mode", ViewMode.LIST, enumTransformer(ViewMode));
const sortBy = useRouteQuery(
"sortBy",
SortValues.START_TIME_ASC,
enumTransformer(SortValues)
const sortByEvents = useRouteQuery(
"sortByEvents",
EventSortValues.START_TIME_ASC,
enumTransformer(EventSortValues)
);
const sortByGroups = useRouteQuery(
"sortByGroups",
GroupSortValues.LAST_EVENT_ACTIVITY,
enumTransformer(GroupSortValues)
);
const bbox = useRouteQuery("bbox", undefined);
const zoom = useRouteQuery("zoom", undefined, integerTransformer);
@ -957,7 +716,6 @@ const GROUP_PAGE_LIMIT = 16;
const { features } = useFeatures();
const { eventCategories } = useEventCategories();
const { islongEvents } = useIsLongEvents();
const orderedCategories = computed(() => {
if (!eventCategories.value) return [];
@ -1070,113 +828,6 @@ const searchIsUrl = computed((): boolean => {
return url.protocol === "http:" || url.protocol === "https:";
});
const contentTypeMapping = computed(() => {
if (islongEvents.value) {
return [
{
contentType: "ALL",
label: t("Everything"),
},
{
contentType: "SHORTEVENTS",
label: t("Events"),
},
{
contentType: "LONGEVENTS",
label: t("Activities"),
},
{
contentType: "GROUPS",
label: t("Groups"),
},
];
} else {
return [
{
contentType: "ALL",
label: t("Everything"),
},
{
contentType: "EVENTS",
label: t("Events"),
},
{
contentType: "GROUPS",
label: t("Groups"),
},
];
}
});
const eventDistance = computed(() => {
return [
{
id: "anywhere",
label: t("Any distance"),
},
{
id: "5_km",
label: t(
"{number} kilometers",
{
number: 5,
},
5
),
},
{
id: "10_km",
label: t(
"{number} kilometers",
{
number: 10,
},
10
),
},
{
id: "25_km",
label: t(
"{number} kilometers",
{
number: 25,
},
25
),
},
{
id: "50_km",
label: t(
"{number} kilometers",
{
number: 50,
},
50
),
},
{
id: "100_km",
label: t(
"{number} kilometers",
{
number: 100,
},
100
),
},
{
id: "150_km",
label: t(
"{number} kilometers",
{
number: 150,
},
150
),
},
];
});
const eventStatuses = computed(() => {
return [
{
@ -1211,10 +862,21 @@ const geoHashLocation = computed(() =>
coordsToGeoHash(latitude.value, longitude.value)
);
const radius = computed(() => Number.parseInt(distance.value.slice(0, -3)));
const radius = computed({
get(): number | null {
if (addressName.value) {
return Number.parseInt(distance.value.slice(0, -3));
} else {
return null;
}
},
set(newRadius: number) {
distance.value = newRadius.toString() + "_km";
},
});
const longEvents = computed(() => {
if (contentType.value === ContentType.SHORTEVENTS) {
if (contentType.value === ContentType.EVENTS) {
return false;
} else if (contentType.value === ContentType.LONGEVENTS) {
return true;
@ -1227,45 +889,44 @@ const totalCount = computed(() => {
return (searchEvents.value?.total ?? 0) + (searchGroups.value?.total ?? 0);
});
const sortOptions = computed(() => {
const sortOptionsGroups = computed(() => {
const options = [
{
key: SortValues.MATCH_DESC,
label: t("Best match"),
key: GroupSortValues.LAST_EVENT_ACTIVITY,
label: t("Last event activity"),
},
{
key: GroupSortValues.MEMBER_COUNT_DESC,
label: t("Decreasing number of members"),
},
{
key: GroupSortValues.CREATED_AT_DESC,
label: t("Decreasing creation date"),
},
];
if (
contentType.value === ContentType.EVENTS ||
contentType.value === ContentType.SHORTEVENTS ||
contentType.value === ContentType.LONGEVENTS
) {
options.push(
{
key: SortValues.START_TIME_ASC,
label: t("Event date"),
},
{
key: SortValues.CREATED_AT_DESC,
label: t("Most recently published"),
},
{
key: SortValues.CREATED_AT_ASC,
label: t("Least recently published"),
},
{
key: SortValues.PARTICIPANT_COUNT_DESC,
label: t("With the most participants"),
}
);
}
return options;
});
if (contentType.value === ContentType.GROUPS) {
options.push({
key: SortValues.MEMBER_COUNT_DESC,
label: t("Number of members"),
});
}
const sortOptionsEvents = computed(() => {
const options = [
{
key: EventSortValues.START_TIME_ASC,
label: t("Event date"),
},
{
key: EventSortValues.CREATED_AT_DESC,
label: t("Most recently published"),
},
{
key: EventSortValues.CREATED_AT_ASC,
label: t("Least recently published"),
},
{
key: EventSortValues.PARTICIPANT_COUNT_DESC,
label: t("With the most participants"),
},
];
return options;
});
@ -1283,7 +944,7 @@ const handleSearchConfigChanged = (
searchConfigChanged?.global?.isEnabled &&
searchConfigChanged?.global?.isDefault
) {
searchTarget.value = SearchTargets.GLOBAL;
searchTarget.value = SearchTargets.INTERNAL;
}
};
@ -1321,11 +982,11 @@ watch(isOnline, (newIsOnline) => {
});
const sortByForType = (
value: SortValues,
allowed: typeof EventSortValues | typeof GroupSortValues
): SortValues | undefined => {
if (value === SortValues.START_TIME_ASC && when.value === "past") {
value = SortValues.START_TIME_DESC;
value: EventSortValues,
allowed: typeof EventSortValues
): EventSortValues | undefined => {
if (value === EventSortValues.START_TIME_ASC && when.value === "past") {
value = EventSortValues.START_TIME_DESC;
}
return Object.values(allowed).includes(value) ? value : undefined;
};
@ -1360,20 +1021,15 @@ watch(
searchTarget,
bbox,
zoom,
sortBy,
sortByEvents,
sortByGroups,
boostLanguagesQuery,
],
([newContentType]) => {
switch (newContentType) {
case ContentType.ALL:
page.value = 1;
break;
case ContentType.EVENTS:
eventPage.value = 1;
break;
case ContentType.SHORTEVENTS:
eventPage.value = 1;
break;
case ContentType.LONGEVENTS:
eventPage.value = 1;
break;
@ -1393,12 +1049,10 @@ const { result: searchElementsResult, loading: searchLoading } = useQuery<{
location: geoHashLocation.value,
beginsOn: start.value,
endsOn: end.value,
longevents: longEvents.value,
longEvents: longEvents.value,
radius: geoHashLocation.value ? radius.value : undefined,
eventPage:
contentType.value === ContentType.ALL ? page.value : eventPage.value,
groupPage:
contentType.value === ContentType.ALL ? page.value : groupPage.value,
eventPage: eventPage.value,
groupPage: groupPage.value,
limit: EVENT_PAGE_LIMIT,
type: isOnline.value ? "ONLINE" : undefined,
categoryOneOf: categoryOneOf.value,
@ -1407,8 +1061,8 @@ const { result: searchElementsResult, loading: searchLoading } = useQuery<{
searchTarget: searchTarget.value,
bbox: mode.value === ViewMode.MAP ? bbox.value : undefined,
zoom: zoom.value,
sortByEvents: sortByForType(sortBy.value, EventSortValues),
sortByGroups: sortByForType(sortBy.value, GroupSortValues),
sortByEvents: sortByForType(sortByEvents.value, EventSortValues),
sortByGroups: sortByGroups.value,
boostLanguages: boostLanguagesQuery.value,
}));
</script>

View file

@ -147,7 +147,7 @@ import RouteName from "../../router/name";
import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model";
import { useTimezones } from "@/composition/apollo/config";
import { useUserSettings, updateLocale } from "@/composition/apollo/user";
import { useLoggedUser, updateLocale } from "@/composition/apollo/user";
import { useHead } from "@/utils/head";
import { computed, defineAsyncComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@ -159,7 +159,7 @@ const FullAddressAutoComplete = defineAsyncComponent(
const { timezones: serverTimezones, loading: loadingTimezones } =
useTimezones();
const { loggedUser, loading: loadingUserSettings } = useUserSettings();
const { loggedUser, loading: loadingUserSettings } = useLoggedUser();
const { t } = useI18n({ useScope: "global" });

View file

@ -22,7 +22,7 @@
},
"software": {
"name": "Mobilizon",
"version": "5.0.0-beta.1",
"version": "5.1.0",
"repository": "https://framagit.org/framasoft/mobilizon"
},
"openRegistrations": true

View file

@ -56,7 +56,8 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
current_actor_id: nil,
exclude_my_groups: false,
exclude_stale_actors: true,
local_only: false
local_only: false,
sort_by: nil
],
1,
10

View file

@ -4,6 +4,7 @@ defmodule Mobilizon.GraphQL.Resolvers.AdminTest do
import Swoosh.TestAssertions
alias Mobilizon.Actors.Actor
alias Mobilizon.Config
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Reports.{Note, Report}
@ -568,7 +569,7 @@ defmodule Mobilizon.GraphQL.Resolvers.AdminTest do
assert_email_sent(
to: user.email,
subject:
"An administrator manually changed the email attached to your account on Test instance"
"An administrator manually changed the email attached to your account on #{Config.instance_name()}"
)
# # Swoosh.TestAssertions can't test multiple emails sent

View file

@ -113,9 +113,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ConfigTest do
assert res["data"]["config"]["long_description"] == nil
assert res["data"]["config"]["slogan"] == nil
assert res["data"]["config"]["languages"] == []
assert length(res["data"]["config"]["timezones"]) == 596
assert length(res["data"]["config"]["timezones"]) > 500
assert res["data"]["config"]["rules"] == nil
assert String.slice(res["data"]["config"]["version"], 0, 5) == "5.0.0"
# there is no real way to make this test work when bumping instance version
# as there is no tag with this new version number in the git history, yet
# assert String.slice(res["data"]["config"]["version"], 0, 5) == "5.1.0"
assert res["data"]["config"]["federating"] == true
end

View file

@ -18,8 +18,8 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
describe "search events/3" do
@search_events_query """
query SearchEvents($location: String, $radius: Float, $tags: String, $term: String, $beginsOn: DateTime, $endsOn: DateTime, $longevents:Boolean, $searchTarget: SearchTarget) {
searchEvents(location: $location, radius: $radius, tags: $tags, term: $term, beginsOn: $beginsOn, endsOn: $endsOn, longevents: $longevents, searchTarget: $searchTarget) {
query SearchEvents($location: String, $radius: Float, $tags: String, $term: String, $beginsOn: DateTime, $endsOn: DateTime, $longEvents:Boolean, $searchTarget: SearchTarget) {
searchEvents(location: $location, radius: $radius, tags: $tags, term: $term, beginsOn: $beginsOn, endsOn: $endsOn, longEvents: $longEvents, searchTarget: $searchTarget) {
total,
elements {
id
@ -224,7 +224,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: false}
variables: %{longEvents: false}
)
assert res["errors"] == nil
@ -241,7 +241,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: true}
variables: %{longEvents: true}
)
assert res["errors"] == nil
@ -296,7 +296,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: false}
variables: %{longEvents: false}
)
assert res["errors"] == nil
@ -311,7 +311,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: true}
variables: %{longEvents: true}
)
assert res["errors"] == nil
@ -364,7 +364,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: false}
variables: %{longEvents: false}
)
assert res["errors"] == nil
@ -378,7 +378,7 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{longevents: true}
variables: %{longEvents: true}
)
assert res["errors"] == nil

View file

@ -11,7 +11,7 @@ defmodule Mobilizon.Service.Metadata.InstanceTest do
assert Instance.build_tags() |> Utils.stringify_tags() ==
"""
<title>#{title}</title><meta content="#{description}" name="description"><meta content="#{title}" property="og:title"><meta content="#{Endpoint.url()}" property="og:url"><meta content="#{description}" property="og:description"><meta content="website" property="og:type"><script type="application/ld+json">{"@context":"http://schema.org","@type":"WebSite","name":"#{title}","potentialAction":{"@type":"SearchAction","query-input":"required name=search_term","target":"#{Endpoint.url()}/search?term={search_term}"},"url":"#{Endpoint.url()}"}</script>\n<link href=\"#{Endpoint.url()}/feed/instance/atom\" rel=\"alternate\" title=\"Test instance's feed\" type=\"application/atom+xml\"><link href=\"#{Endpoint.url()}/feed/instance/ics\" rel=\"alternate\" title=\"Test instance's feed\" type=\"text/calendar\">\
<title>#{title}</title><meta content="#{description}" name="description"><meta content="#{title}" property="og:title"><meta content="#{Endpoint.url()}" property="og:url"><meta content="#{description}" property="og:description"><meta content="website" property="og:type"><script type="application/ld+json">{"@context":"http://schema.org","@type":"WebSite","name":"#{title}","potentialAction":{"@type":"SearchAction","query-input":"required name=search_term","target":"#{Endpoint.url()}/search?term={search_term}"},"url":"#{Endpoint.url()}"}</script>\n<link href=\"#{Endpoint.url()}/feed/instance/atom\" rel=\"alternate\" title=\"#{Config.instance_name()}'s feed\" type=\"application/atom+xml\"><link href=\"#{Endpoint.url()}/feed/instance/ics\" rel=\"alternate\" title=\"#{Config.instance_name()}'s feed\" type=\"text/calendar\">\
"""
end
end

View file

@ -115,11 +115,12 @@ defmodule Mobilizon.Web.FeedControllerTest do
begins_on: DateTime.add(DateTime.utc_now(), 4, :day)
)
event3 =
event_in_the_past =
insert(:event,
organizer_actor: actor,
attributed_to: group,
title: "Event Three",
ends_on: DateTime.add(DateTime.utc_now(), -3, :day),
begins_on: DateTime.add(DateTime.utc_now(), -2, :day)
)
@ -135,7 +136,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
Enum.each(entries, fn entry ->
assert entry.summary in [event1.title, event2.title]
refute entry.summary == event3.title
refute entry.summary == event_in_the_past.title
end)
assert entry1.categories == [tag1.title]

View file

@ -29,7 +29,6 @@ const createSlotButtonText = "+ Create a post";
type Props = {
title?: string;
icon?: string;
privateSection?: boolean;
route?: { name: string; params: { preferredUsername: string } };
};
@ -73,10 +72,6 @@ describe("GroupSection", () => {
expect(wrapper.find("a").attributes("href")).toBe(`/@${groupUsername}/p`);
// expect(wrapper.find(".group-section-title").classes("privateSection")).toBe(
// true
// );
expect(wrapper.find("section > div.flex-1").text()).toBe(defaultSlotText);
expect(wrapper.find(".flex.justify-end.p-2 a").text()).toBe(
createSlotButtonText
@ -88,11 +83,8 @@ describe("GroupSection", () => {
});
it("renders public group section", () => {
const wrapper = generateWrapper({ privateSection: false });
const wrapper = generateWrapper();
// expect(wrapper.find(".group-section-title").classes("privateSection")).toBe(
// false
// );
expect(wrapper.html()).toMatchSnapshot();
});
});