Merge branch 'vue3-compat' into 'main'
Vue 3 and Vite See merge request framasoft/mobilizon!1259
|
@ -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:
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
elixir 1.13.4-otp-24
|
||||
erlang 24.3.3
|
||||
elixir 1.14.0-otp-25
|
||||
erlang 25.0.4
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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/
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
12
js/env.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
|
@ -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",
|
||||
// },
|
||||
};
|
102
js/package.json
|
@ -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
|
@ -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;
|
BIN
js/public/img/categories/arts-small.webp
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
js/public/img/categories/arts.webp
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
js/public/img/categories/business-small.webp
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
js/public/img/categories/business.webp
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
js/public/img/categories/crafts-small.webp
Normal file
After Width: | Height: | Size: 9 KiB |
BIN
js/public/img/categories/crafts.webp
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
js/public/img/categories/film_media-small.webp
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
js/public/img/categories/film_media.webp
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
js/public/img/categories/food_drink-small.webp
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
js/public/img/categories/food_drink.webp
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
js/public/img/categories/games-small.webp
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
js/public/img/categories/games.webp
Normal file
After Width: | Height: | Size: 9 KiB |
BIN
js/public/img/categories/health-small.webp
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
js/public/img/categories/health.webp
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
js/public/img/categories/lgbtq-small.webp
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
js/public/img/categories/lgbtq.webp
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
js/public/img/categories/movements_politics-small.webp
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
js/public/img/categories/movements_politics.webp
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
js/public/img/categories/music-small.webp
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
js/public/img/categories/music.webp
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
js/public/img/categories/outdoors_adventure-small.webp
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
js/public/img/categories/outdoors_adventure.webp
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
js/public/img/categories/party-small.webp
Normal file
After Width: | Height: | Size: 776 B |
BIN
js/public/img/categories/party.webp
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
js/public/img/categories/photography-small.webp
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
js/public/img/categories/photography.webp
Normal file
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 6.4 KiB |
BIN
js/public/img/categories/spirituality_religion_beliefs.webp
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
js/public/img/categories/sports-small.webp
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
js/public/img/categories/sports.webp
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
js/public/img/categories/theatre-small.webp
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
js/public/img/categories/theatre.webp
Normal file
After Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 9.9 KiB |
Before Width: | Height: | Size: 920 B After Width: | Height: | Size: 920 B |
BIN
js/public/img/online-event.webp
Normal file
After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 725 KiB |
BIN
js/public/img/pics/error.webp
Normal file
After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 174 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
js/public/img/pics/event_creation.webp
Normal file
After Width: | Height: | Size: 174 KiB |
Before Width: | Height: | Size: 379 KiB |
BIN
js/public/img/pics/footer_1.webp
Normal file
After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 359 KiB |
BIN
js/public/img/pics/footer_2.webp
Normal file
After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 376 KiB |
BIN
js/public/img/pics/footer_3.webp
Normal file
After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 358 KiB |
BIN
js/public/img/pics/footer_4.webp
Normal file
After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 518 KiB |
BIN
js/public/img/pics/footer_5.webp
Normal file
After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 193 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
js/public/img/pics/group.webp
Normal file
After Width: | Height: | Size: 193 KiB |
Before Width: | Height: | Size: 1.8 MiB |
BIN
js/public/img/pics/homepage.webp
Normal file
After Width: | Height: | Size: 317 KiB |
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 1.5 MiB |
BIN
js/public/img/pics/realisation.webp
Normal file
After Width: | Height: | Size: 222 KiB |
Before Width: | Height: | Size: 133 KiB |
BIN
js/public/img/pics/rose.webp
Normal file
After Width: | Height: | Size: 21 KiB |
10
js/public/img/shape-1.svg
Normal 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: 1,015 B |
10
js/public/img/shape-2.svg
Normal 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: 1,016 B |
10
js/public/img/shape-3.svg
Normal 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: 1,013 B |
|
@ -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>
|
|
@ -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!"
|
462
js/src/App.vue
|
@ -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;
|
||||
|
|
|
@ -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`;
|
||||
|
|
25
js/src/apollo/absinthe-socket-link.ts
Normal 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);
|
20
js/src/apollo/absinthe-upload-socket-link.ts
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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"];
|
||||
|
|
280
js/src/assets/oruga-tailwindcss.css
Normal 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)];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
20
js/src/components/About/InstanceContactLink.story.vue
Normal 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>
|
|
@ -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>
|
||||
|
|