Merge branch 'vue3-compat' into 'main'

Vue 3 and Vite

See merge request framasoft/mobilizon!1259
This commit is contained in:
Thomas Citharel 2022-09-21 09:13:39 +00:00
commit 83d518579b
606 changed files with 40482 additions and 39553 deletions

View File

@ -127,14 +127,14 @@ exunit:
- test-junit-report.xml
expire_in: 30 days
jest:
vitest:
stage: test
needs:
- lint-front
before_script:
- yarn --cwd "js" install --frozen-lockfile
script:
- yarn --cwd "js" run test:unit --no-color --ci --reporters=default --reporters=jest-junit
- yarn --cwd "js" run coverage --reporter=default --reporter=junit --outputFile.junit=./junit.xml
artifacts:
when: always
paths:

View File

@ -1,2 +1,2 @@
elixir 1.13.4-otp-24
erlang 24.3.3
elixir 1.14.0-otp-25
erlang 25.0.4

View File

@ -54,7 +54,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
secret_key_base: "1yOazsoE0Wqu4kXk3uC5gu3jDbShOimTCzyFL3OjCdBmOXMyHX87Qmf3+Tu9s0iM",
render_errors: [view: Mobilizon.Web.ErrorView, accepts: ~w(html json)],
pubsub_server: Mobilizon.PubSub,
cache_static_manifest: "priv/static/manifest.json",
cache_static_manifest: "priv/static/cache_manifest.json",
has_reverse_proxy: true
config :mime, :types, %{
@ -123,6 +123,18 @@ config :mobilizon, Mobilizon.Web.Email.Mailer,
# can be `true`
no_mx_lookups: false
config :vite_phx,
release_app: :mobilizon,
# to tell prod and dev env appart
environment: config_env(),
# this manifest is different from the Phoenix "cache_manifest.json"!
# optional
vite_manifest: "priv/static/manifest.json",
# optional
phx_manifest: "priv/static/cache_manifest.json",
# optional
dev_server_address: "http://localhost:5173"
# Configures Elixir's Logger
config :logger, :console,
backends: [:console],
@ -347,6 +359,23 @@ config :mobilizon, :exports,
config :mobilizon, :analytics, providers: []
config :mobilizon, Mobilizon.Service.Pictures, service: Mobilizon.Service.Pictures.Unsplash
config :mobilizon, Mobilizon.Service.Pictures.Unsplash,
app_name: "Mobilizon",
access_key: nil
config :mobilizon, :search, global: [is_default_search: false, is_enabled: true]
config :mobilizon, Mobilizon.Service.GlobalSearch,
service: Mobilizon.Service.GlobalSearch.SearchMobilizon
config :mobilizon, Mobilizon.Service.GlobalSearch.SearchMobilizon,
endpoint: "https://search.joinmobilizon.org",
csp_policy: [
img_src: "search.joinmobilizon.org"
]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View File

@ -15,13 +15,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
"--config",
"node_modules/@vue/cli-service/webpack.config.js",
"node_modules/.bin/vite",
cd: Path.expand("../js", __DIR__)
]
]
@ -102,3 +96,5 @@ config :mobilizon, :anonymous,
reports: [
allowed: true
]
config :unplug, :init_mode, :runtime

View File

@ -1,7 +1,7 @@
FROM elixir:latest
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
ENV REFRESHED_AT=2022-04-06
ENV REFRESHED_AT=2022-09-20
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool python3-pip python3-setuptools
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
RUN npm install -g yarn wait-on

View File

@ -1,3 +1,6 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
@ -6,10 +9,11 @@ module.exports = {
},
extends: [
"plugin:vue/essential",
"eslint:recommended",
"@vue/typescript/recommended",
"plugin:vue/vue3-essential",
"@vue/eslint-config-typescript/recommended",
"plugin:prettier/recommended",
"@vue/eslint-config-prettier",
],
plugins: ["prettier"],
@ -20,12 +24,11 @@ module.exports = {
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-underscore-dangle": [
"error",
{
allow: ["__typename"],
allow: ["__typename", "__schema"],
},
],
"@typescript-eslint/no-explicit-any": "off",
@ -50,4 +53,7 @@ module.exports = {
},
ignorePatterns: ["src/typings/*.d.ts", "vue.config.js"],
globals: {
GeolocationPositionError: true,
},
};

4
js/.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules
/tests/e2e/videos/
/tests/e2e/screenshots/
/coverage
stats.html
# local env files
.env.local
@ -23,3 +24,6 @@ yarn-error.log*
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

12
js/env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="histoire/vue" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SERVER_URL: string;
readonly VITE_HISTOIRE_ENV: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -1,5 +1,5 @@
const fetch = require("node-fetch");
const fs = require("fs");
import fetch from "node-fetch";
import fs from "fs";
fetch(`http://localhost:4000/api`, {
method: "POST",

51
js/histoire.config.ts Normal file
View File

@ -0,0 +1,51 @@
/// <reference types="@histoire/plugin-vue/components" />
import { defineConfig } from "histoire";
import { HstVue } from "@histoire/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [HstVue()],
setupFile: path.resolve(__dirname, "./src/histoire.setup.ts"),
viteNodeInlineDeps: [/date-fns/],
tree: {
groups: [
{
title: "Actors",
include: (file) => /^src\/components\/Account/.test(file.path),
},
{
title: "Address",
include: (file) => /^src\/components\/Address/.test(file.path),
},
{
title: "Comments",
include: (file) => /^src\/components\/Comment/.test(file.path),
},
{
title: "Discussion",
include: (file) => /^src\/components\/Discussion/.test(file.path),
},
{
title: "Events",
include: (file) => /^src\/components\/Event/.test(file.path),
},
{
title: "Groups",
include: (file) => /^src\/components\/Group/.test(file.path),
},
{
title: "Home",
include: (file) => /^src\/components\/Home/.test(file.path),
},
{
title: "Posts",
include: (file) => /^src\/components\/Post/.test(file.path),
},
{
title: "Others",
include: () => true,
},
],
},
});

View File

@ -1,20 +0,0 @@
module.exports = {
preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",
collectCoverage: true,
collectCoverageFrom: [
"**/*.{vue,ts}",
"!**/node_modules/**",
"!get_union_json.ts",
],
coverageReporters: ["html", "text", "text-summary"],
reporters: ["default", "jest-junit"],
// The following should fix the issue with svgs and ?inline loader (see Logo.vue), but doesn't work
//
// transform: {
// "^.+\\.svg$": "<rootDir>/tests/unit/svgTransform.js",
// },
// moduleNameMapper: {
// "^@/(.*svg)(\\?inline)$": "<rootDir>/src/$1",
// "^@/(.*)$": "<rootDir>/src/$1",
// },
};

View File

@ -3,19 +3,25 @@
"version": "2.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev": "vite",
"preview": "vite preview",
"build": "yarn run build:assets && yarn run build:pictures",
"test:unit": "LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 TZ=UTC vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e",
"lint": "vue-cli-service lint",
"build:assets": "vue-cli-service build --report",
"build:pictures": "bash ./scripts/build/pictures.sh"
"lint": "eslint --ext .ts,.vue --ignore-path .gitignore --fix src",
"format": "prettier . --write",
"build:assets": "vite build",
"build:pictures": "bash ./scripts/build/pictures.sh",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95",
"@headlessui/vue": "^1.6.7",
"@oruga-ui/oruga-next": "^0.5.5",
"@sentry/tracing": "^7.1",
"@sentry/vue": "^7.1",
"@tailwindcss/line-clamp": "^0.4.0",
@ -39,24 +45,32 @@
"@tiptap/extension-strike": "^2.0.0-beta.26",
"@tiptap/extension-text": "^2.0.0-beta.15",
"@tiptap/extension-underline": "^2.0.0-beta.7",
"@tiptap/vue-2": "^2.0.0-beta.21",
"@tiptap/suggestion": "^2.0.0-beta.195",
"@tiptap/vue-3": "^2.0.0-beta.96",
"@vue-a11y/announcer": "^2.1.0",
"@vue-a11y/skip-to": "^2.1.2",
"@vue/apollo-option": "4.0.0-alpha.11",
"@vue-leaflet/vue-leaflet": "^0.6.1",
"@vue/apollo-composable": "^4.0.0-alpha.17",
"@vue/compiler-sfc": "^3.2.37",
"@vueuse/core": "^9.1.0",
"@vueuse/head": "^0.7.9",
"@vueuse/router": "^9.0.2",
"apollo-absinthe-upload-link": "^1.5.0",
"autoprefixer": "^10",
"blurhash": "^1.1.3",
"buefy": "^0.9.0",
"blurhash": "^2.0.0",
"bulma": "^0.9.4",
"bulma-divider": "^0.2.0",
"core-js": "^3.6.4",
"date-fns": "^2.16.0",
"date-fns-tz": "^1.1.6",
"graphql": "^16.0.0",
"floating-vue": "^2.0.0-beta.17",
"graphql": "^15.8.0",
"graphql-tag": "^2.10.3",
"hammerjs": "^2.0.8",
"intersection-observer": "^0.12.0",
"jwt-decode": "^3.1.2",
"leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.76.0",
"leaflet.markercluster": "^1.5.3",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"p-debounce": "^4.0.0",
@ -67,24 +81,28 @@
"tailwindcss": "^3",
"tippy.js": "^6.2.3",
"unfetch": "^4.2.0",
"v-tooltip": "^2.1.3",
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
"vue-i18n": "^8.14.0",
"vue": "^3.2.37",
"vue-i18n": "9",
"vue-material-design-icons": "^5.1.2",
"vue-matomo": "^4.1.0",
"vue-meta": "^2.3.1",
"vue-plausible": "^1.3.1",
"vue-property-decorator": "^9.0.0",
"vue-router": "^3.1.6",
"vue-router": "4",
"vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3",
"vuedraggable": "^2.24.3"
"vue-use-route-query": "^1.1.0",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.0",
"@types/jest": "^28.0.0",
"@histoire/plugin-vue": "^0.10.0",
"@intlify/vite-plugin-vue-i18n": "^6.0.0",
"@playwright/test": "^1.25.1",
"@rushstack/eslint-patch": "^1.1.4",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.4",
"@types/hammerjs": "^2.0.41",
"@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.74",
"@types/leaflet.markercluster": "^1.5.1",
"@types/lodash": "^4.14.141",
"@types/ngeohash": "^0.6.2",
"@types/phoenix": "^1.5.2",
@ -93,37 +111,29 @@
"@types/prosemirror-state": "^1.2.4",
"@types/prosemirror-view": "^1.11.4",
"@types/sanitize-html": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0",
"@vue/cli-plugin-babel": "~5.0.6",
"@vue/cli-plugin-eslint": "~5.0.6",
"@vue/cli-plugin-pwa": "~5.0.6",
"@vue/cli-plugin-router": "~5.0.6",
"@vue/cli-plugin-typescript": "~5.0.6",
"@vue/cli-plugin-unit-jest": "~5.0.6",
"@vue/cli-service": "~5.0.6",
"@vitejs/plugin-vue": "^3.0.3",
"@vitest/coverage-c8": "^0.23.4",
"@vitest/ui": "^0.23.4",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/test-utils": "^1.1.0",
"@vue/vue2-jest": "^28.0.0",
"babel-jest": "^28.1.1",
"eslint": "^8.2.0",
"@vue/test-utils": "^2.0.2",
"eslint": "^8.21.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^9.1.1",
"eslint-plugin-vue": "^9.3.0",
"flush-promises": "^1.0.2",
"jest": "^28.1.1",
"jest-junit": "^13.0.0",
"histoire": "^0.10.4",
"jsdom": "^20.0.0",
"mock-apollo-client": "^1.1.0",
"prettier": "^2.2.1",
"prettier-eslint": "^15.0.1",
"rollup-plugin-visualizer": "^5.7.1",
"sass": "^1.34.1",
"sass-loader": "^13.0.0",
"ts-jest": "28",
"typescript": "~4.5.5",
"vue-cli-plugin-tailwind": "~3.0.0",
"vue-i18n-extract": "^2.0.4",
"vue-template-compiler": "^2.6.11",
"webpack-cli": "^4.7.0"
"typescript": "~4.8.3",
"vite": "^3.0.9",
"vite-plugin-pwa": "^0.13.0",
"vitest": "^0.23.3",
"vue-i18n-extract": "^2.0.4"
}
}

107
js/playwright.config.ts Normal file
View File

@ -0,0 +1,107 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: "./tests/e2e",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:4005",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
// {
// name: 'webkit',
// use: {
// ...devices['Desktop Safari'],
// },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

View File

Before

Width:  |  Height:  |  Size: 920 B

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

10
js/public/img/shape-1.svg Normal file
View File

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(255, 231.287, 78.545, 0.3)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(254.848, 165.324, 149.009, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M18.2,-13.5C23.5,-7.8,27.8,-0.2,27.7,8.7C27.5,17.6,22.9,27.8,14.1,33.9C5.3,39.9,-7.8,41.6,-17.7,36.8C-27.6,32,-34.2,20.7,-37.1,8.4C-39.9,-3.9,-39,-17.2,-32.2,-23.2C-25.4,-29.3,-12.7,-28.3,-3.1,-25.8C6.4,-23.3,12.8,-19.3,18.2,-13.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1015 B

10
js/public/img/shape-2.svg Normal file
View File

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(181.058, 255, 167.816, 0.2)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(149.009, 254.848, 251.263, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M20.2,-14.3C28.2,-6,38.3,2.5,37.6,9.8C36.9,17.1,25.5,23.1,15.5,25.2C5.6,27.3,-2.9,25.4,-11.2,21.9C-19.6,18.4,-27.9,13.3,-30.8,5.6C-33.7,-2.1,-31.2,-12.5,-25.2,-20.4C-19.1,-28.3,-9.6,-33.7,-1.8,-32.3C6.1,-30.9,12.1,-22.7,20.2,-14.3Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

10
js/public/img/shape-3.svg Normal file
View File

@ -0,0 +1,10 @@
<!--?xml version="1.0" standalone="no"?-->
<svg id="sw-js-blob-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
<linearGradient id="sw-gradient" x1="0" x2="1" y1="1" y2="0">
<stop id="stop1" stop-color="rgba(172.198, 167.816, 255, 0.2)" offset="0%"></stop>
<stop id="stop2" stop-color="rgba(236.8, 149.009, 254.848, 0.25)" offset="100%"></stop>
</linearGradient>
</defs>
<path fill="url(#sw-gradient)" d="M25.3,-21.5C29.4,-15.2,26.8,-4.8,23.6,3.8C20.4,12.5,16.5,19.4,10.2,23.2C3.9,27,-4.8,27.6,-12.6,24.5C-20.3,21.4,-27,14.6,-30.1,5.6C-33.2,-3.4,-32.6,-14.4,-26.9,-21.1C-21.3,-27.8,-10.7,-30.1,0,-30.1C10.7,-30.1,21.3,-27.8,25.3,-21.5Z" width="100%" height="100%" transform="translate(50 50)" style="transition: all 0.3s ease 0s;" stroke-width="0" stroke="url(#sw-gradient)"></path>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<meta name="server-injected-data" />
</head>
<body>
<noscript>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -30,11 +30,6 @@ convert_image () {
convert -geometry "$resolution"x $file $output
}
produce_webp () {
name=$(file_name)
output="$output_dir/$name.webp"
cwebp $file -quiet -o $output
}
progress() {
local w=80 p=$1; shift
@ -68,23 +63,3 @@ do
fi
done
echo -e "\nDone!"
echo "Generating optimized versions of the pictures…"
if ! command -v cwebp &> /dev/null
then
echo "$(tput setaf 1)ERROR: The cwebp command could not be found. You need to install webp.$(tput sgr 0)"
exit 1
fi
nb_files=$( shopt -s nullglob ; set -- $output_dir/* ; echo $#)
i=1
for file in $output_dir/*
do
if [[ -f $file ]]; then
produce_webp
progress $(($i*100/$nb_files)) still working...
i=$((i+1))
fi
done
echo -e "\nDone!"

View File

@ -1,267 +1,277 @@
<template>
<div id="mobilizon">
<VueAnnouncer />
<VueSkipTo to="#main" :label="$t('Skip to main content')" />
<!-- <VueAnnouncer />
<VueSkipTo to="#main" :label="t('Skip to main content')" /> -->
<NavBar />
<div v-if="config && config.demoMode">
<b-message
class="container"
type="is-danger"
:title="$t('Warning').toLocaleUpperCase()"
<div v-if="isDemoMode">
<o-notification
class="container mx-auto"
variant="danger"
:title="t('Warning').toLocaleUpperCase()"
closable
:aria-close-label="$t('Close')"
:aria-close-label="t('Close')"
>
<p>
{{ $t("This is a demonstration site to test Mobilizon.") }}
<b>{{ $t("Please do not use it in any real way.") }}</b>
{{ t("This is a demonstration site to test Mobilizon.") }}
<b>{{ t("Please do not use it in any real way.") }}</b>
{{
$t(
t(
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
)
}}
</p>
</b-message>
</o-notification>
</div>
<error v-if="error" :error="error" />
<ErrorComponent v-if="error" :error="error" />
<main id="main" v-else>
<transition name="fade" mode="out-in">
<router-view ref="routerView" />
</transition>
<main id="main" class="pt-4" v-else>
<router-view></router-view>
</main>
<mobilizon-footer />
</div>
</template>
<script lang="ts">
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
import NavBar from "./components/NavBar.vue";
<script lang="ts" setup>
import NavBar from "@/components/NavBar.vue";
import {
AUTH_ACCESS_TOKEN,
AUTH_USER_EMAIL,
AUTH_USER_ID,
AUTH_USER_ROLE,
} from "./constants";
import {
CURRENT_USER_CLIENT,
UPDATE_CURRENT_USER_CLIENT,
} from "./graphql/user";
import Footer from "./components/Footer.vue";
import Logo from "./components/Logo.vue";
import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model";
import { ICurrentUser } from "./types/current-user.model";
} from "@/constants";
import { UPDATE_CURRENT_USER_CLIENT } from "@/graphql/user";
import MobilizonFooter from "@/components/PageFooter.vue";
import jwt_decode, { JwtPayload } from "jwt-decode";
import { refreshAccessToken } from "./apollo/utils";
import { Route } from "vue-router";
import { refreshAccessToken } from "@/apollo/utils";
import {
reactive,
ref,
provide,
onUnmounted,
onMounted,
onBeforeMount,
inject,
defineAsyncComponent,
computed,
watch,
} from "vue";
import { LocationType } from "@/types/user-location.model";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { initializeCurrentActor } from "@/utils/identity";
import { useI18n } from "vue-i18n";
import { Snackbar } from "@/plugins/snackbar";
import { Notifier } from "@/plugins/notifier";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useRouter } from "vue-router";
@Component({
apollo: {
currentUser: CURRENT_USER_CLIENT,
config: CONFIG,
},
components: {
Logo,
NavBar,
error: () =>
import(/* webpackChunkName: "editor" */ "./components/Error.vue"),
"mobilizon-footer": Footer,
},
metaInfo() {
return {
titleTemplate: "%s | Mobilizon",
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const ErrorComponent = defineAsyncComponent(
() => import("@/components/ErrorComponent.vue")
);
const { t } = useI18n({ useScope: "global" });
const location = computed(() => config.value?.location);
const userLocation = reactive<LocationType>({
lon: undefined,
lat: undefined,
name: undefined,
picture: undefined,
isIPLocation: true,
accuracy: 100,
});
const updateUserLocation = (newLocation: LocationType) => {
userLocation.lat = newLocation.lat;
userLocation.lon = newLocation.lon;
userLocation.name = newLocation.name;
userLocation.picture = newLocation.picture;
userLocation.isIPLocation = newLocation.isIPLocation;
userLocation.accuracy = newLocation.accuracy;
};
updateUserLocation({
lat: location.value?.latitude,
lon: location.value?.longitude,
name: "", // config.ipLocation.country.name,
isIPLocation: true,
accuracy: 150, // config.ipLocation.location.accuracy_radius * 1.5 || 150,
});
provide("userLocation", {
userLocation,
updateUserLocation,
});
// const routerView = ref("routerView");
const error = ref<Error | null>(null);
const online = ref(true);
const interval = ref<number>(0);
const notifier = inject<Notifier>("notifier");
interval.value = setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken();
}
}
}, 60000) as unknown as number;
onBeforeMount(async () => {
if (initializeCurrentUser()) {
await initializeCurrentActor();
}
});
const snackbar = inject<Snackbar>("snackbar");
onMounted(() => {
online.value = window.navigator.onLine;
window.addEventListener("offline", () => {
online.value = false;
showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
online.value = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
snackbar?.open({
queue: false,
indefinite: true,
variant: "dark",
actionText: t("Update app"),
cancelText: t("Ignore"),
message: t("A new version is available."),
onAction: async () => {
const registration = event.detail as ServiceWorkerRegistration;
try {
await refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
notifier?.error(t("An error has occured while refreshing the page."));
}
},
});
});
});
onUnmounted(() => {
clearInterval(interval.value);
interval.value = 0;
});
const { mutate: updateCurrentUser } = useMutation(UPDATE_CURRENT_USER_CLIENT);
const initializeCurrentUser = () => {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
updateCurrentUser({
id: userId,
email: userEmail,
isLoggedIn: true,
role,
});
return true;
}
return false;
};
const refreshApp = async (
registration: ServiceWorkerRegistration
): Promise<any> => {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
};
},
})
export default class App extends Vue {
config!: IConfig;
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
});
};
currentUser!: ICurrentUser;
const showOfflineNetworkWarning = (): void => {
notifier?.error(t("You are offline"));
};
// const extractPageTitleFromRoute = (routeWatched: RouteLocation): string => {
// if (routeWatched.meta?.announcer?.message) {
// return routeWatched.meta?.announcer?.message();
// }
// return document.title;
// };
error: Error | null = null;
// watch(route, (routeWatched) => {
// const pageTitle = extractPageTitleFromRoute(routeWatched);
// if (pageTitle) {
// // this.$announcer.polite(
// // t("Navigated to {pageTitle}", {
// // pageTitle,
// // }) as string
// // );
// }
// // Set the focus to the router view
// // https://marcus.io/blog/accessible-routing-vuejs
// setTimeout(() => {
// const focusTarget = (
// routerView.value?.$refs?.componentFocusTarget !== undefined
// ? routerView.value?.$refs?.componentFocusTarget
// : routerView.value?.$el
// ) as HTMLElement;
// if (focusTarget && focusTarget instanceof Element) {
// // Make focustarget programmatically focussable
// focusTarget.setAttribute("tabindex", "-1");
online = true;
// // Focus element
// focusTarget.focus();
interval: number | undefined = undefined;
// // Remove tabindex from focustarget.
// // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
// focusTarget.removeAttribute("tabindex");
// }
// }, 0);
// });
@Ref("routerView") routerView!: Vue;
const router = useRouter();
async created(): Promise<void> {
if (await this.initializeCurrentUser()) {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
}
}
errorCaptured(error: Error): void {
this.error = error;
}
private async initializeCurrentUser() {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role,
},
});
}
return false;
}
mounted(): void {
this.online = window.navigator.onLine;
window.addEventListener("offline", () => {
this.online = false;
this.showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
this.online = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
this.$buefy.snackbar.open({
queue: false,
indefinite: true,
type: "is-secondary",
actionText: this.$t("Update app") as string,
cancelText: this.$t("Ignore") as string,
message: this.$t("A new version is available.") as string,
onAction: async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const detail = event.detail;
const registration = detail as ServiceWorkerRegistration;
try {
await this.refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
this.$notifier.error(
this.$t(
"An error has occured while refreshing the page."
) as string
);
}
},
});
});
this.interval = setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken(this.$apollo.getClient());
}
}
}, 60000);
}
private async refreshApp(
registration: ServiceWorkerRegistration
): Promise<any> {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
console.debug("Doing worker.skipWaiting().");
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
console.debug("Done worker.skipWaiting().");
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
};
console.debug("calling skip waiting");
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
watch(config, async (configWatched: IConfig | undefined) => {
if (configWatched) {
const { statistics } = await import("@/services/statistics");
statistics(configWatched?.analytics, {
router,
version: configWatched.version,
});
}
});
showOfflineNetworkWarning(): void {
this.$notifier.error(this.$t("You are offline") as string);
}
unmounted(): void {
clearInterval(this.interval);
this.interval = undefined;
}
@Watch("config")
async initializeStatistics(config: IConfig) {
if (config) {
const { statistics } = (await import("./services/statistics")) as {
statistics: (config: IConfig, environment: Record<string, any>) => void;
};
statistics(config, { router: this.$router, version: config.version });
}
}
@Watch("$route", { immediate: true })
updateAnnouncement(route: Route): void {
const pageTitle = this.extractPageTitleFromRoute(route);
if (pageTitle) {
this.$announcer.polite(
this.$t("Navigated to {pageTitle}", {
pageTitle,
}) as string
);
}
// Set the focus to the router view
// https://marcus.io/blog/accessible-routing-vuejs
setTimeout(() => {
const focusTarget = (
this.routerView?.$refs?.componentFocusTarget !== undefined
? this.routerView?.$refs?.componentFocusTarget
: this.routerView?.$el
) as HTMLElement;
if (focusTarget && focusTarget instanceof Element) {
// Make focustarget programmatically focussable
focusTarget.setAttribute("tabindex", "-1");
// Focus element
focusTarget.focus();
// Remove tabindex from focustarget.
// Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk
focusTarget.removeAttribute("tabindex");
}
}, 0);
}
extractPageTitleFromRoute(route: Route): string {
if (route.meta?.announcer?.message) {
return route.meta?.announcer?.message();
}
return document.title;
}
}
const isDemoMode = computed(() => config.value?.demoMode);
</script>
<style lang="scss">
@import "variables";
/* Icons */
$mdi-font-path: "~@mdi/font/fonts";
@import "~@mdi/font/scss/materialdesignicons";
@import "common";
#mobilizon {
min-height: 100vh;
display: flex;

View File

@ -14,7 +14,8 @@ export const MOBILIZON_INSTANCE_HOST = window.location.hostname;
*
* Example: https://framameet.org
*/
export const GRAPHQL_API_ENDPOINT = window.location.origin;
export const GRAPHQL_API_ENDPOINT =
import.meta.env.VITE_SERVER_URL ?? window.location.origin;
/**
* URL with path on which the API is. Replaces GRAPHQL_API_ENDPOINT if used
@ -23,4 +24,4 @@ export const GRAPHQL_API_ENDPOINT = window.location.origin;
*
* Example: https://framameet.org/api
*/
export const GRAPHQL_API_FULL_PATH = `${window.location.origin}/api`;
export const GRAPHQL_API_FULL_PATH = `${GRAPHQL_API_ENDPOINT}/api`;

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

@ -1,4 +1,5 @@
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { CURRENT_USER_LOCATION_CLIENT } from "@/graphql/location";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUserRole } from "@/types/enums";
import { ApolloCache, NormalizedCacheObject } from "@apollo/client/cache";
@ -7,7 +8,7 @@ import { Resolvers } from "@apollo/client/core/types";
export default function buildCurrentUserResolver(
cache: ApolloCache<NormalizedCacheObject>
): Resolvers {
cache.writeQuery({
cache?.writeQuery({
query: CURRENT_USER_CLIENT,
data: {
currentUser: {
@ -20,7 +21,7 @@ export default function buildCurrentUserResolver(
},
});
cache.writeQuery({
cache?.writeQuery({
query: CURRENT_ACTOR_CLIENT,
data: {
currentActor: {
@ -33,6 +34,20 @@ export default function buildCurrentUserResolver(
},
});
cache?.writeQuery({
query: CURRENT_USER_LOCATION_CLIENT,
data: {
currentUserLocation: {
lat: null,
lon: null,
accuracy: null,
isIPLocation: null,
name: null,
picture: null,
},
},
});
return {
Mutation: {
updateCurrentUser: (
@ -84,6 +99,39 @@ export default function buildCurrentUserResolver(
localCache.writeQuery({ data, query: CURRENT_ACTOR_CLIENT });
},
updateCurrentUserLocation: (
_: any,
{
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
}: {
lat: number;
lon: number;
accuracy: number;
isIPLocation: boolean;
name: string;
picture: any;
},
{ cache: localCache }: { cache: ApolloCache<NormalizedCacheObject> }
) => {
const data = {
currentUserLocation: {
lat,
lon,
accuracy,
isIPLocation,
name,
picture,
__typename: "CurrentUserLocation",
},
};
localCache.writeQuery({ data, query: CURRENT_USER_LOCATION_CLIENT });
},
},
};
}

View File

@ -4,19 +4,16 @@ import { IFollower } from "@/types/actor/follower.model";
import { IParticipant } from "@/types/participant.model";
import { Paginate } from "@/types/paginate";
import { saveTokenData } from "@/utils/auth";
import {
ApolloClient,
FieldPolicy,
NormalizedCacheObject,
Reference,
TypePolicies,
} from "@apollo/client/core";
import { FieldPolicy, Reference, TypePolicies } from "@apollo/client/core";
import introspectionQueryResultData from "../../fragmentTypes.json";
import { IMember } from "@/types/actor/member.model";
import { IComment } from "@/types/comment.model";
import { IEvent } from "@/types/event.model";
import { IActivity } from "@/types/activity.model";
import uniqBy from "lodash/uniqBy";
import { provideApolloClient, useMutation } from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo";
import { IToken } from "@/types/login.model";
type possibleTypes = { name: string };
type schemaType = {
@ -73,6 +70,12 @@ export const typePolicies: TypePolicies = {
Instance: {
keyFields: ["domain"],
},
Config: {
merge: true,
},
Address: {
keyFields: ["id"],
},
RootQueryType: {
fields: {
relayFollowers: paginatedLimitPagination<IFollower>(),
@ -99,9 +102,7 @@ export const typePolicies: TypePolicies = {
},
};
export async function refreshAccessToken(
apolloClient: ApolloClient<NormalizedCacheObject>
): Promise<boolean> {
export async function refreshAccessToken(): Promise<boolean> {
// Remove invalid access token, so the next request is not authenticated
localStorage.removeItem(AUTH_ACCESS_TOKEN);
@ -112,23 +113,30 @@ export async function refreshAccessToken(
return false;
}
console.log("Refreshing access token.");
console.debug("Refreshing access token.");
try {
const res = await apolloClient.mutate({
mutation: REFRESH_TOKEN,
variables: {
refreshToken,
},
return new Promise((resolve, reject) => {
const { mutate, onDone, onError } = provideApolloClient(apolloClient)(() =>
useMutation<{ refreshToken: IToken }>(REFRESH_TOKEN)
);
mutate({
refreshToken,
});
saveTokenData(res.data.refreshToken);
onDone(({ data }) => {
if (data?.refreshToken) {
saveTokenData(data?.refreshToken);
resolve(true);
}
reject(false);
});
return true;
} catch (err) {
console.debug("Failed to refresh token");
return false;
}
onError((err) => {
console.debug("Failed to refresh token", err);
reject(false);
});
});
}
type KeyArgs = FieldPolicy<any>["keyArgs"];

View File

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

View File

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

View File

@ -1,188 +0,0 @@
@use "@/styles/_mixins" as *;
@import "variables.scss";
@import "~bulma";
@import "~bulma-divider";
@import "~buefy/src/scss/buefy";
@import "styles/vue-announcer.scss";
@import "styles/vue-skip-to.scss";
a.out,
.content a,
.ProseMirror a {
text-decoration: underline;
text-decoration-color: #ed8d07;
text-decoration-thickness: 2px;
}
.section {
padding: 1rem 1% 4rem;
}
$color-black: #000;
.mention {
background: rgba($color-black, 0.1);
font-size: 0.9rem;
font-weight: bold;
border-radius: 5px;
padding: 0.2rem;
white-space: nowrap;
@include margin-right(0.2rem);
}
.mention-suggestion {
color: rgba($color-black, 0.6);
}
.mention .mention {
background: initial;
@include margin-right(0);
}
.select select {
border-color: $borders;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
body {
background: $body-background-color;
font-family: BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Segoe UI",
"Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow-x: hidden;
}
#mobilizon > .container > .message {
margin: 1rem auto auto;
.message-header {
button.delete {
background: #4a4a4a;
}
}
}
.module-description {
margin-bottom: 2rem;
color: $violet-1;
}
$list-background-color: $scheme-main !default;
$list-shadow: 0 2px 3px rgba($scheme-invert, 0.1),
0 0 0 1px rgba($scheme-invert, 0.1) !default;
$list-radius: $radius !default;
$list-item-border: 1px solid $border !default;
$list-item-color: $text !default;
$list-item-active-background-color: $link !default;
$list-item-active-color: $link-invert !default;
$list-item-hover-background-color: $background !default;
.list-item {
display: block;
padding: 0.5em 1em;
&:not(a) {
color: $list-item-color;
}
&:first-child {
border-top-left-radius: $list-radius;
border-top-right-radius: $list-radius;
}
&:last-child {
border-bottom-left-radius: $list-radius;
border-bottom-right-radius: $list-radius;
}
&:not(:last-child) {
border-bottom: $list-item-border;
}
&.is-active {
background-color: $list-item-active-background-color;
color: $list-item-active-color;
}
}
a.list-item {
background-color: $list-item-hover-background-color;
cursor: pointer;
}
.setting-title {
margin-top: 2rem;
margin-bottom: 1rem;
h2 {
display: inline;
background: $secondary;
padding: 2px 7.5px;
text-transform: uppercase;
font-size: 1.25rem;
}
}
@mixin focus() {
&:focus {
border: 2px solid black;
border-radius: 5px;
}
}
ul.menu-list > li,
p {
@include focus;
}
.navbar-item {
@include focus;
}
.navbar-dropdown span.navbar-item:hover {
background-color: whitesmoke;
color: #0a0a0a;
}
/**
* Bulma/Buefy fixes
*/
.icon {
vertical-align: middle;
}
.tags .tag:not(:last-child) {
margin-right: unset;
@include margin-right(0.5rem);
}
.button .icon {
&:first-child:not(:last-child) {
@include margin-left(calc(-0.5em - 1px));
@include margin-right(0.25em);
}
&:last-child:not(:first-child) {
@include margin-right(calc(-0.5em - 1px));
@include margin-left(0.25em);
}
}
.buttons .button:not(:last-child):not(.is-fullwidth) {
margin-right: unset;
@include margin-right(0.5rem);
}
.breadcrumb li:first-child a {
padding-left: unset;
@include padding-left(0);
@include padding-right(0.75em);
}
.media-left {
@include margin-left(1rem);
}
a.dropdown-item {
@include padding-right(3rem);
}

View File

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

View File

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

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