Merge branch 'tailwind2' into 'main'

Various admin improvements

Closes #973

See merge request framasoft/mobilizon!1152
chapril
Thomas Citharel 1 year ago
commit e6b95a43d1

@ -0,0 +1,66 @@
# Update the VARIANT arg in docker-compose.yml to pick an Elixir version: 1.9, 1.10, 1.10.4
ARG VARIANT="1.12.3"
FROM elixir:${VARIANT}
# This Dockerfile adds a non-root user with sudo access. Update the “remoteUser” property in
# devcontainer.json to use it. More info: https://aka.ms/vscode-remote/containers/non-root-user.
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Options for common package install script
ARG INSTALL_ZSH="true"
ARG UPGRADE_PACKAGES="true"
ARG COMMON_SCRIPT_SOURCE="https://raw.githubusercontent.com/microsoft/vscode-dev-containers/v0.209.6/script-library/common-debian.sh"
ARG COMMON_SCRIPT_SHA="d35dd1711454156c9a59cc41ebe04fbff681ca0bd304f10fd5b13285d0de13b2"
# Optional Settings for Phoenix
ARG PHOENIX_VERSION="1.6.2"
# [Optional] Setup nodejs
ARG NODE_SCRIPT_SOURCE="https://raw.githubusercontent.com/microsoft/vscode-dev-containers/main/script-library/node-debian.sh"
ARG NODE_SCRIPT_SHA="dev-mode"
ARG NODE_VERSION="none"
ENV NVM_DIR=/usr/local/share/nvm
ENV NVM_SYMLINK_CURRENT=true
ENV PATH=${NVM_DIR}/current/bin:${PATH}
# [Optional, Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies.
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends curl ca-certificates 2>&1 \
&& curl -sSL ${COMMON_SCRIPT_SOURCE} -o /tmp/common-setup.sh \
&& ([ "${COMMON_SCRIPT_SHA}" = "dev-mode" ] || (echo "${COMMON_SCRIPT_SHA} */tmp/common-setup.sh" | sha256sum -c -)) \
&& /bin/bash /tmp/common-setup.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" \
#
# [Optional] Install Node.js for use with web applications
&& if [ "$NODE_VERSION" != "none" ]; then \
curl -sSL ${NODE_SCRIPT_SOURCE} -o /tmp/node-setup.sh \
&& ([ "${NODE_SCRIPT_SHA}" = "dev-mode" ] || (echo "${NODE_SCRIPT_SHA} */tmp/node-setup.sh" | sha256sum -c -)) \
&& /bin/bash /tmp/node-setup.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}"; \
fi \
#
# Install dependencies
&& apt-get install -y build-essential \
#
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /tmp/common-setup.sh /tmp/node-setup.sh
RUN su ${USERNAME} -c "mix local.hex --force \
&& mix local.rebar --force \
&& mix archive.install --force hex phx_new ${PHOENIX_VERSION}"
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends cmake webp bash libncurses6 git python3 inotify-tools \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# [Optional] Uncomment this line to install additional package.
# RUN mix ...

@ -0,0 +1,44 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/elixir-phoenix-postgres
{
"name": "Elixir, Phoenix, Node.js & PostgresSQL (Community)",
"dockerComposeFile": "docker-compose.yml",
"service": "elixir",
"workspaceFolder": "/workspace",
// Set *default* container specific settings.json values on container create.
"settings": {
"sqltools.connections": [{
"name": "Container database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "postgres",
"username": "postgres",
"password": "postgres"
}]
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"jakebecker.elixir-ls",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4000, 4001, 5432],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "mix deps.get",
// "runArgs": ["--userns=keep-id", "--privileged"],
// "containerUser": "vscode",
// "containerEnv": {
// "HOME": "/home/vscode",
// },
// "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}

@ -0,0 +1,46 @@
version: "3.8"
services:
elixir:
build:
context: .
dockerfile: Dockerfile
args:
# Elixir Version: 1.9, 1.10, 1.10.4, ...
VARIANT: "1.13.1"
# Phoenix Version: 1.4.17, 1.5.4, ...
PHOENIX_VERSION: "1.6.6"
# Node Version: 10, 11, ...
NODE_VERSION: "16"
volumes:
- ..:/workspace:z
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
environment:
MOBILIZON_INSTANCE_NAME: My Mobilizon Instance
MOBILIZON_INSTANCE_HOST: localhost
MOBILIZON_INSTANCE_HOST_PORT: 4000
MOBILIZON_INSTANCE_PORT: 4000
MOBILIZON_INSTANCE_EMAIL: noreply@mobilizon.me
MOBILIZON_INSTANCE_REGISTRATIONS_OPEN: "true"
MOBILIZON_DATABASE_PASSWORD: postgres
MOBILIZON_DATABASE_USERNAME: postgres
MOBILIZON_DATABASE_DBNAME: mobilizon
MOBILIZON_DATABASE_HOST: db
db:
image: postgis/postgis:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
volumes:
postgres-data: null

@ -0,0 +1,2 @@
elixir 1.13
erlang 24.2

@ -290,6 +290,7 @@ config :mobilizon, Oban,
crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
{"36 * * * *", Mobilizon.Service.Workers.RefreshInstances, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},

@ -16,6 +16,7 @@
"@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95",
"@tailwindcss/line-clamp": "^0.3.0",
"@tiptap/core": "^2.0.0-beta.41",
"@tiptap/extension-blockquote": "^2.0.0-beta.25",
"@tiptap/extension-bold": "^2.0.0-beta.24",
@ -41,6 +42,7 @@
"@vue-a11y/skip-to": "^2.1.2",
"@vue/apollo-option": "4.0.0-alpha.11",
"apollo-absinthe-upload-link": "^1.5.0",
"autoprefixer": "^10",
"blurhash": "^1.1.3",
"buefy": "^0.9.0",
"bulma-divider": "^0.2.0",
@ -57,8 +59,10 @@
"ngeohash": "^0.6.3",
"p-debounce": "^4.0.0",
"phoenix": "^1.6",
"postcss": "^8",
"register-service-worker": "^1.7.2",
"sanitize-html": "^2.5.3",
"tailwindcss": "^3",
"tippy.js": "^6.2.3",
"unfetch": "^4.2.0",
"v-tooltip": "^2.1.3",
@ -112,6 +116,7 @@
"sass-loader": "^12.0.0",
"ts-jest": "27",
"typescript": "~4.4.3",
"vue-cli-plugin-tailwind": "^3.0.0-beta.0",
"vue-i18n-extract": "^2.0.4",
"vue-template-compiler": "^2.6.11",
"webpack-cli": "^4.7.0"

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

@ -216,7 +216,11 @@ export default class App extends Vue {
// Set the focus to the router view
// https://marcus.io/blog/accessible-routing-vuejs
setTimeout(() => {
const focusTarget = this.routerView?.$el as HTMLElement;
const focusTarget = (
this.routerView?.$refs?.componentFocusTarget !== undefined
? this.routerView?.$refs?.componentFocusTarget
: this.routerView?.$el
) as HTMLElement;
if (focusTarget) {
// Make focustarget programmatically focussable
focusTarget.setAttribute("tabindex", "-1");

@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = {
participantStats: { merge: replaceMergePolicy },
},
},
Instance: {
keyFields: ["domain"],
},
RootQueryType: {
fields: {
relayFollowers: paginatedLimitPagination<IFollower>(),

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60">
<path style="opacity:0;fill:#fea72b;fill-opacity:1;stroke:none;stroke-opacity:0" d="M-5.801-6.164h72.69v72.871h-72.69z" />
<g data-name="Calque 2">
<g data-name="header">
<path d="M26.58 27.06q0 8-4.26 12.3a12.21 12.21 0 0 1-9 3.42 12.21 12.21 0 0 1-9-3.42Q0 35.1 0 27.06q0-8.04 4.26-12.3a12.21 12.21 0 0 1 9-3.42 12.21 12.21 0 0 1 9 3.42q4.32 4.24 4.32 12.3zM13.29 17q-5.67 0-5.67 10.06t5.67 10.08q5.71 0 5.71-10.08T13.29 17z" style="fill:#3a384c;fill-opacity:1" transform="translate(14.627 5.256) scale(1.15671)" />
<path d="M9 6.78a7.37 7.37 0 0 1-.6-3 7.37 7.37 0 0 1 .6-3A8.09 8.09 0 0 1 12.83 0a7.05 7.05 0 0 1 3.69.84 7.37 7.37 0 0 1 .6 3 7.37 7.37 0 0 1-.6 3 7.46 7.46 0 0 1-3.87.84A6.49 6.49 0 0 1 9 6.78z" style="fill:#fff" transform="translate(14.627 5.256) scale(1.15671)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 920 B

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@ -7,10 +7,6 @@
@import "styles/vue-announcer.scss";
@import "styles/vue-skip-to.scss";
// a {
// color: $violet-2;
// }
a.out,
.content a,
.ProseMirror a {
@ -19,18 +15,10 @@ a.out,
text-decoration-thickness: 2px;
}
// input.input {
// border-color: $input-border-color !important;
// }
.section {
padding: 1rem 1% 4rem;
}
figure img.is-rounded {
border: 1px solid #cdcaea;
}
$color-black: #000;
.mention {

@ -1,31 +1,75 @@
<template>
<div class="media" style="align-items: top" dir="auto">
<div class="media-left">
<figure class="image is-32x32" v-if="actor.avatar">
<img class="is-rounded" :src="actor.avatar.url" alt="" />
<div
class="w-80 bg-white rounded-lg shadow-md p-4 sm:p-8 flex items-center space-x-4 flex-col items-center pb-10"
>
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
:class="{ 'line-clamp-3': limit }"
v-html="actor.summary"
/>
</div>
<!-- <div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
dir="auto"
>
<div class="flex-shrink-0">
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
<b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div>
<div class="media-content">
<p>
{{ actor.name || `@${usernameWithDomain(actor)}` }}
</p>
<p class="has-text-grey-dark" v-if="actor.name">
<div class="flex-1 min-w-0">
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
class="summary"
class="line-clamp-3"
:class="{ limit: limit }"
v-html="actor.summary"
/>
</div>
</div>
</div> -->
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor, usernameWithDomain } from "../../types/actor";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
@Component
export default class ActorCard extends Vue {
@ -38,135 +82,7 @@ export default class ActorCard extends Vue {
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
usernameWithDomain = usernameWithDomain;
}
</script>
<style lang="scss" scoped>
.summary.limit {
max-width: 25rem;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
</style>
<style lang="scss">
@use "@/styles/_mixins" as *;
.media {
.media-left {
margin-right: initial;
@include margin-right(1rem);
}
displayName = displayName;
}
.tooltip {
display: block !important;
z-index: 10000;
.tooltip-inner {
background: black;
color: white;
border-radius: 16px;
padding: 5px 10px 4px;
}
.tooltip-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: black;
z-index: 1;
}
&[x-placement^="top"] {
margin-bottom: 5px;
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: 5px;
.tooltip-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
@include margin-left(5px);
.tooltip-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px;
top: calc(50% - 5px);
@include margin-left(0);
@include margin-right(0);
}
}
&[x-placement^="left"] {
@include margin-right(5px);
.tooltip-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px;
top: calc(50% - 5px);
@include margin-left(0);
@include margin-right(0);
}
}
&.popover {
$color: #f9f9f9;
.popover-inner {
background: lighten($background-color, 65%);
color: black;
padding: 24px;
border-radius: 5px;
box-shadow: 0 5px 30px rgba(black, 0.1);
}
.popover-arrow {
border-color: $color;
}
}
&[aria-hidden="true"] {
visibility: hidden;
opacity: 0;
transition: opacity 0.15s, visibility 0.15s;
}
&[aria-hidden="false"] {
visibility: visible;
opacity: 1;
transition: opacity 0.15s;
}
}
</style>
</script>

@ -1,262 +0,0 @@
<template>
<div>
<b-table
v-show="relayFollowers.elements.length > 0"
:data="relayFollowers.elements"
:loading="$apollo.queries.relayFollowers.loading"
ref="table"
:checked-rows.sync="checkedRows"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="relayFollowers.total"
:per-page="FOLLOWERS_PER_PAGE"
@page-change="onFollowersPageChange"
checkable
checkbox-position="left"
>
<b-table-column
field="actor.id"
label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.actor.id }}</b-table-column
>
<b-table-column
field="actor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column
field="approved"
:label="$t('Status')"
width="100"
sortable
centered
v-slot="props"
>
<span
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column>
<b-table-column field="actor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props">
<a
@click="toggle(props.row)"
v-if="RelayMixin.isInstance(props.row.actor)"
>{{ props.row.actor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{
`${props.row.actor.preferredUsername}@${props.row.actor.domain}`
}}</a>
</template>
</b-table-column>
<b-table-column
field="targetActor.updatedAt"
:label="$t('Date')"
sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
>
<template #detail="props">
<article>
<div class="content">
<strong>{{ props.row.actor.name }}</strong>
<small v-if="props.row.actor.preferredUsername !== 'relay'"
>@{{ props.row.actor.preferredUsername }}</small
>
<p v-html="props.row.actor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button
@click="acceptRelays"
type="is-success"
v-if="checkedRowsHaveAtLeastOneToApprove"
>
{{
$tc(
"No instance to approve|Approve instance|Approve {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button @click="rejectRelays" type="is-danger">
{{
$tc(
"No instance to reject|Reject instance|Reject {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">{{
$t("No instance follows your instance yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns";
import {
ACCEPT_RELAY,
REJECT_RELAY,
RELAY_FOLLOWERS,
} from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import RelayMixin from "../../mixins/relay";
import RouteName from "@/router/name";
import { Paginate } from "@/types/paginate";
const FOLLOWERS_PER_PAGE = 10;
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
variables() {
return {
page: this.page,
limit: FOLLOWERS_PER_PAGE,
};
},
},
},
metaInfo() {
return {
title: this.$t("Followers") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followers extends Mixins(RelayMixin) {
RelayMixin = RelayMixin;
formatDistanceToNow = formatDistanceToNow;
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
checkedRows: IFollower[] = [];
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
toggle(row: Record<string, unknown>): void {
this.table.toggleDetails(row);
}
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.RELAY_FOLLOWERS, {
page: page.toString(),
});
}
acceptRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
rejectRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async acceptRelay(address: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
async rejectRelay(address: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some((checkedRow) => !checkedRow.approved);
}
async onFollowersPageChange(page: number): Promise<void> {
this.page = page;
try {
await this.$apollo.queries.relayFollowers.fetchMore({
variables: {
page: this.page,
limit: FOLLOWERS_PER_PAGE,
},
});
} catch (err: any) {
console.error(err);
}
}
}
</script>

@ -1,311 +0,0 @@
<template>
<div>
<form @submit="followRelay">
<b-field
:label="$t('Add an instance')"
custom-class="add-relay"
horizontal
>
<b-field grouped expanded size="is-large">
<p class="control">
<b-input
v-model="newRelayAddress"
:placeholder="$t('Ex: mobilizon.fr')"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
$t("Add an instance")
}}</b-button>
</p>
</b-field>
</b-field>
</form>
<b-table
v-show="relayFollowings.elements.length > 0"
:data="relayFollowings.elements"
:loading="$apollo.queries.relayFollowings.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="relayFollowings.total"
:per-page="FOLLOWINGS_PER_PAGE"
@page-change="onFollowingsPageChange"
checkable
checkbox-position="left"
>
<b-table-column
field="targetActor.id"
label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.targetActor.id }}</b-table-column
>
<b-table-column
field="targetActor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon
icon="lan"
v-if="RelayMixin.isInstance(props.row.targetActor)"
/>
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column
field="approved"
:label="$t('Status')"
width="100"
sortable
centered
v-slot="props"
>
<span
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column>
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props">
<a
@click="toggle(props.row)"
v-if="RelayMixin.isInstance(props.row.targetActor)"
>{{ props.row.targetActor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{
`${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}`
}}</a>
</template>
</b-table-column>
<b-table-column
field="targetActor.updatedAt"
:label="$t('Date')"
sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
>
<template #detail="props">
<article>
<div class="content">
<strong>{{ props.row.targetActor.name }}</strong>
<small v-if="props.row.actor.preferredUsername !== 'relay'"
>@{{ props.row.targetActor.preferredUsername }}</small
>
<p v-html="props.row.targetActor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<b-button @click="removeRelays" type="is-danger">
{{
$tc(
"No instance to remove|Remove instance|Remove {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowings.total === 0">{{
$t("You don't follow any instances yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns";
import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import RelayMixin from "../../mixins/relay";
import { RELAY_FOLLOWINGS } from "@/graphql/admin";
import { Paginate } from "@/types/paginate";
import RouteName from "@/router/name";
import { ApolloCache, FetchResult, Reference } from "@apollo/client/core";
import gql from "graphql-tag";
const FOLLOWINGS_PER_PAGE = 10;
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
variables() {
return {
page: this.page,
limit: FOLLOWINGS_PER_PAGE,
};
},
},
},
metaInfo() {
return {
title: this.$t("Followings") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followings extends Mixins(RelayMixin) {
newRelayAddress = "";
RelayMixin = RelayMixin;
formatDistanceToNow = formatDistanceToNow;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
FOLLOWINGS_PER_PAGE = FOLLOWINGS_PER_PAGE;
checkedRows: IFollower[] = [];
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.RELAY_FOLLOWINGS, {
page: page.toString(),
});
}
async onFollowingsPageChange(page: number): Promise<void> {
this.page = page;
try {
await this.$apollo.queries.relayFollowings.fetchMore({
variables: {
page: this.page,
limit: FOLLOWINGS_PER_PAGE,
},
});
} catch (err: any) {
console.error(err);
}
}
async followRelay(e: Event): Promise<void> {
e.preventDefault();
try {
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
mutation: ADD_RELAY,
variables: {
address: this.newRelayAddress.trim(), // trim to fix copy and paste domain name spaces and tabs
},
update(
cache: ApolloCache<{ relayFollowings: Paginate<IFollower> }>,
{ data }: FetchResult
) {
cache.modify({
fields: {
relayFollowings(
existingFollowings = { elements: [], total: 0 },
{ readField }
) {
const newFollowingRef = cache.writeFragment({
id: `${data?.addRelay.__typename}:${data?.addRelay.id}`,
data: data?.addRelay,
fragment: gql`
fragment NewFollowing on Follower {
id
}
`,
});
if (
existingFollowings.elements.some(
(ref: Reference) =>
readField("id", ref) === data?.addRelay.id
)
) {
return existingFollowings;
}
return {
total: existingFollowings.total + 1,
elements: [newFollowingRef, ...existingFollowings.elements],
};
},
},
broadcast: false,
});
},
});
this.newRelayAddress = "";
} catch (err: any) {
if (err.message) {
Snackbar.open({
message: err.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
removeRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(row);
});
}
async removeRelay(follower: IFollower): Promise<void> {
const address = `${follower.targetActor.preferredUsername}@${follower.targetActor.domain}`;
try {
await this.$apollo.mutate<{ removeRelay: IFollower }>({
mutation: REMOVE_RELAY,
variables: {
address,
},
update(cache: ApolloCache<{ removeRelay: IFollower }>) {
cache.modify({
fields: {
relayFollowings(existingFollowingRefs, { readField }) {
return {
total: existingFollowingRefs.total - 1,
elements: existingFollowingRefs.elements.filter(
(followingRef: Reference) =>
follower.id !== readField("id", followingRef)
),
};
},
},
});
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
}
</script>

@ -34,12 +34,6 @@
class="metadata-organized-by"
:title="$t('Organized by')"
>
<popover-actor-card
:actor="event.organizerActor"
v-if="!event.attributedTo"
>
<actor-card :actor="event.organizerActor" />
</popover-actor-card>
<router-link
v-if="event.attributedTo"
:to="{
@ -49,23 +43,19 @@
},
}"
>
<popover-actor-card
:actor="event.attributedTo"
<actor-card
v-if="
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
"
>
<actor-card :actor="event.attributedTo" />
</popover-actor-card>
:actor="event.attributedTo"
/>
<actor-card v-else :actor="event.organizerActor" />
</router-link>
<popover-actor-card
<actor-card
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
>
<actor-card :actor="contact" />
</popover-actor-card>
/>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"

@ -78,7 +78,7 @@
/>
<SettingMenuItem
:title="$t('Federation')"
:to="{ name: RouteName.RELAYS }"
:to="{ name: RouteName.INSTANCES }"
/>
</SettingMenuSection>
</ul>

@ -0,0 +1,69 @@
<template>
<nav class="flex mb-3" :aria-label="$t('Breadcrumbs')">
<ol class="inline-flex items-center space-x-1 md:space-x-3 flex-wrap">
<li
class="inline-flex items-center"
v-for="(element, index) in links"
:key="index"
:aria-current="index > 0 ? 'page' : undefined"
>
<router-link
v-if="index === 0"
:to="element"
class="inline-flex items-center text-gray-800 hover:text-gray-900"
>
{{ element.text }}
</router-link>
<div class="flex items-center" v-else-if="index === links.length - 1">
<svg
class="w-6 h-6 text-gray-400 rtl:rotate-180"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
></path>
</svg>
<span
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-600 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</span
>
</div>
<div class="flex items-center" v-else>
<svg
class="w-6 h-6 text-gray-400 rtl:rotate-180"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
></path>
</svg>
<router-link
:to="element"
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-800 hover:text-gray-900 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</router-link
>
</div>
</li>
<slot></slot>
</ol>
</nav>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Location } from "vue-router";
type LinkElement = Location & { text: string };
@Component
export default class Breadcrumbs extends Vue {
@Prop({ type: Array, required: true }) links!: LinkElement[];
}
</script>

@ -70,6 +70,67 @@ export const RELAY_FOLLOWINGS = gql`
${RELAY_FRAGMENT}
`;
export const INSTANCE_FRAGMENT = gql`
fragment InstanceFragment on Instance {
domain
hasRelay
followerStatus
followedStatus
eventCount
personCount
groupCount
followersCount
followingsCount
reportsCount
mediaSize
}
`;
export const INSTANCE = gql`
query instance($domain: ID!) {
instance(domain: $domain) {
...InstanceFragment
}
}
${INSTANCE_FRAGMENT}
`;
export const INSTANCES = gql`
query Instances(
$page: Int
$limit: Int
$orderBy: InstancesSortFields
$direction: String
$filterDomain: String
$filterFollowStatus: InstanceFilterFollowStatus
$filterSuspendStatus: InstanceFilterSuspendStatus
) {
instances(
page: $page
limit: $limit
orderBy: $orderBy
direction: $direction
filterDomain: $filterDomain
filterFollowStatus: $filterFollowStatus
filterSuspendStatus: $filterSuspendStatus
) {
total
elements {
...InstanceFragment
}
}
}
${INSTANCE_FRAGMENT}
`;
export const ADD_INSTANCE = gql`
mutation addInstance($domain: String!) {
addInstance(domain: $domain) {
...InstanceFragment
}
}
${INSTANCE_FRAGMENT}
`;
export const ADD_RELAY = gql`
mutation addRelay($address: String!) {
addRelay(address: $address) {
@ -190,3 +251,26 @@ export const SAVE_ADMIN_SETTINGS = gql`
}
${ADMIN_SETTINGS_FRAGMENT}
`;
export const ADMIN_UPDATE_USER = gql`
mutation AdminUpdateUser(
$id: ID!
$email: String
$role: UserRole
$confirmed: Boolean
$notify: Boolean
) {
adminUpdateUser(
id: $id
email: $email
role: $role
confirmed: $confirmed
notify: $notify
) {
id
email
role
confirmedAt
}
}
`;

@ -2,8 +2,13 @@ import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const REPORTS = gql`
query Reports($status: ReportStatus, $page: Int, $limit: Int) {
reports(status: $status, page: $page, limit: $limit) {
query Reports(
$status: ReportStatus
$domain: String
$page: Int
$limit: Int
) {
reports(status: $status, domain: $domain, page: $page, limit: $limit) {
total
elements {
id

@ -209,14 +209,30 @@ export const UPDATE_ACTIVITY_SETTING = gql`
`;
export const LIST_USERS = gql`
query ListUsers($email: String, $page: Int, $limit: Int) {
users(email: $email, page: $page, limit: $limit) {
query ListUsers(
$email: String
$currentSignInIp: String
$page: Int
$limit: Int
$sort: SortableUserField
$direction: SortDirection
) {
users(
email: $email
currentSignInIp: $currentSignInIp
page: $page
limit: $limit
sort: $sort
direction: $direction
) {
total
elements {
id
email
locale
confirmedAt
currentSignInIp
currentSignInAt
disabled
actors {
...ActorFragment

@ -1260,5 +1260,52 @@
"This profile was not found": "This profile was not found",
"Back to profile list": "Back to profile list",
"This user was not found": "This user was not found",
"Back to user list": "Back to user list"
"Back to user list": "Back to user list",
"Stop following instance": "Stop following instance",
"Follow instance": "Follow instance",
"Accept follow": "Accept follow",
"Reject follow": "Reject follow",
"This instance doesn't follow yours.": "This instance doesn't follow yours.",
"Only Mobilizon instances can be followed": "",
"Follow a new instance": "Follow a new instance",