chore(fe1): remove deprecated frontend (#3998)

---------

Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
This commit is contained in:
Iain Sproat
2025-02-18 12:36:52 +00:00
committed by GitHub
parent 49438f089d
commit ec98f8d4cb
337 changed files with 45 additions and 45523 deletions
+4 -34
View File
@@ -93,7 +93,6 @@ workflows:
- get-version - get-version
- deployment-testing-approval - deployment-testing-approval
- docker-build-server - docker-build-server
- docker-build-frontend
- docker-build-frontend-2 - docker-build-frontend-2
- docker-build-previews - docker-build-previews
- docker-build-webhooks - docker-build-webhooks
@@ -116,12 +115,6 @@ workflows:
requires: requires:
- get-version - get-version
- docker-build-frontend:
context: *build-context
filters: *filters-build
requires:
- get-version
- docker-build-frontend-2: - docker-build-frontend-2:
context: *build-context context: *build-context
filters: *filters-build filters: *filters-build
@@ -195,22 +188,6 @@ workflows:
- test-server-multiregion - test-server-multiregion
- test-preview-service - test-preview-service
- docker-publish-frontend:
context: *docker-hub-context
filters: *filters-publish
requires:
- docker-build-frontend
- get-version
- pre-commit
- publish-approval
- test-frontend-2
- test-viewer
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend-2: - docker-publish-frontend-2:
context: *docker-hub-context context: *docker-hub-context
filters: *filters-publish filters: *filters-publish
@@ -340,7 +317,6 @@ workflows:
- deployment-test-helm-chart - deployment-test-helm-chart
- docker-publish-docker-compose-ingress - docker-publish-docker-compose-ingress
- docker-publish-file-imports - docker-publish-file-imports
- docker-publish-frontend
- docker-publish-frontend-2 - docker-publish-frontend-2
- docker-publish-monitor-container - docker-publish-monitor-container
- docker-publish-previews - docker-publish-previews
@@ -494,6 +470,7 @@ jobs:
S3_CREATE_BUCKET: 'true' S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379' REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1' S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
ENABLE_ALL_FFS: 'true' ENABLE_ALL_FFS: 'true'
RATELIMITER_ENABLED: 'false' RATELIMITER_ENABLED: 'false'
@@ -566,6 +543,7 @@ jobs:
NODE_ENV: test NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test' DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle PGUSER: speckle
SESSION_SECRET: 'keyboard cat' SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true' STRATEGY_LOCAL: 'true'
@@ -577,6 +555,7 @@ jobs:
S3_CREATE_BUCKET: 'true' S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379' REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1' S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
DISABLE_ALL_FFS: 'true' DISABLE_ALL_FFS: 'true'
RATELIMITER_ENABLED: 'false' RATELIMITER_ENABLED: 'false'
@@ -627,6 +606,7 @@ jobs:
S3_CREATE_BUCKET: 'true' S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379' REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1' S3_REGION: '' # optional, defaults to 'us-east-1'
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true' FF_BILLING_INTEGRATION_ENABLED: 'true'
# These are the only different env keys: # These are the only different env keys:
@@ -1018,11 +998,6 @@ jobs:
environment: environment:
SPECKLE_SERVER_PACKAGE: server SPECKLE_SERVER_PACKAGE: server
docker-build-frontend:
<<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-build-frontend-2: docker-build-frontend-2:
<<: *build-job <<: *build-job
resource_class: xlarge resource_class: xlarge
@@ -1088,11 +1063,6 @@ jobs:
environment: environment:
SPECKLE_SERVER_PACKAGE: server SPECKLE_SERVER_PACKAGE: server
docker-publish-frontend:
<<: *publish-job
environment:
SPECKLE_SERVER_PACKAGE: frontend
docker-publish-frontend-2: docker-publish-frontend-2:
<<: *publish-job <<: *publish-job
environment: environment:
-3
View File
@@ -1,8 +1,6 @@
*node_modules *node_modules
packages/server/.env packages/server/.env
packages/server/dist packages/server/dist
packages/frontend/dist
packages/frontend/profiler
packages/viewer/dist packages/viewer/dist
packages/objectloader/dist packages/objectloader/dist
packages/*/dist packages/*/dist
@@ -22,7 +20,6 @@ coverage/
packages/viewer/example/*.js packages/viewer/example/*.js
packages/viewer/example/*.js.map packages/viewer/example/*.js.map
packages/frontend/schema.graphql
.tool-versions .tool-versions
packages/server/reports* packages/server/reports*
+1 -1
View File
@@ -25,7 +25,7 @@
This monorepo is the home of the Speckle v2 web packages: This monorepo is the home of the Speckle v2 web packages:
- [`packages/server`](https://github.com/specklesystems/speckle-server/blob/main/packages/server): the Server, a nodejs app. Core external dependencies are a Redis and Postgresql db. - [`packages/server`](https://github.com/specklesystems/speckle-server/blob/main/packages/server): the Server, a nodejs app. Core external dependencies are a Redis and Postgresql db.
- [`packages/frontend`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend): the Frontend, a static Vue app. - [`packages/frontend-2`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend-2): the Frontend, a Nuxt/Vue app.
- [`packages/viewer`](https://github.com/specklesystems/speckle-server/blob/main/packages/viewer): a threejs extension that allows you to display 3D data [![npm version](https://camo.githubusercontent.com/dc69232cc57b77de6554e752dd6dfc60ca0ecdfbe91bdfcbf7c7531a511ec200/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532467669657765722e737667)](https://www.npmjs.com/package/@speckle/viewer) - [`packages/viewer`](https://github.com/specklesystems/speckle-server/blob/main/packages/viewer): a threejs extension that allows you to display 3D data [![npm version](https://camo.githubusercontent.com/dc69232cc57b77de6554e752dd6dfc60ca0ecdfbe91bdfcbf7c7531a511ec200/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532467669657765722e737667)](https://www.npmjs.com/package/@speckle/viewer)
- [`packages/objectloader`](https://github.com/specklesystems/speckle-server/blob/main/packages/objectloader): a small js utility class that helps you stream an object and all its sub-components from the Speckle Server API. [![npm version](https://camo.githubusercontent.com/4d4f1e38ce50aaf11b4a3ad8e01ce3eaaa561dc5fd08febbae556f52f1d41097/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532466f626a6563746c6f616465722e737667)](https://www.npmjs.com/package/@speckle/objectloader) - [`packages/objectloader`](https://github.com/specklesystems/speckle-server/blob/main/packages/objectloader): a small js utility class that helps you stream an object and all its sub-components from the Speckle Server API. [![npm version](https://camo.githubusercontent.com/4d4f1e38ce50aaf11b4a3ad8e01ce3eaaa561dc5fd08febbae556f52f1d41097/68747470733a2f2f62616467652e667572792e696f2f6a732f253430737065636b6c652532466f626a6563746c6f616465722e737667)](https://www.npmjs.com/package/@speckle/objectloader)
- [`packages/preview-service`](https://github.com/specklesystems/speckle-server/blob/main/packages/preview-service): generates object previews for Speckle Objects headlessly. This package is meant to be called on by the server. - [`packages/preview-service`](https://github.com/specklesystems/speckle-server/blob/main/packages/preview-service): generates object previews for Speckle Objects headlessly. This package is meant to be called on by the server.
-1
View File
@@ -72,7 +72,6 @@ services:
FILE_SIZE_LIMIT_MB: 100 FILE_SIZE_LIMIT_MB: 100
EMAIL_FROM: 'no-reply@example.org' EMAIL_FROM: 'no-reply@example.org'
USE_FRONTEND_2: true
FRONTEND_ORIGIN: 'http://127.0.0.1' FRONTEND_ORIGIN: 'http://127.0.0.1'
ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10' ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10'
-1
View File
@@ -9,7 +9,6 @@ services:
environment: environment:
SPECKLE_SERVER: http://127.0.0.1 # this is the canonical url SPECKLE_SERVER: http://127.0.0.1 # this is the canonical url
SERVER_VERSION: 2 SERVER_VERSION: 2
FRONTEND_VERSION: '2'
VERIFY_CERTIFICATE: '0' VERIFY_CERTIFICATE: '0'
restart: 'no' restart: 'no'
-1
View File
@@ -32,7 +32,6 @@
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev", "dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen", "gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev", "dev:server": "yarn workspace @speckle/server dev",
"dev:frontend": "yarn workspace @speckle/frontend dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev", "dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev", "dev:shared": "yarn workspace @speckle/shared dev",
"prepare": "husky install", "prepare": "husky install",
@@ -1,6 +1,3 @@
/**
* TODO: Does this need to change for new frontend?
*/
export const speckleWebAppId = 'spklwebapp' export const speckleWebAppId = 'spklwebapp'
export enum AuthStrategy { export enum AuthStrategy {
@@ -6,7 +6,6 @@ import {
} from '~~/lib/auth/errors/errors' } from '~~/lib/auth/errors/errors'
import { speckleWebAppId } from '~~/lib/auth/helpers/strategies' import { speckleWebAppId } from '~~/lib/auth/helpers/strategies'
// TODO: Should these differ from the old frontend values?
const appId = speckleWebAppId const appId = speckleWebAppId
const appSecret = speckleWebAppId const appSecret = speckleWebAppId
-1
View File
@@ -1 +0,0 @@
since 2019
-39
View File
@@ -1,39 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "firefox",
"request": "launch",
"reAttach": true,
"name": "Launch localhost",
"url": "http://127.0.0.1:3000",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"trace": true,
"pathMappings": [
{
"url": "webpack:///src/main",
"path": "${workspaceFolder}/src/main"
}
]
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://127.0.0.1:3000",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"trace": true,
"pathMappings": [
{
"url": "webpack:///src/main",
"path": "${workspaceFolder}/src/main"
}
]
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative",
"volar.completion.preferredTagNameCase": "kebab",
"vitest.disableWorkspaceWarning": true
}
-59
View File
@@ -1,59 +0,0 @@
# NOTE: Docker context should be set to git root directory, to include the viewer
ARG NODE_ENV=production
ARG SPECKLE_SERVER_VERSION=custom
# build stage
FROM node:18-bullseye-slim@sha256:8cc7dcd5aa06715247f8f2f258332f188d4221e2685b1a0159e4e6c3382e4918 as build-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION
ENV NODE_ENV=${NODE_ENV}
WORKDIR /speckle-server
COPY .yarnrc.yml .
COPY .yarn ./.yarn
COPY package.json yarn.lock ./
# Onyl copy in the relevant package.json files for the dependencies
COPY packages/frontend-2/type-augmentations/stubs ./packages/frontend-2/type-augmentations/stubs/
COPY packages/frontend/package.json ./packages/frontend/
COPY packages/viewer/package.json ./packages/viewer/
COPY packages/objectloader/package.json ./packages/objectloader/
COPY packages/shared/package.json ./packages/shared/
RUN yarn workspaces focus --all
# Onyl copy in the relevant source files for the dependencies
COPY packages/objectloader ./packages/objectloader/
COPY packages/viewer ./packages/viewer/
COPY packages/frontend ./packages/frontend/
COPY packages/shared ./packages/shared/
# This way the foreach only builds the frontend and its deps
RUN yarn workspaces foreach -W run build
RUN DEBIAN_FRONTEND=noninteractive \
apt-get -q update && \
apt-get install --no-install-recommends -y \
gettext=0.21-4 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# production stage
FROM bitnami/openresty:1.21.4-3-debian-11-r3@sha256:456f29ba40fb4b5591ded0666c50c5026e3e0f97397440b9c5f2246813de9ec8 as production-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION
ENV NODE_ENV=${NODE_ENV}
ENV FILE_SIZE_LIMIT_MB=100
COPY --from=build-stage /usr/bin/envsubst /usr/bin/envsubst
COPY --from=build-stage /speckle-server/packages/frontend/dist /app
COPY packages/frontend/nginx/ /opt/bitnami/openresty/nginx/
# prepare the environment
ENTRYPOINT ["/opt/bitnami/openresty/nginx/docker-entrypoint.sh"]
EXPOSE 8080
CMD ["/opt/bitnami/scripts/openresty/entrypoint.sh", "/opt/bitnami/scripts/openresty/run.sh"]
-87
View File
@@ -1,87 +0,0 @@
# The Speckle Frontend App
[![Twitter Follow](https://img.shields.io/twitter/follow/SpeckleSystems?style=social)](https://twitter.com/SpeckleSystems) [![Community forum users](https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&style=flat-square&logo=discourse&logoColor=white)](https://speckle.community) [![website](https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square)](https://speckle.systems) [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/)
## Disclaimer
We're working to stabilize the 2.0 API, and until then there will be breaking changes.
Notes:
- In **development** mode, the Speckle Server will proxy the frontend from `localhost:3000` to `localhost:8080`. If you don't see anything, ensure you've run `yarn serve` in the frontend package.
- In **production** mode, the Speckle Frontend will be statically served by nginx (see the Dockerfile in the current directory).
## Documentation
Comprehensive developer and user documentation can be found in our:
#### 📚 [Speckle Docs website](https://speckle.guide/dev/)
## Project setup
Make sure you follow the Developing and Debugging section in the project root readme.
### Running
Dev server with hot reload:
```
yarn dev
```
Build static build & serve it (for development, otherwise use docker image):
```
yarn build && yarn serve
```
### Apollo Client
We're on Apollo Client v3 and Vue Apollo v4 (both the options API and composition API) in this package, so pretty much all of the latest and greatest features are there and ready to be used.
**Note**: Do not import anything from `@apollo/client`, use `@apollo/client/core` instead! Otherwise you risk bundling in React dependencies, which we definitely do not need!
### TypeScript
This project also supports TypeScript, both in Vue SFCs and outside them. It's preferred that you use it when writing new code and also migrate JS files when there's a good oppurtunity to do so.
#### TS in Vue
1. Set `<script lang="ts">` in your .vue SFC
1. Make sure you do `export default defineComponent({...})` or `export default Vue.extend({...})` (or something else that is explicity typed to be a Vue component) not just `export default`, otherwise it's not clear to TS that the exported object is a Vue component
1. If Vetur reports incorrect errors, check this out: https://vuejs.github.io/vetur/guide/FAQ.html
Note: If you're defining a Vue component in a non-standard way (e.g. `vueWithMixins([]).extends({...})`), make sure you add a `// @vue/component` comment right above the Vue component object definition so that ESLint shows Vue appropriate linting rules, otherwise it won't.
#### Improved GraphQL DX w/ TS
Run `yarn gqlgen` to generate relevant TS types from the GraphQL Schema (introspected from server which must be running) and operations defined in the frontend. Afterwards make sure you import them from the generated `graphql.ts` file, not the original file where you defined the operation/fragment.
### Packaging for production
If you plan to package the frontend to use in a production setting, see our [Server deployment instructions](https://speckle.guide/dev/server-setup.html) (chapter `Run your speckle-server fork`)
### Troubleshooting
#### Vue TypeScript types get stuck in VSCode
Restart the Vetur Vue Language Server (VLS) through the command palette. Vetur is a bit janky and sometimes it gets stuck and isn't able to find new types/code.
#### Property 'xxx' does not exist on type 'CombinedVueInstance'
If you are getting a lot of Property 'xxx' does not exist on type 'CombinedVueInstance' errors, it's an issue with Vue's typing and TypeScript. You can work around it by annotating the return type for each computed/data property, making sure data/props keys are defined even if they're empty.
#### Vue Apollo Options API fetchMore() doesn't update the state/template quickly enough
This seems to be an issue that appeared after the Apollo Client v2 -> Apollo Client v3 upgrade. It only becomes an issue when you're trying to use vue-infinite-loader with fetchMore(), because the infinite loader triggers an infinite amount of requests with the old cursor, because the cursor hasn't been updated yet at that point.
The workaround is simple - use the Vue Apollo Composition API fetchMore
## Community
If in trouble, the Speckle Community hangs out on [the forum](https://speckle.community). Do join and introduce yourself! We're happy to help.
## License
Unless otherwise described, the code in this repository is licensed under the Apache-2.0 License. Please note that some modules, extensions or code herein might be otherwise licensed. This is indicated either in the root of the containing folder under a different license file, or in the respective file's header. If you have any questions, don't hesitate to get in touch with us via [email](mailto:hello@speckle.systems).
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
client: {
service: 'speckle-server',
url: 'http://127.0.0.1:3000/graphql',
includes: ['src/**/*.{js,jsx,ts,tsx,vue,gql}']
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
ignore: ['../viewer/dist', '../objectloader/dist'],
plugins: ['lodash']
}
-24
View File
@@ -1,24 +0,0 @@
overwrite: true
schema:
- 'http://127.0.0.1:3000/graphql'
- 'src/graphql/local-only/schema.gql'
documents:
- 'src/graphql/**/*.gql'
- 'src/**/*.{ts,tsx,js,jsx}'
- '!src/graphql/generated/**/*'
generates:
src/graphql/generated/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-document-nodes'
- 'typed-document-node'
config:
dedupeFragments: true
config:
scalars:
JSONObject: Record<string, unknown>
DateTime: string
require:
- ts-node/register
- tsconfig-paths/register
-119
View File
@@ -1,119 +0,0 @@
import {
baseConfigs,
globals,
prettierConfig,
getESMDirname
} from '../../eslint.config.mjs'
import pluginVue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
const tsParserOptions = {
tsconfigRootDir: getESMDirname(import.meta.url),
project: './tsconfig.eslint.json',
extraFileExtensions: ['.vue']
}
/**
* @type {Array<import('eslint').Linter.FlatConfig>}
*/
const configs = [
...baseConfigs,
{
ignores: ['nginx/**', 'generated/**/*']
},
{
languageOptions: {
sourceType: 'module',
globals: {
...globals.browser
}
}
},
{
files: ['*.js'],
ignores: ['vite.config.js'],
languageOptions: {
sourceType: 'commonjs',
globals: {
...globals.node
}
}
},
// TS
...tseslint.configs.recommendedTypeChecked.map((c) => ({
...c,
files: [...(c.files || []), '**/*.ts', '**/*.d.ts', '**/*.vue']
})),
{
files: ['**/*.ts', '**/*.d.ts'],
languageOptions: {
parserOptions: {
...tsParserOptions
}
}
},
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off'
}
},
// Vue
...pluginVue.configs['flat/vue2-recommended'].map((c) => ({
...c,
files: [...(c.files || []), '**/*.vue']
})),
{
files: ['**/*.vue'],
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'vue/component-name-in-template-casing': ['warn', 'kebab-case'],
'vue/require-default-prop': 'off',
// TODO: Can we clean some of these up?
// There was a lot of `any` magic in FE1, would take a lot of effort to clean those up
'@typescript-eslint/no-unsafe-call': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/no-unsafe-member-access': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/no-unsafe-assignment': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/no-unsafe-argument': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/no-unsafe-return': 'off' // can be turned on, but there's a lot of fixing to do
},
languageOptions: {
parserOptions: {
parser: tseslint.parser,
...tsParserOptions
}
}
},
// Vue + TS
{
files: ['**/*.vue', '**/*.ts', '**/*.d.ts'],
rules: {
'@typescript-eslint/unbound-method': 'off', // too many false positives
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
'@typescript-eslint/no-this-alias': 'off', // who cares lol
'@typescript-eslint/no-misused-promises': 'off', // too restrictive
'@typescript-eslint/no-implied-eval': 'off', // false positives cause of any
'@typescript-eslint/no-unsafe-enum-comparison': 'off', // too restrictive
'@typescript-eslint/no-floating-promises': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/require-await': 'off', // can be turned on, but there's a lot of fixing to do
'@typescript-eslint/await-thenable': 'off' // can be turned on, but there's a lot of fixing to do
}
},
{
files: ['./*.{js,ts}', './build-config/**/*.{js, ts}'],
languageOptions: {
sourceType: 'commonjs',
globals: {
...globals.node,
...globals.commonjs
}
}
},
prettierConfig
]
export default configs
-142
View File
@@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<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,maximum-scale=1.0, user-scalable=0"
/>
<meta property="og:title" content="Speckle" />
<meta property="og:description" content="" />
<meta property="og:image" content="/og_image.png" />
<meta property="og:url" content="/" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.ico" />
<title>Speckle</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<style type="text/css">
body {
background-color: #333333;
color: #0a66ff;
}
@media screen and (prefers-color-scheme: light) {
body {
background-color: white !important;
color: #0a66ff;
}
}
.tada {
-webkit-animation-name: tada;
animation-name: tada;
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
animation-iteration-count: infinite;
}
.hover-tada:hover {
-webkit-animation-name: tada;
animation-name: tada;
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
animation-iteration-count: infinite;
}
@-webkit-keyframes tada {
0% {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
10%,
20% {
-webkit-transform: scale3d(0.8, 0.8, 0.8) rotate3d(0, 0, 1, -3deg);
transform: scale3d(0.8, 0.8, 0.8) rotate3d(0, 0, 1, -3deg);
}
30%,
50%,
70%,
90% {
-webkit-transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, 3deg);
transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, 3deg);
}
40%,
60%,
80% {
-webkit-transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, -3deg);
transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, -3deg);
}
100% {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@keyframes tada {
0% {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
10%,
20% {
-webkit-transform: scale3d(0.8, 0.8, 0.8) rotate3d(0, 0, 1, -3deg);
transform: scale3d(0.8, 0.8, 0.8) rotate3d(0, 0, 1, -3deg);
}
30%,
50%,
70%,
90% {
-webkit-transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, 3deg);
transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, 3deg);
}
40%,
60%,
80% {
-webkit-transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, -3deg);
transform: scale3d(1.4, 1.4, 1.4) rotate3d(0, 0, 1, -3deg);
}
100% {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
</style>
</head>
<body>
<noscript>
<strong>
We're sorry but Speckle doesn't work properly without JavaScript enabled. Please
enable it to continue.
</strong>
</noscript>
<div id="app">
<div
style="
width: 100%;
height: 300px;
font-family: sans-serif !important;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
text-align: center;
font-weight: 400;
font-size: 10px;
"
>
<img src="/logo.svg" style="max-width: 50px" class="tada" />
</div>
</div>
<script type="module" src="./src/main/app.js"></script>
</body>
</html>
@@ -1,12 +0,0 @@
#!/bin/bash
set -euo pipefail
# shellcheck disable=SC2016,SC2046
defined_envs=$(printf '${%s} ' $(env | cut -d= -f1))
echo Starting nginx environment template rendering with "${defined_envs}"
cp /opt/bitnami/openresty/nginx/mime.types /opt/bitnami/openresty/nginx/conf/mime.types
envsubst "${defined_envs}" < /opt/bitnami/openresty/nginx/templates/nginx.conf.template > /opt/bitnami/openresty/nginx/conf/nginx.conf
echo Nginx conf rendered, starting server...
exec "$@"
-98
View File
@@ -1,98 +0,0 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/avif avif;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/wasm wasm;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}
@@ -1,160 +0,0 @@
pcre_jit on;
error_log stderr info;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# move default write paths to a custom directory
# kubernetes can mount this directory and prevent writes to the root directory
# https://github.com/openresty/docker-openresty/issues/119
client_body_temp_path /bitnami/openresty/nginx-client-body;
proxy_temp_path /bitnami/openresty/nginx-proxy;
fastcgi_temp_path /bitnami/openresty/nginx-fastcgi;
uwsgi_temp_path /bitnami/openresty/nginx-uwsgi;
scgi_temp_path /bitnami/openresty/nginx-scgi;
log_format json_combined escape=json
'{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"remote_user":"$remote_user",'
'"request":"$request",'
'"status": "$status",'
'"body_bytes_sent":"$body_bytes_sent",'
'"request_time":"$request_time",'
'"http_referrer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
sendfile on;
keepalive_timeout 65;
access_log /dev/stdout json_combined;
# Speckle configuration
server_tokens off;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/x-javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/xhtml+xml
application/xml
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2c0f:f248::/32;
set_real_ip_from 2a06:98c0::/29;
#use any of the following two
real_ip_header CF-Connecting-IP;
#real_ip_header X-Forwarded-For;
server {
listen 8080;
client_max_body_size 100m;
location / {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location ~* ^/(favicon.ico|logo.svg|loadingImage.png|og_image.png) {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
expires 1d;
}
location ~* ^/(js/.*|fonts/.*|(css/.*)|(img/.*)|(assets/.*)) {
root /app;
index index.html;
try_files $uri $uri/ /index.html;
expires 1y;
}
location ~ ^/streams/.* {
default_type text/html;
content_by_lua_block {
local f = assert(io.open('/app/index.html', "rb"))
local content = f:read("*all")
f:close()
local http_host = ngx.var.http_host
content = content:gsub('<meta property=og:title (.-)>', '<meta property=og:title content="Speckle Stream">')
local stream_id = ngx.var.uri:sub(10)
local img_tag = '<meta property=og:image content="https://' .. http_host .. '/preview/' .. stream_id .. '?postprocess=og&ts=' .. ngx.now() .. '">'
content = content:gsub('<meta property=og:image (.-)>', img_tag)
ngx.say(content)
}
}
location ~* ^/(graphql|explorer|(auth/.*)|(objects/.*)|(preview/.*)|(api/.*)|(static/.*)) {
resolver 127.0.0.11 valid=30s;
set $upstream_speckle_server speckle-server;
client_max_body_size ${FILE_SIZE_LIMIT_MB}m;
proxy_pass http://$upstream_speckle_server:3000;
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /app;
}
}
}
-102
View File
@@ -1,102 +0,0 @@
{
"name": "@speckle/frontend",
"version": "2.5.4",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview --port 8080",
"profile": "vite-bundle-visualizer --output profiler/stats.html",
"lint:eslint": "eslint .",
"lint:ts": "vue-tsc --noEmit",
"lint": "yarn lint:eslint && yarn lint:ts",
"lint:ci": "yarn lint:ts",
"gqlgen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.7.0",
"@speckle/shared": "workspace:^",
"@speckle/viewer": "2.17.8",
"@tiptap/core": "^2.0.0-beta.176",
"@tiptap/extension-bold": "^2.0.0-beta.26",
"@tiptap/extension-document": "^2.0.0-beta.15",
"@tiptap/extension-hard-break": "^2.0.0-beta.30",
"@tiptap/extension-history": "^2.0.0-beta.21",
"@tiptap/extension-italic": "^2.0.0-beta.26",
"@tiptap/extension-link": "^2.0.0-beta.38",
"@tiptap/extension-mention": "^2.0.0-beta.97",
"@tiptap/extension-paragraph": "^2.0.0-beta.23",
"@tiptap/extension-placeholder": "^2.0.0-beta.48",
"@tiptap/extension-strike": "^2.0.0-beta.27",
"@tiptap/extension-text": "^2.0.0-beta.15",
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/vue-2": "^2.0.0-beta.79",
"@tryghost/content-api": "^1.5.12",
"@vue/apollo-composable": "^4.0.0-alpha.19",
"@vue/apollo-option": "^4.0.0-alpha.20",
"@vuejs-community/vue-filter-date-format": "^1.6.3",
"@vuejs-community/vue-filter-date-parse": "^1.2.0",
"@vueuse/core": "^9.13.0",
"apexcharts": "^3.33.1",
"apollo-upload-client": "^17.0.0",
"dompurify": "^2.5.4",
"graphql": "^15.0.0",
"graphql-tag": "^2.12.6",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"numeral": "^2.0.6",
"portal-vue": "^2.1.7",
"regenerator-runtime": "^0.13.9",
"subscriptions-transport-ws": "^0.11.0",
"tween": "^0.9.0",
"uuid": "^8.3.2",
"v-tooltip": "^2.0.3",
"vue": "^2.7.5",
"vue-apexcharts": "^1.6.1",
"vue-histogram-slider": "^0.3.8",
"vue-infinite-loading": "^2.4.5",
"vue-mixpanel": "1.0.7",
"vue-router": "^3.4.9",
"vue-timeago": "^5.1.2",
"vuedraggable": "^2.24.3",
"vuetify": "^2.6.10",
"vuetify-image-input": "^19.1.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/introspection": "^4.0.3",
"@graphql-codegen/typed-document-node": "^5.0.7",
"@graphql-codegen/typescript": "^4.0.7",
"@graphql-codegen/typescript-document-nodes": "^4.0.7",
"@graphql-codegen/typescript-operations": "^4.2.1",
"@mdi/font": "^5.8.55",
"@parcel/watcher": "^2.4.1",
"@swc/core": "^1.2.222",
"@types/apollo-upload-client": "^17.0.1",
"@types/dompurify": "^2.3.3",
"@types/lodash": "^4.14.180",
"@types/mixpanel-browser": "^2.50.2",
"@types/node": "^17.0.43",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-vue2": "^2.2.0",
"babel-plugin-lodash": "^3.3.4",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.26.0",
"prettier": "^2.5.1",
"sass": "~1.32.6",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.0.0",
"type-fest": "^2.13.1",
"typescript": "~4.5.5",
"unplugin-vue-components": "^0.25.1",
"vite": "^5.3.4",
"vite-bundle-visualizer": "^0.7.0",
"vite-plugin-simple-gql": "^0.5.0",
"vue-tsc": "^1.8.8"
},
"engines": {
"node": "^18.19.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 416 314" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-5895,-5112)">
<g id="Artboard1" transform="matrix(1,0,0,1,4187.76,4786.57)">
<rect x="0" y="0" width="3840" height="2160" style="fill:none;"/>
<g transform="matrix(0.998475,-0.0552112,0,1.00153,504.711,-136.152)">
<rect x="1294.87" y="614.156" width="204.453" height="204.719" style="fill:rgb(4,126,251);"/>
</g>
<g transform="matrix(0.989814,-0.0547323,0.524518,0.471524,802.293,120.929)">
<rect x="643.94" y="618.105" width="206.242" height="64.29" style="fill:rgb(123,188,255);"/>
</g>
<g transform="matrix(0.362077,0.325495,0,1.34467,1135,426.883)">
<rect x="1736.88" y="-457.431" width="93.132" height="152.477" style="fill:rgb(49,59,207);"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 416 314" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-5895,-5112)">
<g id="Artboard1" transform="matrix(1,0,0,1,4187.76,4786.57)">
<rect x="0" y="0" width="3840" height="2160" style="fill:none;"/>
<g transform="matrix(0.998475,-0.0552112,0,1.00153,504.711,-136.152)">
<rect x="1294.87" y="614.156" width="204.453" height="204.719" style="fill:rgb(4,126,251);"/>
</g>
<g transform="matrix(0.989814,-0.0547323,0.524518,0.471524,802.293,120.929)">
<rect x="643.94" y="618.105" width="206.242" height="64.29" style="fill:rgb(123,188,255);"/>
</g>
<g transform="matrix(0.362077,0.325495,0,1.34467,1135,426.883)">
<rect x="1736.88" y="-457.431" width="93.132" height="152.477" style="fill:rgb(49,59,207);"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

-249
View File
@@ -1,249 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 997 769" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-3958,-1864)">
<g id="devs" transform="matrix(1,0,0,1,2934.11,-10.551)">
<rect x="1024" y="1875.4" width="995.978" height="768" style="fill:none;"/>
<g transform="matrix(1,0,0,1,14.9065,16.6081)">
<g transform="matrix(1,0,0,1,-29.4313,15.2744)">
<g transform="matrix(0.556609,0,0,0.556609,737.492,974.014)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial1);"/>
</g>
<g transform="matrix(0.482727,-0.278703,0.557405,0.321818,736.148,973.087)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(1,0,0,1,0,-70.5477)">
<g transform="matrix(0.556609,0,0,0.556609,558.017,1301.2)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial2);"/>
</g>
<g transform="matrix(0.482727,-0.278703,0.557405,0.321818,556.673,1300.27)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(1,0,0,1,0,-70.5477)">
<g transform="matrix(0.556609,0,0,0.556609,558.017,1252.14)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial3);"/>
</g>
<g transform="matrix(0.482727,-0.278703,0.557405,0.321818,556.673,1251.22)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(1,0,0,0.590914,-4.54747e-13,927.132)">
<g transform="matrix(4.41926e-17,0.721719,-0.187381,1.14738e-17,1917.43,-929.382)">
<clipPath id="_clip4">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(8.48423e-17,-5.33673,2.34481,5.53009e-16,3470.31,4848.15)">
<use xlink:href="#_Image5" x="483.725" y="411.539" width="12.747px" height="91.299px" transform="matrix(0.980542,0,0,0.992383,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(4.41926e-17,0.721719,-0.187381,1.14738e-17,1938.81,-929.382)">
<clipPath id="_clip6">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip6)">
<g transform="matrix(8.48423e-17,-5.33673,2.34481,5.53009e-16,3470.31,4962.25)">
<use xlink:href="#_Image5" x="505.529" y="411.539" width="12.747px" height="91.299px" transform="matrix(0.980542,0,0,0.992383,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(4.41926e-17,0.721719,-0.187381,1.14738e-17,1963.76,-929.382)">
<clipPath id="_clip7">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip7)">
<g transform="matrix(8.48423e-17,-5.33673,2.34481,5.53009e-16,3470.31,5095.41)">
<use xlink:href="#_Image5" x="530.975" y="411.539" width="12.747px" height="91.299px" transform="matrix(0.980542,0,0,0.992383,0,0)"/>
</g>
</g>
</g>
</g>
<g transform="matrix(0.785519,0,0,0.785519,-1990.09,499.836)">
<g transform="matrix(0.708588,0,0,0.708588,3243.86,732.695)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial8);"/>
</g>
<g transform="matrix(0.614533,-0.354801,0.709601,0.409689,3242.15,731.515)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(37,99,235);"/>
</g>
</g>
<g transform="matrix(3.36792,-1.94447,0.544272,0.314236,-1836.01,1629.53)">
<clipPath id="_clip9">
<rect x="348.837" y="4017.54" width="23.533" height="75.348"/>
</clipPath>
<g clip-path="url(#_clip9)">
<g transform="matrix(0.14846,0.918658,-0.25714,1.59116,363.633,2977.02)">
<use xlink:href="#_Image10" x="519.642" y="312.497" width="120.267px" height="69.436px" transform="matrix(0.993944,0,0,0.991947,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(1,0,0,1,0,10)">
<g transform="matrix(-1.54203,-0.890293,0.104003,-0.060046,1564.74,2738.08)">
<clipPath id="_clip11">
<rect x="317.463" y="4041.6" width="57.199" height="89.822"/>
</clipPath>
<g clip-path="url(#_clip11)">
<g transform="matrix(-0.324247,4.80757,-0.561613,-8.32695,680.115,4740.3)">
<use xlink:href="#_Image12" x="400.21" y="312.242" width="97.545px" height="56.317px" transform="matrix(0.995354,0,0,0.988025,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(-1.54203,-0.890293,0.104003,-0.060046,1544.61,2749.7)">
<clipPath id="_clip13">
<rect x="317.463" y="4041.6" width="57.199" height="89.822"/>
</clipPath>
<g clip-path="url(#_clip13)">
<g transform="matrix(-0.324247,4.80757,-0.561613,-8.32695,680.115,4933.82)">
<use xlink:href="#_Image12" x="379.99" y="324.003" width="97.545px" height="56.317px" transform="matrix(0.995354,0,0,0.988025,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(-1.54203,-0.890293,0.104003,-0.060046,1522.97,2762.2)">
<clipPath id="_clip14">
<rect x="317.463" y="4041.6" width="57.199" height="89.822"/>
</clipPath>
<g clip-path="url(#_clip14)">
<g transform="matrix(-0.324247,4.80757,-0.561613,-8.32695,680.115,5141.93)">
<use xlink:href="#_Image12" x="358.245" y="336.651" width="97.545px" height="56.317px" transform="matrix(0.995354,0,0,0.988025,0,0)"/>
</g>
</g>
</g>
</g>
<g transform="matrix(-0.830211,-9.20644e-17,2.77667e-16,-0.839612,2754.97,4122.21)">
<g transform="matrix(-1.54203,-0.890293,0.104003,-0.060046,1570.11,2734.48)">
<clipPath id="_clip15">
<rect x="317.463" y="4041.6" width="57.199" height="89.822"/>
</clipPath>
<g clip-path="url(#_clip15)">
<g transform="matrix(0.39056,-5.79078,0.668896,9.91761,-151.653,2875.93)">
<use xlink:href="#_Image16" x="492.27" y="415.625" width="80.983px" height="47.285px" transform="matrix(0.999786,0,0,0.9851,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(-1.54203,-0.890293,0.104003,-0.060046,1508.59,2769.81)">
<clipPath id="_clip17">
<rect x="317.463" y="4041.6" width="57.199" height="89.822"/>
</clipPath>
<g clip-path="url(#_clip17)">
<g transform="matrix(0.39056,-5.79078,0.668896,9.91761,-151.757,3465.86)">
<use xlink:href="#_Image16" x="543.351" y="385.513" width="80.983px" height="47.285px" transform="matrix(0.999786,0,0,0.9851,0,0)"/>
</g>
</g>
</g>
</g>
<g transform="matrix(0.785519,0,0,0.597818,284.221,915.242)">
<g transform="matrix(5.62591e-17,0.91878,-0.238544,1.46066e-17,2264.17,-2166.43)">
<clipPath id="_clip18">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip18)">
<g transform="matrix(8.48423e-17,-5.33673,1.82062,4.29382e-16,4074.24,5623.79)">
<use xlink:href="#_Image19" x="631.949" y="194.96" width="12.747px" height="117.586px" transform="matrix(0.980542,0,0,0.996492,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(5.62591e-17,0.91878,-0.238544,1.46066e-17,2291.39,-2166.43)">
<clipPath id="_clip20">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip20)">
<g transform="matrix(8.48423e-17,-5.33673,1.82062,4.29382e-16,4074.24,5737.89)">
<use xlink:href="#_Image19" x="653.754" y="194.96" width="12.747px" height="117.586px" transform="matrix(0.980542,0,0,0.996492,0,0)"/>
</g>
</g>
</g>
<g transform="matrix(5.62591e-17,0.91878,-0.238544,1.46066e-17,2323.16,-2166.43)">
<clipPath id="_clip21">
<rect x="4427.94" y="2248.85" width="214.08" height="68.028"/>
</clipPath>
<g clip-path="url(#_clip21)">
<g transform="matrix(8.48423e-17,-5.33673,1.82062,4.29382e-16,4074.24,5871.05)">
<use xlink:href="#_Image19" x="679.2" y="194.96" width="12.747px" height="117.586px" transform="matrix(0.980542,0,0,0.996492,0,0)"/>
</g>
</g>
</g>
</g>
<g transform="matrix(1.50894,0,0,1.50894,-984.123,-1457.54)">
<g>
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial22);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
</g>
<g transform="matrix(0.785519,0,0,0.785519,-1990.09,460.263)">
<g transform="matrix(0.708588,0,0,0.708588,3243.86,732.695)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial23);"/>
</g>
<g transform="matrix(0.614533,-0.354801,0.709601,0.409689,3242.15,731.515)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(37,99,235);"/>
</g>
</g>
<g transform="matrix(1.50894,0,0,1.50894,-1292.7,-1350.8)">
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial24);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(1,0,0,1,-16.3136,-12.923)">
<g transform="matrix(0.700752,0,0,0.700752,386.322,699.249)">
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial25);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(0.700752,0,0,0.700752,436.68,670.174)">
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial26);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(0.700752,0,0,0.700752,492.398,702.343)">
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial27);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
<g transform="matrix(0.700752,0,0,0.700752,438.856,733.255)">
<g transform="matrix(0.368875,0,0,0.368875,1121.71,1555.77)">
<path d="M1564.47,2104.14L1554.7,2098.47L1554.7,2148.03L1554.7,2148.03C1554.7,2151.55 1557.27,2155.27 1562.29,2158.16L1695.31,2234.96C1700.08,2237.72 1706.12,2239.19 1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34L1711.94,2239.34C1717.33,2239.47 1722.52,2238.46 1726.34,2236.25L1863.82,2156.88C1867.64,2154.67 1869.39,2151.67 1869.16,2148.56L1869.18,2148.55L1869.18,2099.6L1859.32,2104.09L1728.57,2028.6C1719.39,2023.3 1705.49,2022.73 1697.54,2027.31L1564.47,2104.14Z" style="fill:url(#_Radial28);"/>
</g>
<g transform="matrix(0.319912,-0.184701,0.369403,0.213275,1120.82,1555.16)">
<path d="M-1014.61,2607.25C-1014.61,2598.07 -1022.06,2590.62 -1031.24,2590.62L-1189.98,2590.62C-1199.16,2590.62 -1206.61,2598.07 -1206.61,2607.25L-1206.61,2740.27C-1206.61,2749.45 -1199.16,2756.9 -1189.98,2756.9L-1031.24,2756.9C-1022.06,2756.9 -1014.61,2749.45 -1014.61,2740.27L-1014.61,2607.25Z" style="fill:rgb(241,243,248);"/>
</g>
</g>
</g>
</g>
</g>
</g>
<defs>
<radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<image id="_Image5" width="13px" height="92px" xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCABcAA0DAREAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAwUABv/EABsQAAICAwEAAAAAAAAAAAAAAAAEAQIhMWED/8QAGQEAAwEBAQAAAAAAAAAAAAAAAgMEAQAH/8QAGREBAQEBAQEAAAAAAAAAAAAAABIBAwIT/9oADAMBAAIRAxEAPwDjD3154xzj18JkXvsUkhfGjLbKnRbhLvQ2S1WxoDegpVKrcJd6GyWFsaBtsqdV+Eu9DpLC+NAW2VOi3CXehslqtjQP0FKnVbhLvs2SwtjQO9GyqUX4S77OkkLY0BvRsqdV+Eu9DZLC2NA/QUqlVuEu9DZJVbGgN6NlUqvwm32dJI8IAtsnAExzn//Z"/>
<radialGradient id="_Radial8" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<image id="_Image10" width="121px" height="70px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHkAAABGCAYAAADo1jsxAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAJA0lEQVR4nO2cS4geRRCAqzoe4iOJRs1B9JBDFCGHCKJCDhLEgKhgCB4kiEp8kYARgwYiURRziCJBomgIGlARIqiICgYkiHqISBDMwaAiKIJCdpPdvC877WG6Z/pR/ZiZnvln/v0Lln+Zranqqa+ruufvYhEmUllmT1/gIZ0lixZiF2OJkd4MZAgSA9eUPsAe+QCGIHXgmjJK2GxUjoci0zMXeJYB8IaYU0yUujLJZIf8O3WeMwRgiIAsDxRDABQ/TaTrrJ5ANuTv/85zRADGcqgA+aeEzTAP2pBgTyAL+eOfPHNNkBI2oszm4cGeQAaAY3+d44yhArKEXWSzD7YCvK60CXpeQz7657m8NGuZm8MurnlgM0RNp6+w5yXkI7+d41pWMijBFtfcsBF1fQo2axjZlLDnHeQffz3LmUg1ZkAx4ZmwGQIABmBb15qNNwXseQP5+6NnuQnGBYW+5oet35cWdlPQYw/50M9nORVwHVR92P77+gF7bCF//dMZbkKh4FGwydJtTRIMlG4btqlXV6rCHkvIXx4+w+2Ah2GHoND3jQZ2FdBjBfmzH85w/1pJw3ZvqJrBdlcEen2vIzGwxwLygW9Pi3UXIzOq1HWXbve6GoLddDNXR3ywBw35g29OW2W5Wvl0w7bvC8NOvZmrIxTswULef9AGXL986lWgzruwuyI038xVFRP04CDv/eoUV6GkLZ/pYVPrsHWfOMpsC/ZgIO/5/BSns9INO6p8GmfFMbB1KLRdJtoxvLBN3y3BXpAKQpuy+9NTRVcFFz9gfHIxXzkAAOadHGorRlb8hrqOUOKo/E7Y1XQ45Ye2CwiQKQPJdZD2TdoVtlHvTqkCu9eZ/OrHs7zIKGdmqFmJgcxQswdJHSqzmdjy2jYUP+i3q+vI+9Dv29G4oOrEwO4l5Fc+muU2KDdsG5QNWw9WHGyGJigbtqbjgK35joRt2qVgqzo+2L2C/OL7s9wOlhu2W6eE7Q+WA7Zq1wHbq6MeXUJF2AG7RQbLMUfA7s2a/Pz+Gc7FCIv1CsW6aKxXAAAZF3906GjXCLu5DSgWPi6ucY60byV4xX2IpE5xrTSv2dVtqDpyfLTd4j6pX+igpgNQwl6yaGGdvVpaeXbfDLezx87AMjPMrNQz210+3ZlNZw/Svs1OESUDTZ3CriezaR09s52+A10qVywe8SvUlndmuB0sBxRvIORDoVeHsksFy4Ltsmt2ikA12FTjguUnYFfdfKmwly0d8Zchm948yamZR8EuH8oN2wYVhm3apTPDBGXbZQhgvceCH7YJJmWXyrXLLiZ5dgr58TdywHqwbNjOzFVg28HKfXhhB+zSmWHDJn2HYFN2DdghnfJTh738GhqulE4gP/z6SR4uYegJFv2AsbDdmWGCauMUyx6zaVe/rxrs66/zAwZoGfKGXSc4CQV8JYyGTQU5BDsExTlJDNjkJAnADmWg27cO2+V75fJLotm1Avn+nSe4Dcr/gClhkxWhBuyYMZt2/b7rTFBd96YV8XClJIe87uUT3BywCdsfLBp22vLph91sgnoONhpM0FtvvLQ2q4sSsYW7X5jmiABzWTlgjgCZMnCOAIhcfGKho+qjcQ2QCxvo1HHZtX2r99n61H2Z0M3/hg7ftt1MqUS+MUu7bt8Aq1fWBwyQIJPXbp+2Wl7NMpOqfIICu+vyWacaNe1SWbOqGVwpjYys2TbNfQE3d4pdrpWpyqftu86Ybdi+SbL25suSwJVSq1yv3jrFGSJkma8UAXDkBWxXKSpLJV3G9dLdXfl0+/YvEZTdTNgFEQfXmO+5LS1cKZWM3rJlquirql4+6cwOZWDK8mlmdtXyGa4I9btU7lvdDmCAyExetXmKM8w3VeXslAMvZzM1O12ZTWcGvTGyM6MMWvXNHBe+0eM74WaO5SdFehXjBez1t7cHV0rQwconj3NnHxMxq30ZaF+LfRe27aZa38HIbOtMmrCboktlwx2LWocrxenohkePcwpKXPmsBztcPsHqooiC7WzWU+4LQNGvNetSeejO7gADEJBXbDyu/SsifXbKYMXDtlpbCJBlsDywrf6nMGwTCt2/VT6nEzaavm3Ybp3S7mN3dQtXSuHUhKspoTngMOwQFCoQFOxgox0B293qWsJm6PJdQtF0HCAZ5mH0wd507+KRwJWCAH7AmnIEbDtYBJTArC+DVQ02w5Bvw64DtjY+B2zzGgX76XWjhStlQSxgKVY/EsgeJKT7ja0eKkcfk7gHwO5/4qKfy9UvbdrVbJA6hl0Fhe0b6D4xKhbF+BG2ru8HYICaX4bIxm8E8TknXo8YABOwMy5mN1N1RPYAAJ/jRQZykNfEq4aoBtmczC6e2xDNcDJ7XHYzYRfApSNt5HZZlo8hUzoqS9/Shng9YpjbyMTzy8wWdrc9sKQ3cKU0OqCQMxiRhg1ABcsIeMZFsFALlg82A1RA0XYh45BBWcZp3xKUmHARsFkmJpy0K2zseLB/cKUkOYUyYbMMgAMXwaJh5zrluhgD24JCwJZ2C1AEbNt3GLaVuQL2zkcu7y1cKcmOGgFK2MU3QARsO1hh2JCBAOWAAui0q8K2vpOOgG19M6f42bWx/4ABiq1C/A67knEZTPFjfteNQOxWtbYg4hQLStj26xuSdu136GYtSbufGAZcKdZgewHbCHS1g41Sn3p/D8L22H1r8xWDgiuFHHQboAHCsKkgm7BtUGlgh3zvfWqYgAEckKV0BRuNQMfAo2C7QXm+mdP0bdjvPTNcuFKiHqAt2HRWtnOwYcL2VwSED58bPlwplR6kS9jV/ttOhVMsYn1XfR/YvnRs4Eqp/EBtgQaIg+JfY5vB/mTH+AEGqAFZylBgx2zmvnhpPOFKafxwXcAm34UDsGPW94M7rxxruFKSPGSboAGqwY7ZzB3aNT/gSkn6sKOEHX4XBmAM4bvX5hdggMSQpXQKO7JL5fDuq+YdXCmtPnibsBEhqkvlyJ75C1dK6wFoO6sp2PK/F/zy9gQwQAeQpXQCWwA/tu/qCVxFOg9Gm7B/f3cCl5KRBSUl7Alcv4w0OClATwCHpRcBqgN7AjdeehWoGNgTuNXlf9nOIZUXHcgTAAAAAElFTkSuQmCC"/>
<image id="_Image12" width="98px" height="57px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGIAAAA5CAYAAADN7P46AAAACXBIWXMAAA7EAAAOxAGVKw4bAAADd0lEQVR4nO2ZPWgUQRTH31n5FeMn2GgjiiCkCYIgWsVCFBtTKQqigkQEMTYGkYg2CQgqIkSRIDZqoxaiVmohok2wiUVAbMTiPnKXyxfIzbPITW52b3ZuZm/2dnb3/eBYjrk7Zua38/5zs7lKdRHBgO6ulTmTzxN65ExFcEiIXVaE/WJYgYSc0CtChFZH+1gRwSEh4bEqgkNCzAmdEX4QARgD+FcDyJcoP0xZvnPDrgxEAMZfDIAh1q9L77dvXUWrQwPPJJnIYLgkocb4FT3vG2KWXru2kRAV0slRCREnWCaghgDI6ldJe8+O1SREgnJSRCGeO5yhrxwFtDP5Z/buJhl+tCakWF7E5frfNOkB+SCV5M2PAz1rSEgd7Yn4W1jARh4E5ENTbujlx6FeEmI8Ab/+LGDjLg9eIab5cXTf2kzLCD34yd/zqCxRIfOj/2A2hbQ16ImpefTe8RhQmgAQUbEyfO0M4FRfV6aEWBns18k5lK8MX/lStftWBhd2/nA2hFgd5MeJOa38UAmQtV88ti71MiIZ4Lvvs+r8ECfcID8G+9MrJLKBvf4y25wfTbkgz4fmrbC3/fqJ7tQJiXxAzz9V9fJDZ4cl5MfN0+mS0bHBjH+oYmPHhIF//kzzY/Tc+lQI6eggxt7OYPOprWKLq1vCGMD9gWQLiaXz917NeMM86IxKVaJk4c8AHl/ekEghsXZ65GXFXn4In3l6NXkynOjw8LOKVn7w8hS4xfUJejG00Ynx6eBMR4fGy2b50eKIRLy+GXZfiHMdvDJWbj8/JN99f3uTc2MVcbZzAw+m9Z5/SP8kBhw+IsDnUTeFONkpkTN3pvXOr+o7qCABfkHf7m52auxOdSaIkyMlbCXA5BGteP3x0A0hTnRCl+O3Shbzo9H+89GW2Och9g6E4ciNorX8YNj43akn8QlJpAgAgL5rRZTd3br5IQrwE4eQxIrg7B8soH+CVfmBCgF+Oikk8SI4vZcKyucfJgJEOiUjNSI4ey7kPedXtohaSOpEAADsPJsPef+3JiohqRTBiUpIFDJSLYKTBCGZEAHgfrnKjAiOq0IyJ4LjWrnKrAiOK0IyLwLAjXJFIgTiXB0kQkIcQkhEAJ0uVySiBZ0SQiI0ibpckQhDohJCIkIQhQwS0QY2hZAIC9gQQiIs0o4QEmGZsDJIRESYCvkPVqSEQk8gl0YAAAAASUVORK5CYII="/>
<image id="_Image16" width="81px" height="48px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFEAAAAwCAYAAABpJ5bJAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAC4klEQVRoge3aO2gUQRgH8P/MWcTHeRpjIXaKkEax0ULBQmyCIDZaiCD4ACWNCilE0UKCEAIiAUVEFJs0qRQstFAQxCaFWAhaiI0IyZmXSezGIvfdzczu3c3O7t4+Zv7NLewuzPfLNzs7S9ieCzMCBvn+dDszuc7FMFNEiscMJjIixWO2wm1vtMUvY6w7UY7rXZkIIsVVTAYkPzVdw1SK9V1pl9BCPWa0tC3QT3HzdC3Md2X3GBflMdsncjEeMxirIjykmlgFeMy1JDJw1zETG7DLkIkPNg4m50CFNX45w+eHA4XATG2QUTB1PN48Xvv9dD/fmKkOrhMkYwBnBMWk4xaojPp+bFtuIXsyMBkzKh6do+PXd/OH2dMBDV6aETLeugrUqcsaeBwqLgc4Y8p1U7f6c4PZ84HsH54VBGGDp3Ymw4uRrZljZjaAQ9dnRRw8efo/uZotZOZ/xWM36lpnas/CJhZDeAc3Hg8ceHB5Syb1ZI4IAMdv10UcvIp27t753mLmApFyevSPaL0fdli5taku30PX3Tlb61ltuUKknBufE7Z4emeOnEofM5eIlCsTc0LHU3HbLT4s0MHDJzanVmuuEQHg2uN5EZzW5njyPReHqqnUm3tEys1n88IWT9/1nDmaLGZhECmjkwtCXp07rtyhC1PrnpOHNyVSf+EQAWB8arHNrscMT+7MoYPxIQuJSHn0alE0gTRQeecTfPcMfnI7sm+jtUWhESnP3yyFdKYZnv5sPTC4IbJJKRABYPIdQQbxlOenwbvn3l3RIEuDSHn58a9QUczx9C9Ku3euN/IpHSLl7fRytF2P/EVJ2/XsGOiMWVpEAPjwZVkEgNqt3Mo2Ur2HztWqfaFepUakTH9bCdn1mOPJCYN0ApHy9edK8JObgtuazryLjIzpFCIA/Pi1KuLg6alV+5hziJTf9VURB0+Os4iUhaV/sf/9xXlEShxMjyjFFtIjhiQqpkfsEFPM/22HvcYsagRUAAAAAElFTkSuQmCC"/>
<image id="_Image19" width="13px" height="118px" xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAB2AA0DAREAAhEBAxEB/8QAGAABAQEBAQAAAAAAAAAAAAAAAwQAAgb/xAAbEAACAwEBAQAAAAAAAAAAAAAAAwECYSExEf/EABkBAAMBAQEAAAAAAAAAAAAAAAECAwAEB//EABgRAQADAQAAAAAAAAAAAAAAAAABERIC/9oADAMBAAIRAxEAPwDxh7688YzOopMgtqdQqQaGltUYQntSILCOeC7Gl1EYQntSiwjngk9jldRGEJ7UotUc8FnsaXVRhzz2pktUc8F2altEYQns9FhHPBdmpdVGHPPamS1RwXY5XURhzz2pRYRzwXY0uojCE9qZLCeeCbHK6qMIT2pRYRzwXZsrqow557PkkIwXRqX0RhCe1KJCOeCT2NL6pwhPakQSEx8F0NGEMxmf/9k="/>
<radialGradient id="_Radial22" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial23" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial24" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial25" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial26" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial27" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
<radialGradient id="_Radial28" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(139.128,29.5437,-60.595,223.638,1611.84,2160.46)"><stop offset="0" style="stop-color:rgb(224,235,247);stop-opacity:1"/><stop offset="0.57" style="stop-color:rgb(203,221,240);stop-opacity:1"/><stop offset="0.79" style="stop-color:rgb(159,193,227);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(118,166,214);stop-opacity:1"/></radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

-40
View File
@@ -1,40 +0,0 @@
import Vue from 'vue'
import VTooltip from 'v-tooltip'
import VueMixpanel from 'vue-mixpanel'
import PortalVue from 'portal-vue'
import { formatNumber } from '@/plugins/formatNumber'
/**
* Global bootstrapping for the frontend app
*/
// Filter to turn any number into a nice string like '10k', '5.5m'
// Accepts 'max' parameter to set it's formatting while being animated
Vue.filter('prettynum', formatNumber)
// env vars injected by Vite
const enableDevMode = !!import.meta.env.FORCE_VUE_DEVTOOLS || !!import.meta.env.DEV
Vue.config.productionTip = enableDevMode
Vue.config.devtools = enableDevMode
Vue.use(VTooltip, {
defaultDelay: 300,
defaultBoundariesElement: document.body,
defaultHtml: false
})
// In highly restrictive sandboxed environments mixpanel init might fail due to document.cookie access
Vue.use(VueMixpanel, {
token: 'acd87c5a50b56df91a795e999812a3a4',
config: {
// eslint-disable-next-line camelcase
api_host: 'https://analytics.speckle.systems'
}
})
Vue.use(PortalVue)
// Event hub
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
Vue.prototype.$eventHub = new Vue()
@@ -1,300 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import Vue from 'vue'
import { createApolloProvider, ApolloProvider } from '@vue/apollo-option'
import {
ApolloClient,
ApolloLink,
InMemoryCache,
split,
TypePolicies,
from
} from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import { WebSocketLink } from '@apollo/client/link/ws'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import { LocalStorageKeys } from '@/helpers/mainConstants'
import { createUploadLink } from 'apollo-upload-client'
import { AppLocalStorage } from '@/utils/localStorage'
import { getMainDefinition } from '@apollo/client/utilities'
import { OperationDefinitionNode, Kind } from 'graphql'
import {
buildAbstractCollectionMergeFunction,
incomingOverwritesExistingMergeFunction
} from '@/main/lib/core/helpers/apolloSetupHelper'
import { merge } from 'lodash'
import { statePolicies as commitObjectViewerStatePolicies } from '@/main/lib/viewer/commit-object-viewer/stateManagerCore'
import { Optional } from '@speckle/shared'
import { onError } from '@apollo/client/link/error'
import { registerError, isErrorState } from '@/main/lib/core/utils/appErrorStateManager'
import { isInvalidAuth } from '@/helpers/errorHelper'
import { signOut } from '@/plugins/authHelpers'
// Name of the localStorage item
const AUTH_TOKEN = LocalStorageKeys.AuthToken
// Http endpoint
const httpEndpoint = `${window.location.origin}/graphql`
// WS endpoint
const wsEndpoint = `${window.location.origin.replace('http', 'ws')}/graphql`
// app version
const appVersion = (import.meta.env.SPECKLE_SERVER_VERSION || 'unknown') as string
let instance: Optional<ApolloProvider> = undefined
function hasAuthToken() {
return !!AppLocalStorage.get(AUTH_TOKEN)
}
function createCache(): InMemoryCache {
return new InMemoryCache({
/**
* This is where you configure how various GQL fields should be read, written to or merged when new data comes in.
* If you define a merge function here, you don't need to duplicate the merge logic inside an `update()` callback
* of a fetchMore call, for example.
*
* Feel free to re-use utilities in `apolloSetupHelper` for defining merge functions or even use the ones that come from `@apollo/client/utilities`.
*
* Read more: https://www.apollographql.com/docs/react/caching/cache-field-behavior
*/
typePolicies: merge<TypePolicies, TypePolicies>(
{
Query: {
fields: {
otherUser: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'LimitedUser', id: args.id })
}
return original
}
},
user: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'User', id: args.id })
}
return original
}
},
stream: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Stream', id: args.id })
}
return original
}
},
streams: {
keyArgs: ['query'],
merge: buildAbstractCollectionMergeFunction('StreamCollection', {
checkIdentity: true
})
}
}
},
LimitedUser: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
}
}
},
User: {
fields: {
timeline: {
keyArgs: ['after', 'before'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
},
favoriteStreams: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('StreamCollection', {
checkIdentity: true
})
}
}
},
Stream: {
fields: {
activity: {
keyArgs: ['after', 'before', 'actionType'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
},
pendingCollaborators: {
merge: incomingOverwritesExistingMergeFunction
},
pendingAccessRequests: {
merge: incomingOverwritesExistingMergeFunction
}
}
},
Branch: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
}
}
},
BranchCollection: {
merge: true
},
ServerStats: {
merge: true
},
WebhookEventCollection: {
merge: true
},
ServerInfo: {
merge: true
},
CommentThreadActivityMessage: {
merge: true
}
},
commitObjectViewerStatePolicies
)
})
}
function createWsClient(): SubscriptionClient {
return new SubscriptionClient(wsEndpoint, {
reconnect: true,
connectionParams: () => {
const authToken = AppLocalStorage.get(AUTH_TOKEN)
const Authorization = authToken ? `Bearer ${authToken}` : null
return Authorization ? { Authorization, headers: { Authorization } } : {}
}
})
}
function createLink(wsClient?: SubscriptionClient): ApolloLink {
// Prepare links
const httpLink = createUploadLink({
uri: httpEndpoint
})
const authLink = setContext(async (_, { headers }) => {
const authToken = AppLocalStorage.get(AUTH_TOKEN)
const authHeader = authToken ? { Authorization: `Bearer ${authToken}` } : {}
return {
headers: {
...headers,
...authHeader
}
}
})
let link = authLink.concat(httpLink)
// WS link
if (wsClient) {
const wsLink = new WebSocketLink(wsClient)
link = split(
({ query }) => {
const definition = getMainDefinition(query) as OperationDefinitionNode
const { kind, operation } = definition
return kind === Kind.OPERATION_DEFINITION && operation === 'subscription'
},
wsLink,
link
)
// Stopping WS when in error state
wsClient.use([
{
applyMiddleware: (_opt, next) => {
if (isErrorState()) {
return // never invokes next() - essentially stuck
}
next()
}
}
])
}
// Global error handling
const errorLink = onError((res) => {
const { networkError } = res
if (networkError && isInvalidAuth(networkError)) {
// Logout
void signOut()
}
registerError()
})
return from([errorLink, link])
}
function createApolloClient() {
const cache = createCache()
const wsClient = createWsClient()
const link = createLink(wsClient)
const apolloClient = new ApolloClient({
link,
cache,
ssrForceFetchDelay: 100,
connectToDevTools: import.meta.env.DEV,
name: 'web',
version: appVersion
})
return {
apolloClient,
wsClient
}
}
/**
* Create and set a global Vue Apollo provider instance
*/
export function createProvider(): ApolloProvider {
// Create apollo client
const { apolloClient, wsClient } = createApolloClient()
apolloClient.wsClient = hasAuthToken() ? wsClient : null
// Create vue apollo provider
const apolloProvider = createApolloProvider({
defaultClient: apolloClient
})
instance = apolloProvider
return apolloProvider
}
export function getApolloProvider(): ApolloProvider {
if (!instance) {
throw new Error('Attempting to use unitialized global Apollo Provider')
}
return instance
}
export function installVueApollo(apolloProvider: ApolloProvider): void {
// Install apollo provider (it's done weirdly cause it's meant to be used with vue 3)
Vue.config.globalProperties ||= {}
Vue.prototype.$apolloProvider = apolloProvider
apolloProvider.install(Vue)
}
@@ -1,32 +0,0 @@
import { basicStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests'
import { gql } from '@apollo/client/core'
export const getStreamAccessRequestQuery = gql`
query GetStreamAccessRequest($streamId: String!) {
streamAccessRequest(streamId: $streamId) {
...BasicStreamAccessRequestFields
}
}
${basicStreamAccessRequestFieldsFragment}
`
export const createStreamAccessRequestMutation = gql`
mutation CreateStreamAccessRequest($streamId: String!) {
streamAccessRequestCreate(streamId: $streamId) {
...BasicStreamAccessRequestFields
}
}
${basicStreamAccessRequestFieldsFragment}
`
export const useStreamAccessRequestMutation = gql`
mutation UseStreamAccessRequest(
$requestId: String!
$accept: Boolean!
$role: StreamRole = STREAM_CONTRIBUTOR
) {
streamAccessRequestUse(requestId: $requestId, accept: $accept, role: $role)
}
`
-27
View File
@@ -1,27 +0,0 @@
query StreamWithBranch($streamId: String!, $branchName: String!, $cursor: String) {
stream(id: $streamId) {
id
name
role
branch(name: $branchName) {
id
name
description
commits(cursor: $cursor, limit: 4) {
totalCount
cursor
items {
id
authorName
authorId
authorAvatar
sourceApplication
message
referencedObject
createdAt
commentCount
}
}
}
}
}
-32
View File
@@ -1,32 +0,0 @@
import { gql } from '@apollo/client/core'
export const branchCreatedSubscription = gql`
subscription BranchCreated($streamId: String!) {
branchCreated(streamId: $streamId)
}
`
// TODO: Reusable composable
export const streamNavBranchesQuery = gql`
query StreamAllBranches($streamId: String!, $cursor: String) {
stream(id: $streamId) {
id
branches(limit: 500, cursor: $cursor) {
totalCount
cursor
items {
id
name
description
author {
id
name
}
commits {
totalCount
}
createdAt
}
}
}
}
`
-31
View File
@@ -1,31 +0,0 @@
import { gql } from '@apollo/client/core'
export const COMMENT_FULL_INFO_FRAGMENT = gql`
fragment CommentFullInfo on Comment {
id
archived
authorId
text {
doc
attachments {
id
fileName
streamId
fileType
fileSize
}
}
data
screenshot
replies {
totalCount
}
resources {
resourceId
resourceType
}
createdAt
updatedAt
viewedAt
}
`
-18
View File
@@ -1,18 +0,0 @@
query StreamCommitQuery($streamId: String!, $id: String!) {
stream(id: $streamId) {
id
name
role
commit(id: $id) {
id
message
referencedObject
authorName
authorId
authorAvatar
createdAt
branchName
sourceApplication
}
}
}
-26
View File
@@ -1,26 +0,0 @@
import { gql } from '@apollo/client/core'
export const streamBranchesSelectorQuery = gql`
query StreamBranchesSelector($streamId: String!) {
stream(id: $streamId) {
id
branches(limit: 100) {
items {
name
}
}
}
}
`
export const moveCommitsMutation = gql`
mutation MoveCommits($input: CommitsMoveInput!) {
commitsMove(input: $input)
}
`
export const deleteCommitsMutation = gql`
mutation DeleteCommits($input: CommitsDeleteInput!) {
commitsDelete(input: $input)
}
`
@@ -1,22 +0,0 @@
import { limitedUserFieldsFragment } from '@/graphql/fragments/user'
import { gql } from '@apollo/client/core'
export const basicStreamAccessRequestFieldsFragment = gql`
fragment BasicStreamAccessRequestFields on StreamAccessRequest {
id
streamId
createdAt
}
`
export const fullStreamAccessRequestFieldsFragment = gql`
fragment FullStreamAccessRequestFields on StreamAccessRequest {
...BasicStreamAccessRequestFields
requester {
...LimitedUserFields
}
}
${limitedUserFieldsFragment}
${basicStreamAccessRequestFieldsFragment}
`
@@ -1,25 +0,0 @@
import { gql } from '@apollo/client/core'
export const activityMainFieldsFragment = gql`
fragment ActivityMainFields on Activity {
id
actionType
info
userId
streamId
resourceId
resourceType
time
message
}
`
export const limitedCommitActivityFieldsFragment = gql`
fragment LimitedCommitActivityFields on Activity {
id
info
time
userId
message
}
`
@@ -1,27 +0,0 @@
import { fullStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests'
import { gql } from '@apollo/client/core'
export const streamPendingAccessRequestsFragment = gql`
fragment StreamPendingAccessRequests on Stream {
pendingAccessRequests {
...FullStreamAccessRequestFields
}
}
${fullStreamAccessRequestFieldsFragment}
`
export const streamFileUploadFragment = gql`
fragment StreamFileUpload on FileUpload {
id
convertedCommitId
userId
convertedStatus
convertedMessage
fileName
fileType
uploadComplete
uploadDate
convertedLastUpdate
}
`
@@ -1,38 +0,0 @@
import { gql } from '@apollo/client/core'
export const limitedUserFieldsFragment = gql`
fragment LimitedUserFields on LimitedUser {
id
name
bio
company
avatar
verified
}
`
export const streamCollaboratorFieldsFragment = gql`
fragment StreamCollaboratorFields on StreamCollaborator {
id
name
role
company
avatar
serverRole
}
`
export const usersOwnInviteFieldsFragment = gql`
fragment UsersOwnInviteFields on PendingStreamCollaborator {
id
inviteId
streamId
streamName
token
invitedBy {
...LimitedUserFields
}
}
${limitedUserFieldsFragment}
`
File diff suppressed because it is too large Load Diff
-58
View File
@@ -1,58 +0,0 @@
import { gql } from '@apollo/client/core'
import { usersOwnInviteFieldsFragment } from '@/graphql/fragments/user'
export const streamInviteQuery = gql`
query StreamInvite($streamId: String!, $token: String) {
streamInvite(streamId: $streamId, token: $token) {
...UsersOwnInviteFields
}
}
${usersOwnInviteFieldsFragment}
`
export const userStreamInvitesQuery = gql`
query UserStreamInvites {
streamInvites {
...UsersOwnInviteFields
}
}
${usersOwnInviteFieldsFragment}
`
export const useStreamInviteMutation = gql`
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $token: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, token: $token)
}
`
export const cancelStreamInviteMutation = gql`
mutation CancelStreamInvite($streamId: String!, $inviteId: String!) {
streamInviteCancel(streamId: $streamId, inviteId: $inviteId)
}
`
export const deleteInviteMutation = gql`
mutation DeleteInvite($inviteId: String!) {
inviteDelete(inviteId: $inviteId)
}
`
export const resendInviteMutation = gql`
mutation ResendInvite($inviteId: String!) {
inviteResend(inviteId: $inviteId)
}
`
export const batchInviteToServerMutation = gql`
mutation BatchInviteToServer($paramsArray: [ServerInviteCreateInput!]!) {
serverInviteBatchCreate(input: $paramsArray)
}
`
export const batchInviteToStreamsMutation = gql`
mutation BatchInviteToStreams($paramsArray: [StreamInviteCreateInput!]!) {
streamInviteBatchCreate(input: $paramsArray)
}
`
@@ -1,25 +0,0 @@
extend type Query {
"""
Commit/Object viewer state (local-only)
"""
commitObjectViewerState: CommitObjectViewerState!
}
type CommitObjectViewerState {
viewerBusy: Boolean!
selectedCommentMetaData: SelectedCommentMetaData
addingComment: Boolean!
preventCommentCollapse: Boolean!
commentReactions: [String!]!
emojis: [String!]!
currentFilterState: JSONObject
selectedObjects: [JSONObject]
objectProperties: [JSONObject]
localFilterPropKey: String
sectionBox: Boolean
}
type SelectedCommentMetaData {
id: String!
selectionLocation: JSONObject!
}
@@ -1,11 +0,0 @@
query StreamObject($streamId: String!, $id: String!) {
stream(id: $streamId) {
id
object(id: $id) {
totalChildrenCount
id
speckleType
data
}
}
}
@@ -1,11 +0,0 @@
query StreamObjectNoData($streamId: String!, $id: String!) {
stream(id: $streamId) {
id
name
object(id: $id) {
totalChildrenCount
id
speckleType
}
}
}
-95
View File
@@ -1,95 +0,0 @@
import { gql } from '@apollo/client/core'
export const serverInfoBlobSizeFragment = gql`
fragment ServerInfoBlobSizeFields on ServerInfo {
configuration {
blobSizeLimitBytes
}
}
`
export const mainServerInfoFieldsFragment = gql`
fragment MainServerInfoFields on ServerInfo {
name
company
description
adminContact
canonicalUrl
termsOfService
inviteOnly
version
guestModeEnabled
enableNewWebUiMessaging
migration {
movedTo
}
}
`
export const serverInfoRolesFieldsFragment = gql`
fragment ServerInfoRolesFields on ServerInfo {
serverRoles {
id
title
}
}
`
export const serverInfoScopesFieldsFragment = gql`
fragment ServerInfoScopesFields on ServerInfo {
scopes {
name
description
}
}
`
/**
* Get main server info
*/
export const mainServerInfoQuery = gql`
query MainServerInfo {
serverInfo {
...MainServerInfoFields
}
}
${mainServerInfoFieldsFragment}
`
export const fullServerInfoQuery = gql`
query FullServerInfo {
serverInfo {
...MainServerInfoFields
...ServerInfoRolesFields
...ServerInfoScopesFields
...ServerInfoBlobSizeFields
}
}
${mainServerInfoFieldsFragment}
${serverInfoRolesFieldsFragment}
${serverInfoScopesFieldsFragment}
${serverInfoBlobSizeFragment}
`
export const serverInfoBlobSizeLimitQuery = gql`
query ServerInfoBlobSizeLimit {
serverInfo {
...ServerInfoBlobSizeFields
}
}
${serverInfoBlobSizeFragment}
`
export const availableServerRolesQuery = gql`
query AvailableServerRoles {
serverInfo {
serverRoles {
id
title
}
guestModeEnabled
}
}
`
@@ -1,20 +0,0 @@
query StreamCommits($id: String!) {
stream(id: $id) {
id
role
commits {
totalCount
items {
id
authorId
authorName
authorAvatar
createdAt
message
referencedObject
branchName
sourceApplication
}
}
}
}
-41
View File
@@ -1,41 +0,0 @@
query Streams($cursor: String) {
streams(cursor: $cursor, limit: 10) {
totalCount
cursor
items {
id
name
description
role
isPublic
createdAt
updatedAt
commentCount
collaborators {
id
name
company
avatar
role
}
commits(limit: 1) {
totalCount
items {
id
createdAt
message
authorId
branchName
authorName
authorAvatar
referencedObject
}
}
branches {
totalCount
}
favoritedDate
favoritesCount
}
}
}
-231
View File
@@ -1,231 +0,0 @@
import { fullStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests'
import { activityMainFieldsFragment } from '@/graphql/fragments/activity'
import {
limitedUserFieldsFragment,
streamCollaboratorFieldsFragment
} from '@/graphql/fragments/user'
import { gql } from '@apollo/client/core'
import { streamFileUploadFragment } from '@/graphql/fragments/streams'
/**
* Common stream fields when querying for streams
*/
export const commonStreamFieldsFragment = gql`
fragment CommonStreamFields on Stream {
id
name
description
role
isPublic
createdAt
updatedAt
commentCount
collaborators {
...StreamCollaboratorFields
}
commits(limit: 1) {
totalCount
}
branches {
totalCount
}
favoritedDate
favoritesCount
}
${streamCollaboratorFieldsFragment}
`
/**
* Retrieve a single stream
*/
export const streamQuery = gql`
query Stream($id: String!) {
stream(id: $id) {
...CommonStreamFields
}
}
${commonStreamFieldsFragment}
`
/**
* Retrieve stream collaborators info
*/
export const streamWithCollaboratorsQuery = gql`
query StreamWithCollaborators($id: String!) {
stream(id: $id) {
id
name
isPublic
role
collaborators {
...StreamCollaboratorFields
}
pendingCollaborators {
title
inviteId
role
user {
...LimitedUserFields
}
}
pendingAccessRequests {
...FullStreamAccessRequestFields
}
}
}
${limitedUserFieldsFragment}
${streamCollaboratorFieldsFragment}
${fullStreamAccessRequestFieldsFragment}
`
export const streamWithActivityQuery = gql`
query StreamWithActivity($id: String!, $cursor: DateTime) {
stream(id: $id) {
id
name
createdAt
commits {
totalCount
}
branches {
totalCount
}
activity(cursor: $cursor) {
totalCount
cursor
items {
...ActivityMainFields
}
}
}
}
${activityMainFieldsFragment}
`
/**
* Remove authenticated user from the collaborators list
*/
export const leaveStreamMutation = gql`
mutation LeaveStream($streamId: String!) {
streamLeave(streamId: $streamId)
}
`
/**
* Update a user's stream permission
*/
export const updateStreamPermissionMutation = gql`
mutation UpdateStreamPermission($params: StreamUpdatePermissionInput!) {
streamUpdatePermission(permissionParams: $params)
}
`
/**
* Get a stream's first commit
*/
export const streamFirstCommitQuery = gql`
query StreamFirstCommit($id: String!) {
stream(id: $id) {
id
commits(limit: 1) {
totalCount
items {
id
referencedObject
}
}
}
}
`
/**
* Get a stream branch's first commit
*/
export const streamBranchFirstCommitQuery = gql`
query StreamBranchFirstCommit($id: String!, $branch: String!) {
stream(id: $id) {
id
branch(name: $branch) {
commits(limit: 1) {
totalCount
items {
id
referencedObject
}
}
}
}
}
`
export const streamSettingsQuery = gql`
query StreamSettings($id: String!) {
stream(id: $id) {
id
name
description
isPublic
isDiscoverable
allowPublicComments
role
}
}
`
export const searchStreamsQuery = gql`
query SearchStreams($query: String) {
streams(query: $query) {
totalCount
cursor
items {
id
name
updatedAt
}
}
}
`
export const updateStreamSettingsMutation = gql`
mutation UpdateStreamSettings($input: StreamUpdateInput!) {
streamUpdate(stream: $input)
}
`
export const deleteStreamMutation = gql`
mutation DeleteStream($id: String!) {
streamDelete(id: $id)
}
`
export const shareableStreamQuery = gql`
query ShareableStream($id: String!) {
stream(id: $id) {
id
isPublic
role
collaborators {
...StreamCollaboratorFields
}
}
}
${streamCollaboratorFieldsFragment}
`
export const streamFileUploadsUpdatedSubscription = gql`
subscription StreamFileUploadsUpdated($id: String!) {
projectFileImportUpdated(id: $id) {
type
id
upload {
...StreamFileUpload
}
}
}
${streamFileUploadFragment}
`
-193
View File
@@ -1,193 +0,0 @@
import { activityMainFieldsFragment } from '@/graphql/fragments/activity'
import { limitedUserFieldsFragment } from '@/graphql/fragments/user'
import { commonStreamFieldsFragment } from '@/graphql/streams'
import { gql } from '@apollo/client/core'
export const commonUserFieldsFragment = gql`
fragment CommonUserFields on User {
id
email
name
bio
company
avatar
verified
hasPendingVerification
profiles
role
streams {
totalCount
}
commits(limit: 1) {
totalCount
items {
id
createdAt
}
}
}
`
/**
* User data with favorite streams
*/
export const userFavoriteStreamsQuery = gql`
query UserFavoriteStreams($cursor: String) {
activeUser {
...CommonUserFields
favoriteStreams(cursor: $cursor, limit: 10) {
totalCount
cursor
items {
...CommonStreamFields
}
}
}
}
${commonUserFieldsFragment}
${commonStreamFieldsFragment}
`
/**
* Get main user metadata
*/
export const mainUserDataQuery = gql`
query MainUserData {
activeUser {
...CommonUserFields
}
}
${commonUserFieldsFragment}
`
/**
* Main metadata + extra info shown on profile page
*/
export const profileSelfQuery = gql`
query ProfileSelf {
activeUser {
...CommonUserFields
totalOwnedStreamsFavorites
notificationPreferences
}
}
${commonUserFieldsFragment}
`
/**
* (Limited, not admin) User search
*/
export const userSearchQuery = gql`
query UserSearch($query: String!, $limit: Int!, $cursor: String, $archived: Boolean) {
userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {
cursor
items {
...LimitedUserFields
}
}
}
${limitedUserFieldsFragment}
`
/**
* Basic query for checking if user is logged in
*/
export const isLoggedInQuery = gql`
query IsLoggedIn {
activeUser {
id
}
}
`
/**
* Admin panel (invited/registered) users list
*/
export const adminUsersListQuery = gql`
query AdminUsersList($limit: Int, $offset: Int, $query: String) {
adminUsers(limit: $limit, offset: $offset, query: $query) {
totalCount
items {
id
registeredUser {
id
email
name
bio
company
avatar
verified
profiles
role
authorizedApps {
name
}
}
invitedUser {
id
email
invitedBy {
id
name
}
}
}
}
}
`
export const userTimelineQuery = gql`
query UserTimeline($cursor: DateTime) {
activeUser {
id
timeline(cursor: $cursor) {
totalCount
cursor
items {
...ActivityMainFields
}
}
}
}
${activityMainFieldsFragment}
`
export const validatePasswordStrengthQuery = gql`
query ValidatePasswordStrength($pwd: String!) {
userPwdStrength(pwd: $pwd) {
score
feedback {
warning
suggestions
}
}
}
`
export const emailVerificationBannerStateQuery = gql`
query EmailVerificationBannerState {
activeUser {
id
email
verified
hasPendingVerification
}
}
`
export const requestVerificationMutation = gql`
mutation RequestVerification {
requestVerification
}
`
export const updateUserNotificationPreferencesMutation = gql`
mutation UpdateUserNotificationPreferences($preferences: JSONObject!) {
userNotificationPreferencesUpdate(preferences: $preferences)
}
`
@@ -1,10 +0,0 @@
query UserById($id: String!) {
otherUser(id: $id) {
id
name
bio
company
avatar
verified
}
}
@@ -1,10 +0,0 @@
query UserProfile($id: String!) {
otherUser(id: $id) {
id
name
bio
company
avatar
verified
}
}
-22
View File
@@ -1,22 +0,0 @@
query webhook($streamId: String!, $webhookId: String!) {
stream(id: $streamId) {
id
role
webhooks(id: $webhookId) {
items {
id
streamId
url
description
triggers
enabled
history(limit: 1) {
items {
status
statusInfo
}
}
}
}
}
}
@@ -1,24 +0,0 @@
query webhooks($streamId: String!) {
stream(id: $streamId) {
id
name
role
webhooks {
items {
id
streamId
url
description
triggers
enabled
history(limit: 50) {
items {
status
statusInfo
lastUpdate
}
}
}
}
}
}
@@ -1,46 +0,0 @@
import { ApolloError, ServerError, ServerParseError } from '@apollo/client/core'
import { NetworkError } from '@apollo/client/errors'
import { has, isString } from 'lodash'
/**
* Base application error
*/
export abstract class BaseError extends Error {
/**
* Default message if none is passed
*/
static defaultMessage = 'Unexpected error occurred!'
constructor(message?: string, options?: ErrorOptions) {
message ||= new.target.defaultMessage
super(message, options)
}
}
const isServerError = (err: Error): err is ServerError =>
has(err, 'response') && has(err, 'result') && has(err, 'statusCode')
const isServerParseError = (err: Error): err is ServerParseError =>
has(err, 'response') && has(err, 'bodyText') && has(err, 'statusCode')
export function isInvalidAuth(error: ApolloError | NetworkError) {
const networkError = error instanceof ApolloError ? error.networkError : error
if (
!networkError ||
(!isServerError(networkError) && !isServerParseError(networkError))
)
return false
const statusCode = networkError.statusCode
const hasCorrectCode = [403].includes(statusCode)
if (!hasCorrectCode) return false
const message: string | undefined = (
isServerError(networkError)
? isString(networkError.result)
? networkError.result
: networkError.result?.error
: networkError.bodyText
) as string | undefined
return (message || '').toLowerCase().includes('token')
}
@@ -1,31 +0,0 @@
import { Roles } from '@speckle/shared'
import type { ServerRoles, StreamRoles } from '@speckle/shared'
import { StreamRole } from '@/graphql/generated/graphql'
/**
* Keys for values stored in localStorage
*/
export const LocalStorageKeys = Object.freeze({
AuthToken: 'AuthToken',
RefreshToken: 'RefreshToken',
Uuid: 'uuid',
ShouldRedirectTo: 'shouldRedirectTo'
})
/**
* Our GQL schema has a StreamRoles enum that unfortunately can't have the same exact values as our roles constants, because
* we can't use colons (:) there. So you can use this function to map from our constant value to the GQL one.
*/
export function streamRoleToGraphQLEnum(role: StreamRoles): StreamRole {
switch (role) {
case Roles.Stream.Owner:
return StreamRole.StreamOwner
case Roles.Stream.Reviewer:
return StreamRole.StreamReviewer
case Roles.Stream.Contributor:
default:
return StreamRole.StreamContributor
}
}
export { Roles, ServerRoles, StreamRoles }
-3
View File
@@ -1,3 +0,0 @@
import { md5 } from '@speckle/shared'
export default md5
export { md5 }
@@ -1,10 +0,0 @@
/**
* Generate a random string of any length
* @param {number} length
* @returns
*/
export function randomString(length) {
return Math.round(Math.pow(36, length + 1) - Math.random() * Math.pow(36, length))
.toString(36)
.slice(1)
}
@@ -1,6 +0,0 @@
/**
* Check whether or not a stream can be favorited by the active user
*/
export function canBeFavorited(stream) {
return stream && (stream.isPublic || stream.role)
}
@@ -1,79 +0,0 @@
export { isUndefinedOrVoid } from '@speckle/shared'
export type {
Nullable,
Optional,
MaybeNullOrUndefined,
MaybeAsync,
MaybeFalsy
} from '@speckle/shared'
import { ReactiveVar } from '@apollo/client/core'
import Vue, { VueConstructor } from 'vue'
import { LooseRequired } from 'vue/types/common'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GetReactiveVarType<V extends ReactiveVar<any>> = V extends ReactiveVar<
infer T
>
? T
: unknown
export type SetupProps<P = unknown> = Readonly<LooseRequired<P>>
// Copied from Vue typings & improved ergonomics
export type CombinedVueInstance<
Instance extends Vue = Vue,
Data = unknown,
Methods = unknown,
Computed = unknown,
Props = unknown
> = Data & Methods & Computed & Props & Instance
export type ExtendedVue<
Instance extends Vue = Vue,
Data = unknown,
Methods = unknown,
Computed = unknown,
Props = unknown
> = VueConstructor<CombinedVueInstance<Instance, Data, Methods, Computed, Props> & Vue>
export type VueWithMixins<
A extends VueConstructor = VueConstructor,
B extends VueConstructor = VueConstructor,
C extends VueConstructor = VueConstructor,
D extends VueConstructor = VueConstructor,
E extends VueConstructor = VueConstructor
> = VueConstructor<
Vue &
InstanceType<A> &
InstanceType<B> &
InstanceType<C> &
InstanceType<D> &
InstanceType<E>
>
/**
* Create Vue base class with the specified mixins and correctly returned TypeScript types
* @deprecated Use Composition API instead
* @returns
*/
export function vueWithMixins<
A extends VueConstructor = VueConstructor,
B extends VueConstructor = VueConstructor,
C extends VueConstructor = VueConstructor,
D extends VueConstructor = VueConstructor,
E extends VueConstructor = VueConstructor
>(
mixin1?: A,
mixin2?: B,
mixin3?: C,
mixin4?: D,
mixin5?: E
): VueWithMixins<A, B, C, D, E> {
const mixins = [mixin1, mixin2, mixin3, mixin4, mixin5].filter(
(m): m is A | B | C | D | E => !!m
)
return Vue.extend({
mixins
}) as VueWithMixins<A, B, C, D, E>
}
@@ -1,22 +0,0 @@
import type { CombinedVueInstance } from 'vue/types/vue'
/**
* Use this to type v-form $refs instances
*/
export type VFormInstance = CombinedVueInstance<
Vue,
unknown,
{
/**
* Reset validation state
*/
resetValidation(): void
/**
* Validate the form and return whether it's valid or not
*/
validate(): boolean
},
unknown,
unknown,
unknown
>
-53
View File
@@ -1,53 +0,0 @@
<template>
<div>
<div v-if="isAppErrorState && showBanner" class="app-error-state">
<div class="app-error-state__wrapper">
<div>
Due to a large amount of errors some functionality has been disabled! Please
reload the page or contact the server administrators.
</div>
<div>
<v-btn v-tooltip="'Close banner'" icon @click="hideErrorStateBanner">
<v-icon class="app-error-state__icon">mdi-close-circle</v-icon>
</v-btn>
</div>
</div>
</div>
<router-view></router-view>
</div>
</template>
<script setup lang="ts">
import { isErrorState } from '@/main/lib/core/utils/appErrorStateManager'
import { computed, ref } from 'vue'
const showBanner = ref(true)
const isAppErrorState = computed(() => isErrorState())
const hideErrorStateBanner = () => (showBanner.value = false)
</script>
<style lang="css">
.v-timeline:before {
top: 40px !important;
}
.app-error-state {
position: fixed;
left: 0;
right: 0;
top: 0;
background-color: red;
z-index: 1000;
padding: 8px;
font-family: 'Roboto', sans-serif !important;
color: white;
}
.app-error-state__wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-error-state__icon {
color: white !important;
}
</style>
-104
View File
@@ -1,104 +0,0 @@
/**
* Don't export anything out of this file and import it in other files, this borks Vite HMR for some reason
* (runs app.js twice in the browser)!
*/
import '@/bootstrapper'
import Vue from 'vue'
import App from '@/main/App.vue'
import { LocalStorageKeys } from '@/helpers/mainConstants'
import * as MixpanelManager from '@/mixpanelManager'
import { provide } from 'vue'
import { DefaultApolloClient } from '@vue/apollo-composable'
import { createProvider, installVueApollo } from '@/config/apolloConfig'
import {
checkAccessCodeAndGetTokens,
prefetchUserAndSetID
} from '@/plugins/authHelpers'
import router from '@/main/router/index'
import vuetify from '@/plugins/vuetify'
import VueTimeago from 'vue-timeago'
Vue.use(VueTimeago, { locale: 'en' })
import VueFilterDateParse from '@vuejs-community/vue-filter-date-parse'
Vue.use(VueFilterDateParse)
import VueFilterDateFormat from '@vuejs-community/vue-filter-date-format'
Vue.use(VueFilterDateFormat)
// adds various helper methods
import '@/plugins/helpers'
import { AppLocalStorage } from '@/utils/localStorage'
import { InvalidAuthTokenError } from '@/main/lib/auth/errors'
// Async ApexChart load
Vue.component('ApexChart', async () => {
const VueApexCharts = await import('vue-apexcharts')
Vue.use(VueApexCharts)
return VueApexCharts
})
// Filter to capitalize words
Vue.filter('capitalize', (value) => {
if (!value) return ''
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
})
const apolloProvider = createProvider()
installVueApollo(apolloProvider)
function postAuthInit() {
// Init mixpanel
MixpanelManager.initialize({
hostApp: 'web',
hostAppDisplayName: 'Web App'
})
new Vue({
router,
vuetify,
setup() {
provide(DefaultApolloClient, apolloProvider.defaultClient)
},
render: (h) => h(App)
}).$mount('#app')
}
async function init() {
const authToken = AppLocalStorage.get(LocalStorageKeys.AuthToken)
// no auth token - check if we can resolve it from access code
if (!authToken) {
const gotToken = await checkAccessCodeAndGetTokens()
if (gotToken) {
// Remove access_code get param from current url
const url = new URL(window.location.href)
url.searchParams.delete('access_code')
window.history.replaceState({}, document.title, url.toString())
}
}
// try to retrieve user info with auth token
try {
await prefetchUserAndSetID(apolloProvider.defaultClient)
} catch (e) {
if (e instanceof InvalidAuthTokenError) {
// data retrieval failed and user was logged out - go to login page
window.location = `${window.location.origin}/authn/login`
return
}
// Log and continue
console.error(e)
}
// Init app
postAuthInit()
}
init()
@@ -1,596 +0,0 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<v-timeline-item v-show="shouldShowComponent" medium>
<template #icon>
<user-avatar v-if="user" :id="user.id" :avatar="user.avatar" :name="user.name" />
</template>
<v-row class="pt-1 timeline-activity">
<v-col cols="12" class="mb-0 pb-0">
<div v-if="user && you && stream" class="body-2">
&nbsp;
<router-link :to="'/profile/' + user.id">
{{ userName }}
</router-link>
<span>&nbsp;{{ lastActivityBrief.captionText }} &nbsp;</span>
<span v-if="stream">
<router-link :to="'/streams/' + stream.id">{{ stream.name }}</router-link>
</span>
<timeago :datetime="lastActivity.time" class="font-italic ma-1"></timeago>
</div>
</v-col>
<v-col cols="12">
<!-- STREAM PERMISSIONS -->
<v-card
v-if="lastActivity.actionType.includes('stream_permissions') && stream"
class="activity-card"
:flat="$vuetify.theme.dark"
>
<v-card-text class="pa-5 body-1">
<v-container>
<v-row
v-for="activityItem in activityGroup"
:key="activityItem.time"
class="align-center"
>
<v-col cols="12" md="10">
<user-pill
class="mr-3"
:user-id="activityItem.info.targetUser || activityItem.userId"
:color="isUserAddedToStreamActivity ? 'success' : 'error'"
></user-pill>
<span
v-if="$vuetify.breakpoint.smAndUp"
class="mr-3 body-2 font-italic"
>
{{ isUserAddedToStreamActivity ? 'user added as' : 'user removed' }}
</span>
<v-chip v-if="activityItem.info.role" small outlined class="my-2">
<v-icon small left>mdi-account-key-outline</v-icon>
{{ activityItem.info.role.split(':')[1] }}
</v-chip>
</v-col>
<v-col v-if="$vuetify.breakpoint.mdAndUp" cols="2" class="text-right">
<v-btn
v-if="
(activityItem.info.targetUser || activityItem.userId) &&
isUserAddedToStreamActivity
"
text
outlined
small
:to="
'/profile/' +
(activityItem.info.targetUser || activityItem.userId)
"
color="primary"
>
view
</v-btn>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
<!-- STREAM -->
<v-card
v-else-if="lastActivity.resourceType === 'stream' && stream"
class="activity-card"
:flat="$vuetify.theme.dark"
>
<v-card-text class="pa-5 body-1">
<v-container>
<v-row class="align-center">
<router-link :to="url" class="title">
<v-icon color="primary" small>mdi-folder</v-icon>
{{ stream.name }}
</router-link>
<span class="ml-3 body-2 font-italic">
{{ lastActivityBrief.actionText }}
</span>
<v-spacer />
<v-btn
v-if="
(STREAM_CREATED_TYPES.includes(lastActivity.actionType) ||
lastActivity.actionType === `stream_update`) &&
$vuetify.breakpoint.mdAndUp
"
text
outlined
small
exact
:to="url"
color="primary"
>
view
</v-btn>
</v-row>
</v-container>
<div class="mt-3">
<list-item-activity-description
v-for="(item, idx) in activityGroup"
:key="item.time"
:activity-group="activityGroup"
:activity-item-index="idx"
/>
</div>
</v-card-text>
<v-card-actions class="pt-0">
<div>
<v-btn
v-tooltip="
stream.branches.totalCount +
' branch' +
(stream.branches.totalCount === 1 ? '' : 'es')
"
color="primary"
text
class="px-0 ml-3"
small
:to="'/streams/' + stream.id + '/branches'"
>
<v-icon small class="mr-2 float-left">mdi-source-branch</v-icon>
{{ stream.branches.totalCount }}
</v-btn>
<v-btn
v-tooltip="
stream.commits.totalCount +
' commit' +
(stream.commits.totalCount === 1 ? '' : 's')
"
color="primary"
text
class="px-0 ml-3"
small
:to="'/streams/' + stream.id + '/branches/main'"
>
<v-icon small class="mr-2 float-left">mdi-source-commit</v-icon>
{{ stream.commits.totalCount }}
</v-btn>
<v-chip v-if="stream.role" small outlined class="ml-3 no-hover">
<v-icon small left>mdi-account-key-outline</v-icon>
{{ stream.role.split(':')[1] }}
</v-chip>
<span class="caption mb-2 ml-3 font-italic">
Updated
<timeago :datetime="stream.updatedAt"></timeago>
</span>
</div>
</v-card-actions>
</v-card>
<!-- BRANCHES -->
<v-card
v-else-if="lastActivity.resourceType === 'branch'"
class="activity-card"
:flat="$vuetify.theme.dark"
>
<v-card-text class="pa-5 body-1">
<v-chip :to="url" :color="lastActivityBrief.color">
<v-icon small class="mr-2 float-left" light>
{{ lastActivityBrief.icon }}
</v-icon>
{{ branchName }}
</v-chip>
<span class="ml-3 body-2 font-italic">
{{ lastActivityBrief.actionText }}
</span>
<div class="mt-3">
<list-item-activity-description
v-for="(item, idx) in activityGroup"
:key="item.time"
:activity-group="activityGroup"
:activity-item-index="idx"
/>
</div>
</v-card-text>
</v-card>
<!-- COMMITS -->
<v-card
v-else-if="lastActivity.resourceType === 'commit' && commit"
class="activity-card"
:flat="$vuetify.theme.dark"
>
<v-container>
<v-row class="align-center">
<v-col sm="10" cols="12">
<v-card-text class="pa-5">
<div>
<v-chip :to="url" :color="lastActivityBrief.color">
<v-icon small class="mr-2 float-left" light>
{{ lastActivityBrief.icon }}
</v-icon>
{{ lastActivity.resourceId }}
</v-chip>
<span class="mx-3 body-2 font-italic">
{{ lastActivityBrief.actionText }}
</span>
<span v-if="lastActivity.actionType !== 'commit_delete' && commit">
<v-chip
:to="`/streams/${
lastActivity.streamId
}/branches/${formatBranchNameForURL(commit.branchName)}`"
small
color="primary"
>
<v-icon v-if="commit" small class="float-left" light>
mdi-source-branch
</v-icon>
{{ commit.branchName }}
</v-chip>
<span v-if="lastActivity.actionType === 'commit_create'">
<span class="mx-3 body-2 font-italic">from</span>
<source-app-avatar
:application-name="commit.sourceApplication"
/>
</span>
<span v-if="lastActivity.actionType === 'commit_receive'">
<span class="mx-3 body-2 font-italic">in</span>
<source-app-avatar
:application-name="lastActivity.info.sourceApplication"
/>
</span>
</span>
<span v-if="lastActivity.actionType !== 'commit_delete' && !commit">
[commit deleted]
</span>
</div>
<div
v-if="lastActivity.info.commit && lastActivity.info.commit.message"
class="mt-3 body-1"
>
{{ lastActivity.info.commit.message }}
</div>
<!-- NOTE: currently assumes all commits are on the same branch
can't easily group them by branch as that info is not in the activity stream -->
<router-link
v-if="activityGroup.length > 1"
:to="`/streams/${
lastActivity.streamId
}/branches/${formatBranchNameForURL(commit.branchName)}`"
class="mt-5 caption"
>
SEE ALL {{ activityGroup.length }} COMMITS
</router-link>
</v-card-text>
</v-col>
<v-col sm="2" cols="12">
<v-hover
v-if="lastActivity.actionType !== 'commit_delete' && commit"
v-slot="{ hover }"
>
<router-link :to="url">
<preview-image
:url="`/preview/${lastActivity.streamId}/commits/${lastActivity.resourceId}`"
:height="100"
:color="hover"
/>
</router-link>
</v-hover>
</v-col>
</v-row>
</v-container>
</v-card>
</v-col>
</v-row>
</v-timeline-item>
</template>
<script>
import UserAvatar from '@/main/components/common/UserAvatar'
import UserPill from '@/main/components/activity/UserPill'
import SourceAppAvatar from '@/main/components/common/SourceAppAvatar'
import PreviewImage from '@/main/components/common/PreviewImage'
import { gql } from '@apollo/client/core'
import ListItemActivityDescription from '@/main/components/activity/ListItemActivityDescription.vue'
import { STREAM_CREATED_TYPES } from '@/main/lib/feed/helpers/activityStream'
import { formatBranchNameForURL } from '@/main/lib/stream/helpers/branches'
export default {
components: {
UserAvatar,
SourceAppAvatar,
PreviewImage,
UserPill,
ListItemActivityDescription
},
props: {
activityGroup: {
type: Array,
default: () => []
}
},
setup: () => ({ STREAM_CREATED_TYPES, formatBranchNameForURL }),
apollo: {
you: {
query: gql`
query {
activeUser {
id
name
}
}
`,
update: (data) => data.activeUser
},
user: {
query: gql`
query ($id: String!) {
otherUser(id: $id) {
name
avatar
id
}
}
`,
update: (data) => data.otherUser,
variables() {
return {
id: this.lastActivity.userId
}
}
},
stream: {
query: gql`
query ($id: String!) {
stream(id: $id) {
id
name
updatedAt
role
branches {
totalCount
}
commits {
totalCount
}
}
}
`,
variables() {
return {
id: this.lastActivity.streamId
}
}
},
branch: {
query: gql`
query ($id: String!, $branchName: String!) {
stream(id: $id) {
id
branch(name: $branchName) {
id
}
}
}
`,
variables() {
return {
id: this.lastActivity.streamId,
branchName: this.branchName
}
},
skip() {
return this.lastActivity.resourceType !== 'branch'
},
update: (data) => data.stream.branch
},
commit: {
query: gql`
query ($id: String!, $commitId: String!) {
stream(id: $id) {
id
commit(id: $commitId) {
branchName
sourceApplication
id
}
}
}
`,
variables() {
return {
id: this.lastActivity.streamId,
commitId: this.lastActivity.resourceId
}
},
skip() {
return this.lastActivity.resourceType !== 'commit'
},
update: (data) => data.stream.commit
}
},
computed: {
shouldShowComponent() {
if (this.lastActivity.actionType.includes('comment_')) return false
if (this.lastActivity.actionType.includes('stream_permissions') && !this.user)
return false
return true
},
isUserAddedToStreamActivity() {
const actionTypes = [
'stream_permissions_add',
'stream_permissions_invite_accepted'
]
return actionTypes.includes(this.lastActivity?.actionType)
},
lastActivity() {
return this.activityGroup[0]
},
userName() {
return this.user.id === this.you.id ? 'You' : this.user.name
},
captionText() {
return this.lastActivity.actionType.split('_').pop()
},
branchName() {
if (this.lastActivity.info?.branch) return this.lastActivity.info.branch.name
else if (this.lastActivity.info?.new?.name) return this.lastActivity.info.new.name
else if (this.lastActivity.info?.old?.name) return this.lastActivity.info.old.name
return ''
},
url() {
switch (this.lastActivity.resourceType) {
case 'stream':
return this.stream ? `/streams/${this.lastActivity.streamId}` : null
case 'branch':
return this.branch
? `/streams/${this.lastActivity.streamId}/branches/${formatBranchNameForURL(
this.branchName
)}`
: null
case 'commit':
return this.commit
? `/streams/${this.lastActivity.streamId}/commits/${this.lastActivity.resourceId}`
: null
case 'user':
return '/profile'
default:
return null
}
},
lastActivityBrief() {
switch (this.lastActivity.actionType) {
case 'stream_create':
case 'stream_clone':
return {
captionText: 'created',
actionText: 'new stream'
}
case 'stream_update':
return {
captionText: 'updated',
actionText: 'stream updated'
}
case 'stream_delete': //not used
return {
captionText: 'deleted'
}
case 'stream_permissions_add':
return {
captionText: `added ${
this.activityGroup.length === 1
? 'a user'
: this.activityGroup.length + ' users'
} to`
}
case 'stream_permissions_invite_accepted':
return {
captionText: `accepted an invitation to become a collaborator on`
}
case 'stream_permissions_remove': {
const removedCount = this.activityGroup.length
const removedSelf =
this.lastActivity.userId === this.lastActivity.info?.targetUser
if (removedCount > 1) {
return {
captionText: `removed ${removedCount} users from`
}
}
if (removedSelf) {
return {
captionText: `left the stream`
}
}
return { captionText: 'removed a user from' }
}
case 'branch_create':
return {
icon: 'mdi-source-branch-plus',
captionText: 'created a branch in',
actionText: 'new branch',
color: 'success'
}
case 'branch_delete':
return {
icon: 'mdi-source-branch-minus',
captionText: 'deleted a branch from',
actionText: 'branch deleted',
color: 'error'
}
case 'branch_update':
return {
icon: 'mdi-source-branch-sync',
captionText: 'updated a branch in',
actionText: 'branch updated in',
color: 'primary'
}
case 'commit_create':
return {
icon: 'mdi-timeline-plus-outline',
captionText: `pushed ${this.activityGroup.length} commit${
this.activityGroup.length === 0 ? '' : 's'
} to`,
actionText: 'new commit in',
color: 'success'
}
case 'commit_update':
return {
icon: 'mdi-timeline-text-outline',
captionText: 'updated a commit in',
actionText: 'commit updated in',
color: 'primary'
}
case 'commit_receive':
return {
icon: 'mdi-source-branch-sync',
captionText: 'received',
actionText: 'commit received from',
color: 'primary'
}
case 'commit_delete':
return {
icon: 'mdi-timeline-remove-outline',
captionText: 'deleted a commit from',
color: 'error',
actionText: 'commit deleted'
}
case 'user_create':
return {
icon: 'mdi-account-plus',
captionText: 'created'
}
case 'user_update':
return {
icon: 'mdi-account-convert',
captionText: 'updated'
}
case 'user_delete':
return {
icon: 'mdi-account-remove',
captionText: 'deleted'
}
default:
return {
icon: 'mdi-box',
name: this.lastActivity.actionType
}
}
}
}
}
</script>
<style>
.activity-card p {
margin: 0 !important;
}
.timeline-activity a {
text-decoration: none;
}
</style>
@@ -1,133 +0,0 @@
<template>
<div>
<template v-if="representsCreation">
{{ streamDescription }}
</template>
<template v-else-if="activityItem && activityItem.info && activityItem.info.new">
<template v-for="(val, key) in activityItem.info.new">
<p v-if="isUpdatedInfoKeyChanged(key)" :key="key">
<template v-if="key === UpdatedInfoKeys.Name">
Renamed from
<i>
<del>
{{ activityItem.info.old[key] }}
</del>
</i>
to
<i>{{ val }}</i>
</template>
<template v-else-if="key === UpdatedInfoKeys.Description">
📋 Description changed from
<i>
<del>
{{ truncate(activityItem.info.old[key] || 'empty') }}
</del>
</i>
to
<i>
{{ truncate(val) }}
</i>
</template>
<template v-else-if="key === UpdatedInfoKeys.Message">
📋 Message changed from
<i>
<del>
{{ truncate(activityItem.info.old[key] || 'empty') }}
</del>
</i>
to
<i>
{{ truncate(val) }}
</i>
</template>
<template v-else-if="key === UpdatedInfoKeys.IsPublic">
👀 Stream is now
<i>
{{ val ? 'public' : 'private' }}
</i>
</template>
<template v-else-if="key === UpdatedInfoKeys.IsDiscoverable">
📺 Stream is now
<i>
{{ val ? 'discoverable' : 'not discoverable' }}
</i>
</template>
</p>
</template>
</template>
</div>
</template>
<script>
const ActionTypes = {
StreamCreate: 'stream_create',
BranchCreate: 'branch_create'
}
const UpdatedInfoKeys = {
Name: 'name',
Description: 'description',
Message: 'message',
IsPublic: 'isPublic',
IsDiscoverable: 'isDiscoverable'
}
export default {
name: 'ListItemActivityDescription',
props: {
activityGroup: {
type: Array,
required: true
},
activityItemIndex: {
type: Number,
required: true
}
},
data: () => ({ UpdatedInfoKeys }),
computed: {
activityItem() {
return this.activityGroup[this.activityItemIndex]
},
lastActivityItem() {
return this.activityGroup[0]
},
actionType() {
return this.activityItem.actionType
},
/**
* Whether the activity item represents a creation (of a stream/branch)
*/
representsCreation() {
return [ActionTypes.StreamCreate, ActionTypes.BranchCreate].includes(
this.activityItem.actionType
)
},
streamDescription() {
if (this.activityItem.actionType === ActionTypes.StreamCreate) {
return this.activityItem.info?.stream?.description
? this.truncate(this.lastActivityItem.info?.stream?.description, 50)
: ''
} else if (this.activityItem.actionType === ActionTypes.BranchCreate) {
return this.activityItem?.info?.branch?.description
? this.truncate(this.lastActivityItem.info?.branch?.description, 50)
: ''
}
return null
}
},
methods: {
truncate(inputText, length = 25) {
return (inputText?.length || 0) > length
? inputText.substring(0, length) + '...'
: inputText
},
isUpdatedInfoKeyChanged(key) {
const oldVal = this.activityItem.info?.old[key]
const newVal = this.activityItem.info?.new[key]
return oldVal !== undefined && newVal !== oldVal
}
}
}
</script>
@@ -1,60 +0,0 @@
<template>
<v-chip pill :color="color">
<template v-if="targetUser">
<v-avatar left>
<user-avatar
:id="targetUser.id"
:avatar="targetUser.avatar"
:size="30"
:name="targetUser.name"
/>
</v-avatar>
{{ targetUser.name }}
</template>
<template v-else>Deleted user</template>
</v-chip>
</template>
<script>
import { gql } from '@apollo/client/core'
import UserAvatar from '@/main/components/common/UserAvatar'
export default {
components: { UserAvatar },
props: {
userId: {
type: String,
default: null
},
color: {
type: String,
default: null
}
},
apollo: {
targetUser: {
query: gql`
query targetUser($id: String!) {
otherUser(id: $id) {
name
avatar
id
}
}
`,
update: (data) => data.otherUser,
variables() {
return {
id: this.userId
}
},
skip() {
return !this.userId
}
}
},
data() {
return {}
}
}
</script>
@@ -1,190 +0,0 @@
<template>
<section-card expandable>
<template #header>Usage Stats</template>
<v-row v-if="!$apollo.loading" dense class="mt-2">
<v-col v-for="value in graphSeries" :key="value.name" cols="12" sm="6">
<p class="text-center caption primary--text">
<v-icon x-small color="primary" class="mr-1">{{ icons[value.name] }}</v-icon>
{{ capitalize(value.name.split('History')[0]) }} history
</p>
<apex-chart
class="primary--text"
type="bar"
:options="options"
:series="[value]"
/>
</v-col>
</v-row>
</section-card>
</template>
<script>
import { gql } from '@apollo/client/core'
import { formatNumber } from '@/plugins/formatNumber.js'
const EXCLUDED_SERVER_STATS_KEYS = ['__typename']
export default {
name: 'ActivityCard',
components: {
SectionCard: () => import('@/main/components/common/SectionCard')
},
data() {
return {
icons: {
commitHistory: 'mdi-cloud-upload-outline',
streamHistory: 'mdi-cloud-outline',
objectHistory: 'mdi-cube-outline',
userHistory: 'mdi-account-outline'
},
options: {
states: {
active: {
filter: {
type: 'none' /* none, lighten, darken */
}
}
},
chart: {
id: 'newUserData',
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: true,
position: 'bottom',
formatter(val) {
return formatNumber(val)
},
offsetY: -25,
style: {
fontSize: '10px',
fontFamily: 'Helvetica, Arial, sans-serif',
fontWeight: 'bold',
colors: undefined
},
background: {
enabled: true,
foreColor: '#fff',
padding: 6,
borderRadius: 5,
borderWidth: 2,
borderColor: undefined,
opacity: 0.9
}
},
tooltip: {
enabled: false
},
xaxis: {
type: 'datetime',
axisBorder: {
show: false
},
labels: {
show: true,
rotate: 0,
rotateAlways: true,
hideOverlappingLabels: true,
showDuplicates: false,
trim: false,
style: {
colors: [],
fontSize: '12px',
fontFamily: 'Helvetica, Arial, sans-serif',
fontWeight: 400,
cssClass: 'apexcharts-xaxis-label text-center'
},
offsetX: 0,
offsetY: 0
}
},
yaxis: {
show: false,
axisTicks: {
show: false
}
},
grid: {
show: false
},
plotOptions: {
bar: {
borderRadius: 10,
columnWidth: '90%',
barHeight: '10%',
dataLabels: {
position: 'top' // top, center, bottom
}
}
}
}
}
},
apollo: {
serverStats: {
query: gql`
query {
serverStats {
commitHistory
objectHistory
userHistory
streamHistory
}
}
`
}
},
computed: {
graphSeries() {
let result = []
const months = this.past12Months()
if (this.serverStats) {
const statsKeys = Object.keys(this.serverStats).filter(
(k) => !EXCLUDED_SERVER_STATS_KEYS.includes(k)
)
result = statsKeys.map((key) => {
const category = this.serverStats[key]
const processed = []
months?.forEach((month) => {
let totalCount = 0
category.forEach((value) => {
const date = new Date(value.created_month)
if (this.isSameMonth(month, date)) {
totalCount = value.count
}
})
processed.push([month, totalCount])
})
return { name: key, data: processed }
})
}
return result.filter((val) => !!val.data)
}
},
methods: {
capitalize(word) {
return word[0].toUpperCase() + word.slice(1).toLowerCase()
},
past12Months() {
const now = new Date(Date.now())
const dates = []
for (let i = 0; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 2)
dates.push(d)
}
return dates
},
isSameMonth(refDate, date) {
return (
refDate.getUTCFullYear() === date.getUTCFullYear() &&
refDate.getUTCMonth() === date.getUTCMonth()
)
}
}
}
</script>
@@ -1,64 +0,0 @@
<template>
<span v-if="pretty">{{ tweeningValue | prettynum(value) }}</span>
<span v-else>{{ tweeningValue }}</span>
</template>
<script>
import TWEEN from 'tween'
export default {
name: 'AnimatedNumber',
props: {
value: {
type: Number,
default: 0
},
duration: {
type: Number,
default: 1000
},
delay: {
type: Number,
default: 300
},
pretty: {
type: Boolean,
default: true
}
},
data() {
return {
tweeningValue: 0
}
},
watch: {
value(oldVal, newVal) {
this.tween(oldVal, newVal)
}
},
mounted() {
setTimeout(() => this.tween(0, this.value), this.delay)
},
methods: {
tween(startValue, endValue) {
const vm = this
function animate() {
if (TWEEN.update()) {
requestAnimationFrame(animate)
}
}
new TWEEN.Tween({ tweeningValue: startValue })
.to({ tweeningValue: endValue }, this.duration)
.easing(TWEEN.Easing.Quintic.Out)
.onUpdate(function () {
vm.tweeningValue = this.tweeningValue.toFixed(0)
})
.start()
animate()
}
}
}
</script>
<style scoped></style>
@@ -1,66 +0,0 @@
<template>
<section-card expandable>
<template #header>General Info</template>
<v-row class="d-flex justify-space-around mt-4">
<v-col
v-for="(value, name) in serverStats"
:key="name"
cols="6"
sm="6"
md="3"
class="flex-grow-1"
>
<h4 class="primary--text text--lighten-2 text-center">Total {{ name }}</h4>
<v-tooltip bottom color="primary" :disabled="value < 1000">
<template #activator="{ on, attrs }">
<p
class="primary--text text-h3 text-md-h2 text-lg-h1 text-center"
v-bind="attrs"
v-on="on"
>
<animated-number :value="value" class="speckle-gradient-txt" />
</p>
</template>
<span>{{ value }}</span>
</v-tooltip>
</v-col>
</v-row>
</section-card>
</template>
<script>
import { gql } from '@apollo/client/core'
export default {
name: 'GeneralInfoCard',
components: {
AnimatedNumber: () => import('@/main/components/admin/AnimatedNumber'),
SectionCard: () => import('@/main/components/common/SectionCard')
},
apollo: {
serverStats: {
query: gql`
query {
serverStats {
totalObjectCount
totalCommitCount
totalStreamCount
totalUserCount
}
}
`,
update(data) {
const stats = data.serverStats
return {
users: stats.totalUserCount,
streams: stats.totalStreamCount,
commits: stats.totalCommitCount,
objects: stats.totalObjectCount
}
}
}
}
}
</script>
<style scoped lang="scss"></style>
@@ -1,66 +0,0 @@
<template>
<v-row class="align-center px-4">
<v-col cols="3" class="text-truncate">
<v-icon v-tooltip="`${stream.isPublic ? 'Public' : 'Private'} stream`" small>
{{ stream.isPublic ? 'mdi-lock-open-variant-outline' : 'mdi-lock-outline' }}
</v-icon>
<router-link
class="text-decoration-none space-grotesk mx-1"
:to="`/streams/${stream.id}`"
target="_blank"
>
{{ stream.name }}
</router-link>
</v-col>
<v-col cols="2" class="caption text-truncate">
Updated
<b><timeago :datetime="stream.updatedAt"></timeago></b>
<br />
<span class="grey--text">
({{ new Date(stream.updatedAt).toLocaleString() }})
</span>
</v-col>
<v-col cols="2" class="caption text-truncate">
Created
<b><timeago :datetime="stream.createdAt"></timeago></b>
<br />
<span class="grey--text">
({{ new Date(stream.createdAt).toLocaleString() }})
</span>
</v-col>
<v-col v-tooltip="'Stream total size'" class="caption font-weight-bold">
{{ `${(stream.size ? stream.size / 1048576 : 0.0).toFixed(2)} MB` }}
</v-col>
<v-col class="caption text-truncate grey--text">
<v-icon small>mdi-source-branch</v-icon>
{{ stream.branches.totalCount }}
<v-icon small>mdi-source-commit</v-icon>
{{ stream.commits.totalCount }}
</v-col>
<v-col class="caption text-truncate">
<collaborators-display :stream="stream" :link-to-collabs="false" />
</v-col>
<v-col cols="1" class="text-right">
<v-btn
v-tooltip="'Delete stream'"
small
icon
color="error"
@click="$emit('delete', stream)"
>
<v-icon small>mdi-delete-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script>
export default {
components: {
// UserAvatar: () => import('@/main/components/common/UserAvatar')
CollaboratorsDisplay: () => import('@/main/components/stream/CollaboratorsDisplay')
},
props: {
stream: { type: Object, default: () => null }
}
}
</script>
@@ -1,45 +0,0 @@
<template>
<v-row class="d-flex align-center px-3">
<v-col cols="6" class="text-truncate">
<v-icon color="primary" class="px-2 mr-3">mdi-email</v-icon>
<span v-tooltip="invite.email">{{ invite.email }}</span>
</v-col>
<v-col cols="3" class="text-truncate caption">
<span class="grey--text">invited by</span>
<user-avatar :id="invite.invitedBy.id" :size="20" class="mx-2" />
<span>{{ invite.invitedBy.name }}</span>
</v-col>
<v-col cols="3" class="d-flex align-center">
<v-btn class="flex-grow-1 mr-2" @click="$emit('resend', { inviteId: invite.id })">
Resend Invite
</v-btn>
<v-btn
v-tooltip="'Delete invite'"
small
icon
color="error"
@click="$emit('delete', { inviteId: invite.id })"
>
<v-icon small>mdi-delete-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script lang="ts">
import { ServerInvite } from '@/graphql/generated/graphql'
import Vue, { PropType } from 'vue'
import UserAvatar from '@/main/components/common/UserAvatar.vue'
export default Vue.extend({
name: 'UsersListInviteItem',
components: {
UserAvatar
},
props: {
invite: {
type: Object as PropType<ServerInvite>,
required: true
}
}
})
</script>
@@ -1,48 +0,0 @@
<template>
<users-list-user-item
v-if="registeredUser"
:user="registeredUser"
:allow-guest="allowGuest"
@change-role="$emit('user-change-role', $event)"
@delete="$emit('user-delete', $event)"
/>
<users-list-invite-item
v-else
:invite="invitedUser"
@delete="$emit('invite-delete', $event)"
@resend="$emit('invite-resend', $event)"
/>
</template>
<script lang="ts">
import { AdminUsersListItem, ServerInvite, User } from '@/graphql/generated/graphql'
import Vue, { PropType } from 'vue'
import { MaybeFalsy } from '@/helpers/typeHelpers'
import UsersListUserItem from '@/main/components/admin/UsersListUserItem.vue'
import UsersListInviteItem from '@/main/components/admin/UsersListInviteItem.vue'
export default Vue.extend({
name: 'UsersListItem',
components: {
UsersListUserItem,
UsersListInviteItem
},
props: {
item: {
type: Object as PropType<AdminUsersListItem>,
required: true,
validator(val: AdminUsersListItem): boolean {
return !!(val.invitedUser || val.registeredUser)
}
},
allowGuest: { type: Boolean }
},
computed: {
registeredUser(): MaybeFalsy<User> {
return this.item.registeredUser
},
invitedUser(): MaybeFalsy<ServerInvite> {
return this.item.invitedUser
}
}
})
</script>
@@ -1,83 +0,0 @@
<template>
<v-row class="align-center px-3">
<v-col cols="3" class="text-truncate">
<user-avatar :id="selfUser.id" :size="30" class="mr-2"></user-avatar>
<router-link
class="text-decoration-none space-grotesk mx-1"
:to="`/profile/${selfUser.id}`"
>
{{ selfUser.name }}
</router-link>
</v-col>
<v-col cols="3" class="caption text-truncate">
<v-icon
v-if="selfUser.verified"
v-tooltip="'Verfied email'"
small
class="mr-2 primary--text"
>
mdi-shield-check
</v-icon>
<v-icon v-else v-tooltip="'Email not verified'" small class="mr-2 warning--text">
mdi-shield-alert
</v-icon>
{{ selfUser.email }}
</v-col>
<v-col
v-tooltip="selfUser.company ? selfUser.company : 'No company info.'"
cols="3"
class="caption text-truncate"
>
<v-icon x-small>mdi-domain</v-icon>
{{ selfUser.company ? selfUser.company : 'No company info.' }}
</v-col>
<v-col cols="3" class="d-flex align-center text-right">
<v-icon small class="mr-2">
{{
selfUser.role === serverRoles.Admin
? 'mdi-key'
: selfUser.role === serverRoles.ArchivedUser
? 'mdi-account-off'
: 'mdi-account'
}}
</v-icon>
<user-role-select
:allow-guest="allowGuest"
:role="selfUser.role"
@update:role="(e) => $emit('change-role', { user, role: e })"
/>
<v-btn
v-tooltip="'Delete user'"
small
icon
color="error"
@click="$emit('delete', selfUser)"
>
<v-icon small>mdi-delete-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script>
import UserRoleSelect from '@/main/components/common/UserRoleSelect.vue'
import { Roles } from '@speckle/shared'
export default {
name: 'UsersListUserItem',
components: {
UserAvatar: () => import('@/main/components/common/UserAvatar'),
UserRoleSelect
},
props: {
user: { type: Object, default: () => null },
allowGuest: { type: Boolean }
},
data() {
return {
selfUser: this.user,
serverRoles: Roles.Server
}
}
}
</script>
@@ -1,96 +0,0 @@
<template>
<section-card expandable>
<template #header>Server Version Info</template>
<template #actions>
<v-spacer />
<v-btn
:color="`${isLatestVersion ? 'success' : 'warning'}`"
dark
href="https://github.com/specklesystems/speckle-server/releases"
target="_blank"
>
<span v-if="isLatestVersion">
<v-icon size="medium" class="mb-1">mdi-check-bold</v-icon>
Up to date
</span>
<span v-else>
<v-icon size="medium" class="mb-1">mdi-alert</v-icon>
Update available
</span>
</v-btn>
</template>
<div class="d-flex justify-space-around pl-4 pr-4 mt-4">
<div>
<h4 class="primary--text text--lighten-2">Current</h4>
<p class="primary--text text-h4 text-sm-h2 speckle-gradient-txt">
{{ versionInfo.current }}
</p>
</div>
<v-icon color="primary lighten-1">mdi-arrow-right</v-icon>
<div>
<h4 class="primary--text text--lighten-2">Latest</h4>
<p class="primary--text text-h4 text-sm-h2 speckle-gradient-txt">
{{ versionInfo.latest }}
</p>
</div>
</div>
</section-card>
</template>
<script>
import { gql } from '@apollo/client/core'
export default {
name: 'VersionInfoCard',
components: { SectionCard: () => import('@/main/components/common/SectionCard') },
data() {
return {
versionInfo: {
current: '2.0.18',
latest: '2.0.27'
}
}
},
apollo: {
currentVersion: {
query: gql`
query {
serverInfo {
version
}
}
`,
update(data) {
this.versionInfo.current = data.serverInfo.version
}
}
},
computed: {
isLatestVersion() {
return this.versionInfo.current === this.versionInfo.latest
}
},
async mounted() {
this.versionInfo.latest = await this.getLatestVersion()
},
methods: {
getLatestVersion() {
return fetch(
'https://api.github.com/repos/specklesystems/speckle-server/releases/latest'
)
.then(async (res) => {
const x = await res.json()
return x.tag_name
})
.catch((err) => {
// console.error('error fetch', err)
this.$eventHub.$emit('notification', {
text: err.message
})
})
}
}
}
</script>
<style scoped></style>
@@ -1,65 +0,0 @@
<template>
<div v-if="strategies && strategies.length !== 0">
<v-card-title class="justify-center py-2 body-1 text--secondary">
<v-divider class="mx-4"></v-divider>
Sign in with
<v-divider class="mx-4"></v-divider>
</v-card-title>
<v-card-text class="pb-5">
<template v-for="s in strategies">
<v-col
:key="s.name"
cols="12"
class="text-center py-1 my-0"
@click="trackSignIn(s.name)"
>
<v-btn
dark
block
:color="s.color"
:href="`${s.url}?appId=${appId}&challenge=${challenge}${
token ? '&token=' + token : ''
}`"
>
<v-icon small class="mr-5">{{ s.icon }}</v-icon>
{{ s.name }}
</v-btn>
</v-col>
</template>
</v-card-text>
</div>
</template>
<script>
import { getInviteTokenFromRoute } from '@/main/lib/auth/services/authService'
export default {
name: 'AuthStrategies',
props: {
strategies: {
type: Array,
default: () => []
},
appId: {
type: String,
default: () => null
},
challenge: {
type: String,
default: () => null
}
},
computed: {
token() {
return getInviteTokenFromRoute(this.$route)
}
},
methods: {
trackSignIn(strategyName) {
this.$mixpanel.track('Log In', {
isInvite: this.token !== null,
type: 'action',
provider: strategyName
})
}
}
}
</script>
@@ -1,96 +0,0 @@
<template>
<v-card class="elevation-0 transparent pa-5 pb-0">
<v-card-text class="text-h3 text-sm-h4 text-md-h3 primary--text">
<span class="primary--text">
<b>
<!-- display: inline -->
<a class="text-decoration-none" href="https://speckle.systems" target="_blank"
>Speckle</a
>
</b>
</span>
<!-- display: inline -->
<span class="font-weight-light"
>, empowering your design and construction data.</span
>
</v-card-text>
<div v-if="!fe2MessagingEnabled">
<v-card-text class="text-h6 font-weight-regular">
Speckle helps leading AEC companies freely exchange data between software silos
and automate design and delivery processes:
<span class="primary--text text--disabled">
join 100s of designers, architects, engineers and developers building the
digital future of AEC.
</span>
</v-card-text>
</div>
<div v-if="fe2MessagingEnabled" class="px-4">
<v-divider></v-divider>
<v-row align="center" justify="center" class="pt-4 pb-5">
<v-col cols="12" class="pb-0">
<div class="d-flex align-center">
<svg
width="22"
height="20"
viewBox="0 0 22 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.40123 2.0034C9.55572 0.00228626 12.4439 0.00228554 13.5983 2.0034L20.9527 14.7509C22.1065 16.7509 20.6631 19.2501 18.3541 19.2501H3.64546C1.33649 19.2501 -0.106939 16.7509 1.04691 14.7509L8.40123 2.0034ZM11 7.25C11.4142 7.25 11.75 7.58579 11.75 8V11.75C11.75 12.1642 11.4142 12.5 11 12.5C10.5858 12.5 10.25 12.1642 10.25 11.75V8C10.25 7.58579 10.5858 7.25 11 7.25ZM11 15.5C11.4142 15.5 11.75 15.1642 11.75 14.75C11.75 14.3358 11.4142 14 11 14C10.5858 14 10.25 14.3358 10.25 14.75C10.25 15.1642 10.5858 15.5 11 15.5Z"
fill="#EAB308"
/>
</svg>
<h3 class="text-h6 font-weight-bold ml-1">This is the Legacy Web App</h3>
</div>
<p class="mb-0 mt-1 primary--text text--disabled mr-2">
A better and more powerful web app is replacing this.
</p>
</v-col>
<v-col cols="12" class="d-flex justify-end">
<v-row align="center" justify="center">
<v-col cols="12" lg="4" class="d-flex justify-end">
<v-btn
href="https://speckle.systems/blog/the-new-way-to-collaborate-in-aec/"
outlined
target="_blank"
block
class="align-self-center outlined ml-4"
>
Learn more
<v-icon right>mdi-open-in-new</v-icon>
</v-btn>
</v-col>
<v-col cols="12" lg="8" class="d-flex justify-end py-0">
<v-btn
:href="migrationMovedTo"
color="primary"
block
class="align-self-center outlined ml-4"
>
Go to the new web app
<v-icon right>mdi-rocket-launch</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-divider></v-divider>
</div>
</v-card>
</template>
<script>
import { useFE2Messaging } from '@/main/lib/core/composables/server'
export default {
name: 'LoginBlurb',
setup() {
return {
...useFE2Messaging()
}
}
}
</script>
@@ -1,60 +0,0 @@
<template>
<div v-if="user" style="display: inline-block" class="text-center">
<user-avatar-icon
:size="size"
:avatar="user.avatar"
:seed="user.id"
></user-avatar-icon>
<p class="text-h6 mt-4">
{{ user.name }}
<br />
<a class="text-body-2" @click="signOut">Not you? Switch accounts.</a>
</p>
</div>
</template>
<script>
import { signOut } from '@/plugins/authHelpers'
import userQuery from '@/graphql/userById.gql'
import UserAvatarIcon from '@/main/components/common/UserAvatarIcon'
import { AppLocalStorage } from '@/utils/localStorage'
export default {
components: { UserAvatarIcon },
props: {
size: {
type: Number,
default: 42
},
id: {
type: String,
default: () => AppLocalStorage.get('uuid')
}
},
computed: {
isSelf() {
return this.id === AppLocalStorage.get('uuid')
},
loggedInUserId() {
return AppLocalStorage.get('uuid')
}
},
apollo: {
user: {
query: userQuery,
variables() {
return {
id: this.id
}
},
update(data) {
return data.otherUser
}
}
},
methods: {
signOut() {
signOut(this.$mixpanel)
}
}
}
</script>
@@ -1,251 +0,0 @@
<template>
<div class="comment-editor">
<file-upload-zone
ref="uploadZone"
v-slot="{ isFileDrag }"
:size-limit="blobSizeLimitBytes"
:count-limit="countLimit"
:accept="acceptValue"
:disabled="disabled"
multiple
@files-selected="onFilesSelected"
>
<smart-text-editor
v-model="doc"
:class="['elevation-5 rounded-xl', isFileDrag ? 'dragging-files' : '']"
:autofocus="autofocus"
min-width
:placeholder="placeholder"
:schema-options="editorSchemaOptions"
:disabled="disabled"
:hide-toolbar="addingComment"
@submit="onSubmit"
/>
</file-upload-zone>
<file-upload-progress
v-if="currentFiles.length"
:items="currentFiles"
class="mt-2"
:disabled="disabled"
@delete="onUploadDelete"
/>
</div>
</template>
<script lang="ts">
import SmartTextEditor from '@/main/components/common/text-editor/SmartTextEditor.vue'
import {
CommentEditorValue,
SMART_EDITOR_SCHEMA
} from '@/main/lib/viewer/comments/commentsHelper'
import Vue, { PropType } from 'vue'
import FileUploadZone from '@/main/components/common/file-upload/FileUploadZone.vue'
import {
FilesSelectedEvent,
FileUploadDeleteEvent,
isUploadProcessed,
UniqueFileTypeSpecifier
} from '@/main/lib/common/file-upload/fileUploadHelper'
import FileUploadProgress from '@/main/components/common/file-upload/FileUploadProgress.vue'
import { UploadFileItem } from '@/main/lib/common/file-upload/fileUploadHelper'
import { differenceBy } from 'lodash'
import { useQuery } from '@vue/apollo-composable'
import { ServerInfoBlobSizeLimitDocument } from '@/graphql/generated/graphql'
import { deleteBlob, uploadFiles } from '@/main/lib/common/file-upload/blobStorageApi'
import { JSONContent } from '@tiptap/core'
import { computed } from 'vue'
type FileUploadZoneInstance = InstanceType<typeof FileUploadZone>
export default Vue.extend({
name: 'CommentEditor',
components: {
SmartTextEditor,
FileUploadZone,
FileUploadProgress
},
props: {
value: {
type: Object as PropType<CommentEditorValue>,
default: null
},
disabled: {
type: Boolean,
default: false
},
addingComment: {
type: Boolean,
default: false
},
streamId: {
type: String,
required: true
},
autofocus: {
type: Boolean,
default: true
}
},
setup() {
const { result } = useQuery(ServerInfoBlobSizeLimitDocument)
const blobSizeLimitBytes = computed(
() => result.value?.serverInfo.configuration.blobSizeLimitBytes
)
return { blobSizeLimitBytes }
},
data() {
return {
editorSchemaOptions: SMART_EDITOR_SCHEMA,
// fileSizeLimit: 1024 * 1024 * 25, // 25MB
countLimit: 5, // if it's more than 5, just zip it up
acceptValue: [
UniqueFileTypeSpecifier.AnyImage,
UniqueFileTypeSpecifier.AnyVideo,
'.pdf',
'.zip',
'.pptx',
'.ifc',
'.dwg',
'.dxf',
'.3dm',
'.ghx',
'.gh',
'.rvt',
'.pla',
'.pln',
'.obj',
'.blend',
'.3ds',
'.max',
'.mtl',
'.stl',
'.md',
'.txt',
'.csv',
'.xlsx',
'.xls',
'.doc',
'.docx',
'.svg',
'.eps',
'.gwb',
'.skp'
].join(',')
}
},
computed: {
realValue: {
get(): CommentEditorValue {
return this.value
},
set(newVal: CommentEditorValue) {
this.$emit('input', newVal)
}
},
doc: {
get(): JSONContent {
return this.value.doc
},
set(newVal: JSONContent) {
this.realValue = {
...this.realValue,
doc: newVal
}
}
},
currentFiles: {
get(): UploadFileItem[] {
return this.value.attachments
},
set(newVal: UploadFileItem[]) {
this.realValue = {
...this.realValue,
attachments: newVal
}
}
},
placeholder(): string {
return 'Press enter to send'
},
anyAttachmentsProcessing(): boolean {
return this.currentFiles.some((a) => !isUploadProcessed(a))
}
},
watch: {
anyAttachmentsProcessing(newVal: boolean, oldVal: boolean) {
if (newVal !== oldVal) {
this.$emit('attachments-processing', newVal)
}
}
},
beforeDestroy() {
// Delete attachments that weren't posted
for (const currentFile of this.currentFiles.slice()) {
if (currentFile.inUse) continue
this.popUpload(currentFile.id)
}
},
methods: {
addAttachments(): void {
;(this.$refs.uploadZone as FileUploadZoneInstance).triggerPicker()
},
onSubmit(e: unknown) {
this.$emit('submit', e)
},
onFilesSelected(e: FilesSelectedEvent) {
const remainingCount = Math.max(0, this.countLimit - this.currentFiles.length)
if (!remainingCount) return
const incomingFiles = e.files
const currentFiles = this.currentFiles
const newFiles = differenceBy(incomingFiles, currentFiles, (f) => f.id)
if (!newFiles.length) return
const limitedFiles = newFiles.slice(0, remainingCount)
const newUploads = Object.values(
uploadFiles(limitedFiles, { streamId: this.streamId }, (uploadedFiles) => {
// Delete files that were uploaded, but already removed from attachments
for (const [id, file] of Object.entries(uploadedFiles)) {
if (
file.result?.blobId &&
this.currentFiles.findIndex((f) => f.id === id) === -1 &&
!file.inUse
) {
this.deleteBlobInBg(file.result?.blobId)
}
}
})
)
this.currentFiles = [...this.currentFiles, ...newUploads]
},
popUpload(fileId: string) {
const fileIdx = this.currentFiles.findIndex((f) => f.id === fileId)
if (fileIdx === -1) return
// Remove from array
const [removedFile] = this.currentFiles.splice(fileIdx, 1) || []
// Delete from blob storage
if (removedFile.result?.blobId) {
this.deleteBlobInBg(removedFile.result.blobId)
}
},
deleteBlobInBg(blobId: string): void {
deleteBlob(blobId, { streamId: this.streamId }).catch(console.error)
},
onUploadDelete(e: FileUploadDeleteEvent) {
const { id } = e
this.popUpload(id)
}
}
})
</script>
<style lang="scss" scoped>
:deep(.smart-text-editor) {
// transparent border, so we don't get a layout shift
border: 2px solid transparent;
&.dragging-files {
border: 2px solid rgb(0, 193, 0);
}
}
</style>
@@ -1,287 +0,0 @@
<template>
<v-card
:class="`rounded-lg overflow-hidden ${hovered ? 'elevation-10' : ''} ${
isUnread ? 'border' : ''
} `"
style="transition: box-shadow 0.3s ease"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<div v-if="commentDetails" class="">
<!-- <v-img :src="commentDetails.screenshot" max-width="150" max-height="150" /> -->
<div class="d-flex align-center flex-grow-1 justify-space-between">
<div class="mx-2">
<user-avatar :id="commentDetails.authorId" :size="40" />
</div>
<div class="text-truncate body-1 mr-auto">
<div class="text-truncate">
<router-link class="text-decoration-none" :to="link">
{{ documentToBasicString(commentDetails.text.doc) }}
</router-link>
</div>
<div class="text-truncate caption">
<!-- <br /> -->
<span v-if="commentDetails.replies.totalCount > 0">
<!-- eslint-disable-next-line prettier/prettier -->
Last reply
<timeago :datetime="commentDetails.updatedAt" />
<!--, on {{ new Date(commentDetails.updatedAt).toLocaleString() }} -->
<br />
</span>
<span class="grey--text">
Created on {{ new Date(commentDetails.createdAt).toLocaleString() }}
</span>
<br />
<v-btn
v-if="canArchiveThread"
class="ml-n2 red--text rounded-lg elevation-0"
x-small
plain
@click="showArchiveDialog = true"
>
Archive
</v-btn>
<v-dialog v-model="showArchiveDialog" max-width="500">
<v-card>
<v-toolbar color="error" dark flat>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-pencil</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Archive Comment Thread</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="showArchiveDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text class="mt-4">
This comment thread will be archived. Are you sure?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="showArchiveDialog = false">Cancel</v-btn>
<v-btn color="error" text @click="archiveComment()">Archive</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn
v-if="isUnread"
class="ml-n2 rounded-lg elevation-0"
x-small
plain
@click="markAsRead"
>
Mark as read
</v-btn>
</div>
</div>
<div class="body-2 px-4 flex-shrink-0">
<span
v-if="commentDetails.data && commentDetails.data.filters"
v-tooltip="`This comment has a filter.`"
class="mr-1"
>
<v-icon small>mdi-filter-variant</v-icon>
</span>
<span
v-if="commentDetails.data && commentDetails.data.sectionBox"
v-tooltip="`This comment has a section box.`"
class="mr-1"
>
<v-icon small>mdi-cube-outline</v-icon>
</span>
<v-btn small class="ml-1 primary dark rounded-xl" :to="link">
<v-icon small>mdi-comment-outline</v-icon>
{{ commentDetails.replies.totalCount }}
reply
</v-btn>
</div>
<div class="flex-shrink-0">
<router-link class="text-decoration-none" :to="link">
<v-img
:src="commentDetails.screenshot"
:width="`${$vuetify.breakpoint.xs ? '100' : '200'}`"
height="140"
:gradient="`to top right, ${
$vuetify.theme.dark
? 'rgba(100,115,201,.33), rgba(25,32,72,.7)'
: 'rgba(100,115,231,.1), rgba(25,32,72,.05)'
}`"
/>
</router-link>
</div>
</div>
</div>
</v-card>
</template>
<script>
import { gql } from '@apollo/client/core'
import { documentToBasicString } from '@/main/lib/common/text-editor/documentHelper'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
// TODO: Stop polling each comment separately
export default {
components: {
UserAvatar: () => import('@/main/components/common/UserAvatar')
},
props: {
comment: { type: Object, default: () => null },
stream: {
type: Object,
default: () => {
return { role: null }
}
},
streamId: {
type: String,
required: true
}
},
apollo: {
commentDetails: {
query: gql`
query ($streamId: String!, $id: String!) {
comment(streamId: $streamId, id: $id) {
...CommentFullInfo
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'no-cache',
variables() {
return {
streamId: this.streamId,
id: this.comment.id
}
},
update(data) {
return data.comment
},
skip() {
return !this.comment
}
},
$subscribe: {
commentThreadActivity: {
query: gql`
subscription ($streamId: String!, $commentId: String!) {
commentThreadActivity(streamId: $streamId, commentId: $commentId) {
type
}
}
`,
variables() {
return {
streamId: this.streamId,
commentId: this.comment.id
}
},
skip() {
return !this.$loggedIn()
},
result({ data }) {
if (!data || !data.commentThreadActivity) return
// Note: This kind of direct apollo result mutation is only allowed, because
// of the 'no-cache' fetch policy, which means that there's no cache mutation actually happening
if (data.commentThreadActivity.type === 'reply-added') {
this.commentDetails.replies.totalCount++
this.commentDetails.updatedAt = Date.now()
return
}
if (data.commentThreadActivity.type === 'comment-archived') {
this.$emit('deleted', this.comment)
}
}
}
}
},
data() {
return {
hovered: false,
showArchiveDialog: false,
documentToBasicString
}
},
computed: {
canArchiveThread() {
if (!this.comment || !this.stream) return false
if (!this.stream.role) return false
if (
this.comment.authorId === this.$userId() ||
this.stream.role === 'stream:owner'
)
return true
return false
},
link() {
if (!this.commentDetails) return
const res = this.commentDetails.resources.filter(
(r) => r.resourceType !== 'stream'
)
const first = res.shift()
let route = `/streams/${this.streamId}/${first.resourceType}s/${first.resourceId}?cId=${this.commentDetails.id}`
if (res.length !== 0) {
route += `&overlay=${res.map((r) => r.resourceId).join(',')}`
}
return route
},
isUnread() {
if (!this.commentDetails) return
return (
new Date(this.commentDetails.updatedAt) -
new Date(this.commentDetails.viewedAt) >
0
)
}
},
methods: {
async markAsRead() {
this.commentDetails.viewedAt = Date.now()
await this.$apollo.mutate({
mutation: gql`
mutation commentView($streamId: String!, $commentId: String!) {
commentView(streamId: $streamId, commentId: $commentId)
}
`,
variables: {
streamId: this.streamId,
commentId: this.comment.id
}
})
},
async archiveComment() {
try {
await this.$apollo.mutate({
mutation: gql`
mutation commentArchive($streamId: String!, $commentId: String!) {
commentArchive(streamId: $streamId, commentId: $commentId)
}
`,
variables: {
streamId: this.streamId,
commentId: this.comment.id
}
})
this.showArchiveDialog = false
this.commentDetails.archived = true
this.$emit('deleted', this.comment)
this.$mixpanel.track('Comment Action', { type: 'action', name: 'archive' })
this.$eventHub.$emit('notification', {
text: 'Thread archived.'
})
} catch (e) {
this.$eventHub.$emit('notification', {
text: e.message
})
}
}
}
}
</script>
<style scoped>
.border {
outline: 2px solid #047efb;
}
</style>
@@ -1,112 +0,0 @@
<template>
<v-card>
<v-toolbar>
<v-toolbar-title>
{{ attachment.fileName }}
</v-toolbar-title>
<v-spacer />
<v-btn class="primary" @click="downloadBlob()">
<v-icon class="mr-2">mdi-download</v-icon>
{{ prettyFileSize(attachment.fileSize) }}
</v-btn>
</v-toolbar>
<template v-if="isImage && !error">
<v-img min-width="100%" min-height="100px" :src="blobUrl">
<template #placeholder>
<v-row class="fill-height ma-0" align="center" justify="center">
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
</template>
<template v-else-if="!error">
<v-card-text class="mt-4">
<v-icon small class="mr-2">mdi-alert</v-icon>
Be cautious when downloading! Attachments are not scanned for harmful content.
</v-card-text>
</template>
<template v-else>
<v-card-text class="mt-4">
<v-icon small class="mr-2">mdi-alert</v-icon>
Failed to preview attachment.
</v-card-text>
</template>
</v-card>
</template>
<script>
import Vue from 'vue'
import { prettyFileSize } from '@/main/lib/common/file-upload/fileUploadHelper'
import {
getBlobUrl,
downloadBlobWithUrl
} from '@/main/lib/common/file-upload/blobStorageApi'
import { useCommitObjectViewerParams } from '@/main/lib/viewer/commit-object-viewer/stateManager'
export default Vue.extend({
name: 'CommentThreadAttachmentPreview',
props: {
attachment: {
type: Object,
default: () => null,
required: true
},
isOpen: { type: Boolean, required: true }
},
setup() {
const { streamId, resourceId } = useCommitObjectViewerParams()
return { streamId, resourceId }
},
data: () => ({
prettyFileSize,
blobUrl: null,
error: null
}),
computed: {
isImage() {
switch (this.attachment.fileType) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
return true
default:
return false
}
}
},
watch: {
isOpen(val) {
if (!val && this.blobUrl) {
window.URL.revokeObjectURL(this.blobUrl)
}
}
},
async mounted() {
try {
if (this.isImage) {
this.blobUrl = await getBlobUrl(this.attachment.id, {
streamId: this.streamId
})
}
} catch (e) {
this.error = e
}
},
methods: {
async downloadBlob() {
try {
const { id, fileName, streamId } = this.attachment
await downloadBlobWithUrl(id, fileName, { streamId })
} catch (e) {
this.$eventHub.$emit('notification', {
text: e.message
})
}
this.$emit('close')
}
}
})
</script>
@@ -1,196 +0,0 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
class="d-flex align-center comment-thread-reply"
:class="
isUserOwned ? 'comment-thread-reply--author' : 'comment-thread-reply--visitor'
"
@mouseenter="hover = true"
@mouseleave="hover = false"
>
<div
:class="`comment-thread-reply__inner flex-grow-1 d-flex flex-column px-2 py-1 mb-2 rounded-xl elevation-2`"
style="width: 290px"
>
<div class="d-flex">
<div :class="`d-inline-block`">
<user-avatar :id="reply.authorId" :size="30" />
</div>
<div
:class="`reply-box d-inline-block mx-2 py-2 flex-grow-1 float-left caption`"
>
<smart-text-editor
v-if="reply.text.doc"
min-width
read-only
:schema-options="richTextSchema"
:value="reply.text.doc"
/>
<comment-thread-reply-attachments
v-if="reply.text.attachments && reply.text.attachments.length"
:attachments="reply.text.attachments"
:primary="isUserOwned"
/>
</div>
</div>
</div>
<div style="width: 20px; overflow: hidden; position: relative; top: -5px">
<v-scroll-x-transition>
<v-btn
v-show="hover && canArchive"
v-tooltip="'Archive'"
x-small
icon
class="ml-1"
@click="showArchiveDialog = true"
>
<v-icon small>mdi-delete</v-icon>
</v-btn>
</v-scroll-x-transition>
</div>
<v-dialog v-model="showArchiveDialog" max-width="500">
<v-card>
<v-toolbar color="error" dark flat>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-pencil</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>
Archive Comment {{ index === 0 ? 'Thread' : '' }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="showArchiveDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text class="mt-4">
This comment {{ index === 0 ? 'thread, including all replies, ' : '' }} will
be archived. Are you sure?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="showArchiveDialog = false">Cancel</v-btn>
<v-btn color="error" text @click="archiveComment()">Archive</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { gql } from '@apollo/client/core'
import SmartTextEditor from '@/main/components/common/text-editor/SmartTextEditor.vue'
import { SMART_EDITOR_SCHEMA } from '@/main/lib/viewer/comments/commentsHelper'
import CommentThreadReplyAttachments from '@/main/components/comments/CommentThreadReplyAttachments.vue'
import { useCommitObjectViewerParams } from '@/main/lib/viewer/commit-object-viewer/stateManager'
import { useIsLoggedIn } from '@/main/lib/core/composables/core'
import { computed } from 'vue'
export default {
components: {
UserAvatar: () => import('@/main/components/common/UserAvatar'),
SmartTextEditor,
CommentThreadReplyAttachments
},
props: {
reply: { type: Object, default: () => null },
stream: { type: Object, default: () => null },
index: { type: Number, default: 0 }
},
setup(props) {
const { streamId, resourceId, isEmbed } = useCommitObjectViewerParams()
const { userId } = useIsLoggedIn()
const isUserOwned = computed(
() => !!(userId.value && userId.value === props.reply?.authorId)
)
return { streamId, resourceId, isEmbed, isUserOwned }
},
data() {
return {
hover: false,
showArchiveDialog: false,
richTextSchema: SMART_EDITOR_SCHEMA
}
},
computed: {
canArchive() {
if (this.isEmbed) return false
if (!this.reply || !this.stream) return false
if (this.stream.role === 'stream:owner' || this.isUserOwned) return true
return false
}
},
methods: {
async archiveComment() {
try {
await this.$apollo.mutate({
mutation: gql`
mutation commentArchive($streamId: String!, $commentId: String!) {
commentArchive(streamId: $streamId, commentId: $commentId)
}
`,
variables: {
streamId: this.streamId,
commentId: this.reply.id
}
})
this.$emit('deleted', this.reply.id)
this.$mixpanel.track('Comment Action', { type: 'action', name: 'archive' })
this.$eventHub.$emit('notification', {
text: this.index === 0 ? 'Thread archived.' : 'Comment archived.'
})
} catch (e) {
this.$eventHub.$emit('notification', {
text: e.message
})
}
this.showArchiveDialog = false
}
}
}
</script>
<style lang="scss">
// Theme-specific coloring
.comment-thread-reply {
$base: &;
// Active user's reply
&.comment-thread-reply--author {
& > #{$base}__inner {
background-color: var(--v-primary-base);
border-color: var(--v-primary-base);
&,
a {
color: #ffffff;
}
}
}
// Guest's reply
&.comment-thread-reply--visitor {
& > #{$base}__inner {
background-color: var(--v-background-base);
border-color: var(--v-background-base);
&,
a {
color: var(--v-text-base);
}
}
}
}
.smart-text-editor,
.comment-attachments {
a {
font-weight: bold;
text-decoration: none;
word-break: break-all;
}
}
.smart-text-editor {
a:after {
content: ' ↗ ';
}
}
</style>

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