diff --git a/web/satellite/.eslintrc.js b/web/satellite/.eslintrc.cjs similarity index 98% rename from web/satellite/.eslintrc.js rename to web/satellite/.eslintrc.cjs index 5757ea9dc..63d3700fb 100644 --- a/web/satellite/.eslintrc.js +++ b/web/satellite/.eslintrc.cjs @@ -61,6 +61,7 @@ module.exports = { ], 'newlines-between': 'always', }], + 'import/no-unresolved': ['error', { ignore: ['^virtual:'] }], 'no-duplicate-imports': 'error', 'import/default': 'off', 'vue/script-setup-uses-vars': 'error', diff --git a/web/satellite/.stylelintrc.js b/web/satellite/.stylelintrc.cjs similarity index 100% rename from web/satellite/.stylelintrc.js rename to web/satellite/.stylelintrc.cjs diff --git a/web/satellite/package.json b/web/satellite/package.json index 176a3f971..d1c821380 100644 --- a/web/satellite/package.json +++ b/web/satellite/package.json @@ -2,6 +2,7 @@ "name": "storj-satellite", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "preview": "vite preview", "dev": "vite", diff --git a/web/satellite/tsconfig.json b/web/satellite/tsconfig.json index 95a1f8da9..419a34868 100644 --- a/web/satellite/tsconfig.json +++ b/web/satellite/tsconfig.json @@ -40,7 +40,8 @@ "src/**/*.ts", "src/**/*.vue", "src/types/*.d.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "vitePlugins/**/*.d.ts" ], "exclude": [ "node_modules" diff --git a/web/satellite/vite.config-vuetify.js b/web/satellite/vite.config-vuetify.js index 255071874..c1d359da3 100644 --- a/web/satellite/vite.config-vuetify.js +++ b/web/satellite/vite.config-vuetify.js @@ -7,6 +7,8 @@ import vue from '@vitejs/plugin-vue'; import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'; import { defineConfig } from 'vite'; +import vuetifyThemeCSS from './vitePlugins/vuetifyThemeCSS'; + // https://vitejs.dev/config/ export default defineConfig({ base: '/static/dist_vuetify_poc', @@ -21,6 +23,7 @@ export default defineConfig({ configFile: 'vuetify-poc/src/styles/settings.scss', }, }), + vuetifyThemeCSS(), ], define: { 'process.env': {}, diff --git a/web/satellite/vite.config.js b/web/satellite/vite.config.js index 7c8dcbd57..50a6763a9 100644 --- a/web/satellite/vite.config.js +++ b/web/satellite/vite.config.js @@ -19,7 +19,7 @@ const plugins = [ plugins: [{ name: 'removeViewBox', fn: () => {} }], }, }), - vitePluginRequire(), + vitePluginRequire.default(), ]; if (process.env['STORJ_DEBUG_BUNDLE_SIZE']) { diff --git a/web/satellite/vitePlugins/vuetifyThemeCSS/index.ts b/web/satellite/vitePlugins/vuetifyThemeCSS/index.ts new file mode 100644 index 000000000..dfc4053d7 --- /dev/null +++ b/web/satellite/vitePlugins/vuetifyThemeCSS/index.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +import { Plugin } from 'vite'; +import { build } from 'esbuild'; +import { createVuetify } from 'vuetify'; + +import { THEME_OPTIONS } from '../../vuetify-poc/src/plugins/theme'; + +export default function vuetifyThemeCSS(): Plugin { + const name = 'vuetify-theme-css'; + const virtualModuleId = 'virtual:' + name; + const resolvedVirtualModuleId = '\0' + virtualModuleId; + + const theme = createVuetify({ theme: THEME_OPTIONS }).theme; + const themeURLs: Record = {}; + + return { + name, + + async buildStart() { + for (const name of Object.keys(theme.themes.value)) { + theme.global.name.value = name; + + const result = await build({ + stdin: { + contents: theme.styles.value, + loader: 'css', + }, + write: false, + minify: true, + }); + + const refId = this.emitFile({ + type: 'asset', + name: `theme-${name}.css`, + source: result.outputFiles[0].text, + }); + themeURLs[name] = `__VITE_ASSET__${refId}__`; + } + }, + + resolveId(id: string) { + if (id === virtualModuleId) return resolvedVirtualModuleId; + }, + + load(id: string) { + if (id === resolvedVirtualModuleId) { + return `export const themeURLs = {${ + Object.entries(themeURLs) + .map(([name, url]) => `'${name}':'${url}'`) + .join(',') + }};`; + } + }, + }; +} diff --git a/web/satellite/vitePlugins/vuetifyThemeCSS/module.d.ts b/web/satellite/vitePlugins/vuetifyThemeCSS/module.d.ts new file mode 100644 index 000000000..cc55b896d --- /dev/null +++ b/web/satellite/vitePlugins/vuetifyThemeCSS/module.d.ts @@ -0,0 +1,6 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +declare module 'virtual:vuetify-theme-css' { + export const themeURLs: Record; +} diff --git a/web/satellite/vuetify-poc/src/plugins/index.ts b/web/satellite/vuetify-poc/src/plugins/index.ts index b74357f5f..a571d0655 100644 --- a/web/satellite/vuetify-poc/src/plugins/index.ts +++ b/web/satellite/vuetify-poc/src/plugins/index.ts @@ -8,8 +8,9 @@ */ // Plugins -import { App } from 'vue'; +import { App, watch } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; +import { themeURLs } from 'virtual:vuetify-theme-css'; import { router, startTitleWatcher } from '../router'; @@ -20,7 +21,46 @@ import NotificatorPlugin from '@/utils/plugins/notificator'; const pinia = createPinia(); setActivePinia(pinia); +// Vuetify's way of applying themes uses a dynamic inline stylesheet. +// This is incompatible with our CSP policy, so circumvent it. +function setupTheme() { + const oldAppend = document.head.appendChild.bind(document.head); + document.head.appendChild = function(node: T): T { + if (node instanceof HTMLStyleElement && node.id === 'vuetify-theme-stylesheet') { + node.remove(); + return node; + } + return oldAppend(node); + }; + + const themeLinks: Record = {}; + + for (const [name, url] of Object.entries(themeURLs)) { + let link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.disabled = name !== vuetify.theme.global.name.value; + document.head.appendChild(link); + themeLinks[name] = link; + + // If we don't preload the style, there will be a delay after + // toggling to it for the first time. + link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'style'; + link.href = url; + document.head.appendChild(link); + } + + watch(() => vuetify.theme.global.name.value, newName => { + for (const [name, link] of Object.entries(themeLinks)) { + link.disabled = name !== newName; + } + }); +} + export function registerPlugins(app: App) { + setupTheme(); app .use(vuetify) .use(router) diff --git a/web/satellite/vuetify-poc/src/plugins/theme.ts b/web/satellite/vuetify-poc/src/plugins/theme.ts new file mode 100644 index 000000000..446a94a42 --- /dev/null +++ b/web/satellite/vuetify-poc/src/plugins/theme.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +import { createVuetify } from 'vuetify'; + +type ThemeOptions = NonNullable[0]>['theme']>; + +export const THEME_OPTIONS: ThemeOptions = { + themes: { + light: { + colors: { + primary: '#0149FF', + secondary: '#0218A7', + background: '#FFF', + surface: '#FFF', + info: '#0059D0', + help: '#FFA800', + success: '#00AC26', + warning: '#FF7F00', + error: '#FF0149', + purple: '#7B61FF', + blue6: '#091c45', + blue5: '#0218A7', + blue4: '#0059D0', + blue2: '#003ACD', + yellow: '#FFC600', + yellow2: '#FFB701', + orange: '#FFA800', + green: '#00B150', + purple2: '#502EFF', + }, + }, + dark: { + colors: { + primary: '#0149FF', + secondary: '#537CFF', + background: '#090920', + success: '#00AC26', + help: '#FFC600', + error: '#FF0149', + surface: '#090920', + purple: '#A18EFF', + blue6: '#091c45', + blue5: '#2196f3', + blue4: '#0059D0', + blue2: '#003ACD', + yellow: '#FFC600', + yellow2: '#FFB701', + orange: '#FFA800', + warning: '#FF8A00', + // green: '#00B150', + green: '#00e366', + purple2: '#A18EFF', + }, + }, + }, +}; diff --git a/web/satellite/vuetify-poc/src/plugins/vuetify.ts b/web/satellite/vuetify-poc/src/plugins/vuetify.ts index 993ac001c..7dc432d49 100644 --- a/web/satellite/vuetify-poc/src/plugins/vuetify.ts +++ b/web/satellite/vuetify-poc/src/plugins/vuetify.ts @@ -13,62 +13,14 @@ import '@fontsource-variable/inter'; import { createVuetify } from 'vuetify'; import { md3 } from 'vuetify/blueprints'; -import '../styles/styles.scss'; +import '@poc/styles/styles.scss'; +import { THEME_OPTIONS } from '@poc/plugins/theme'; // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides export default createVuetify({ // Use blueprint for Material Design 3 blueprint: md3, - theme: { - themes: { - light: { - colors: { - primary: '#0149FF', - secondary: '#0218A7', - background: '#FFF', - surface: '#FFF', - info: '#0059D0', - help: '#FFA800', - success: '#00AC26', - warning: '#FF7F00', - error: '#FF0149', - purple: '#7B61FF', - blue6: '#091c45', - blue5: '#0218A7', - blue4: '#0059D0', - blue2: '#003ACD', - yellow: '#FFC600', - yellow2: '#FFB701', - orange: '#FFA800', - green: '#00B150', - purple2: '#502EFF', - }, - }, - dark: { - colors: { - primary: '#0149FF', - secondary: '#537CFF', - background: '#090920', - success: '#00AC26', - help: '#FFC600', - error: '#FF0149', - surface: '#090920', - purple: '#A18EFF', - blue6: '#091c45', - blue5: '#2196f3', - blue4: '#0059D0', - blue2: '#003ACD', - yellow: '#FFC600', - yellow2: '#FFB701', - orange: '#FFA800', - warning: '#FF8A00', - // green: '#00B150', - green: '#00e366', - purple2: '#A18EFF', - }, - }, - }, - }, + theme: THEME_OPTIONS, defaults: { global: { // ripple: false,