From bdb741f7e3013f28cb2fb85dfb0934ea70f1dfe5 Mon Sep 17 00:00:00 2001 From: Aurora Lahtela <24460436+AuroraLS3@users.noreply.github.com> Date: Sun, 8 Jun 2025 21:01:59 +0300 Subject: [PATCH 01/57] Prototype /theme-editor Listed currently in-use colors and related use cases based on night mode overrides and easy to find elements TODO: - Persistence of current theme - Color editor - Use case previews - Taking the new theme css to use - Using use case colors for things - Theme selector - Themes & theme storage - Check that all elements are there - Night mode support for theme editor - Translation support for theme editor --- Plan/react/dashboard/src/App.jsx | 2 + .../src/components/layout/SideNavTabs.jsx | 6 +- .../src/components/theme/ColorBox.jsx | 47 +++ .../src/components/theme/ThemeStyleCss.jsx | 76 +++++ .../dashboard/src/nightModeUseCases.json | 73 ++++ Plan/react/dashboard/src/style/main.sass | 47 ++- Plan/react/dashboard/src/theme.json | 92 +++++ Plan/react/dashboard/src/useCases.json | 182 ++++++++++ Plan/react/dashboard/src/util/colors.js | 134 +++++++- Plan/react/dashboard/src/util/mutator.js | 27 ++ .../src/views/layout/ThemeEditorPage.jsx | 323 ++++++++++++++++++ 11 files changed, 1005 insertions(+), 4 deletions(-) create mode 100644 Plan/react/dashboard/src/components/theme/ColorBox.jsx create mode 100644 Plan/react/dashboard/src/components/theme/ThemeStyleCss.jsx create mode 100644 Plan/react/dashboard/src/nightModeUseCases.json create mode 100644 Plan/react/dashboard/src/theme.json create mode 100644 Plan/react/dashboard/src/useCases.json create mode 100644 Plan/react/dashboard/src/util/mutator.js create mode 100644 Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx diff --git a/Plan/react/dashboard/src/App.jsx b/Plan/react/dashboard/src/App.jsx index 22267b4fd2..ff5e2e9064 100644 --- a/Plan/react/dashboard/src/App.jsx +++ b/Plan/react/dashboard/src/App.jsx @@ -69,6 +69,7 @@ const RegisterPage = React.lazy(() => import("./views/layout/RegisterPage")); const ErrorPage = React.lazy(() => import("./views/layout/ErrorPage")); const ErrorsPage = React.lazy(() => import("./views/layout/ErrorsPage")); const SwaggerView = React.lazy(() => import("./views/SwaggerView")); +const ThemeEditorPage = React.lazy(() => import("./views/layout/ThemeEditorPage")); const OverviewRedirect = () => { return () @@ -208,6 +209,7 @@ function App() { } {!staticSite && }/>} {!staticSite && }/>} + }/> { +const SliceHeader = ({i, open, onClick, slice, alignment}) => { return (
  • {slice.header}
  • @@ -26,7 +27,7 @@ const SliceBody = ({i, open, slice}) => { ) } -const SideNavTabs = ({slices, open}) => { +const SideNavTabs = ({slices, open, alignment}) => { const [openSlice, setOpenSlice] = useState(open ? 0 : -1); return ( @@ -40,6 +41,7 @@ const SideNavTabs = ({slices, open}) => { slice={slice} open={openSlice === i} onClick={() => setOpenSlice(i)} + alignment={alignment} /> )) :
  • No Data
  • } diff --git a/Plan/react/dashboard/src/components/theme/ColorBox.jsx b/Plan/react/dashboard/src/components/theme/ColorBox.jsx new file mode 100644 index 0000000000..75ac0631ad --- /dev/null +++ b/Plan/react/dashboard/src/components/theme/ColorBox.jsx @@ -0,0 +1,47 @@ +import React from "react"; +import {getContrastColor} from '../../util/colors'; +import {Col} from 'react-bootstrap'; + +const BackgroundColorBox = ({name, color}) => { + // Convert color value to CSS variable if it's a variable reference + const cssColor = color.startsWith('var(') ? color : `var(--col-${name})`; + const contrastColor = getContrastColor(color); + + return ( + +
    + {name} +
    {color}
    +
    + + ); +}; + +const TextColorBox = ({name, color}) => { + const needsDarkBackground = name.startsWith('text-dark') || name.includes('night'); + const cssColor = color.startsWith('var(') ? color : `var(--col-${name})`; + + return ( + +
    + {name} +
    {color}
    +
    + + ); +}; + +export const ColorBox = ({name, color}) => { + const isTextColor = name.includes('text'); + return isTextColor ? + : + ; +}; diff --git a/Plan/react/dashboard/src/components/theme/ThemeStyleCss.jsx b/Plan/react/dashboard/src/components/theme/ThemeStyleCss.jsx new file mode 100644 index 0000000000..871a9ac64d --- /dev/null +++ b/Plan/react/dashboard/src/components/theme/ThemeStyleCss.jsx @@ -0,0 +1,76 @@ +import {flattenObject} from '../../util/mutator'; +import {getContrastColor, hsvToHex, hsxStringToArray, withReducedSaturation} from '../../util/colors'; + +// Function to generate CSS variables from theme data +const generateThemeCSS = ({theme, useCases, nightModeUseCases}) => { + const baseVariables = []; + const nightModeVariables = []; + + // Helper to add both color and its contrast + const addColorWithContrast = (name, color, variables) => { + variables.push(`--col-${name}: ${color}`); + // If color is HSL string (from withReducedSaturation), convert to hex for contrast + const hexColor = color.startsWith('hsl') ? hsvToHex(hsxStringToArray(color)) : color; + variables.push(`--contrast-col-${name}: ${getContrastColor(hexColor)}`); + }; + + // Add regular colors + Object.entries(theme.colors).forEach(([key, value]) => { + addColorWithContrast(key, value, baseVariables); + // Add desaturated version for night mode + const nightColor = withReducedSaturation(value); + addColorWithContrast(key, nightColor, nightModeVariables); + }); + + // Add night mode colors + Object.entries(theme.nightColors).forEach(([key, value]) => { + addColorWithContrast(key, value, baseVariables); + addColorWithContrast(key, value, nightModeVariables); + }); + + // Add pie chart colors + theme.pieColors.forEach((color, index) => { + addColorWithContrast(`pie-${index + 1}`, color, baseVariables); + const nightColor = withReducedSaturation(color); + addColorWithContrast(`pie-${index + 1}`, nightColor, nightModeVariables); + }); + + // Add use case variables + const flattenedUseCases = flattenObject(useCases); + Object.entries(flattenedUseCases).forEach(([key, value]) => { + if (typeof value === 'string' && value.startsWith('var(--col-')) { + const referencedColor = value.replace('var(--col-', '').replace(')', ''); + baseVariables.push(`--col-${key}: var(--col-${referencedColor})`); + baseVariables.push(`--contrast-col-${key}: var(--contrast-col-${referencedColor})`); + } + }); + + // Add night mode use case variables + const flattenedNightUseCases = flattenObject(nightModeUseCases); + Object.entries(flattenedNightUseCases).forEach(([key, value]) => { + if (typeof value === 'string' && value.startsWith('var(--col-')) { + const referencedColor = value.replace('var(--col-', '').replace(')', ''); + nightModeVariables.push(`--col-${key}: var(--col-${referencedColor})`); + nightModeVariables.push(`--contrast-col-${key}: var(--contrast-col-${referencedColor})`); + } + }); + + return ` +:root { + ${baseVariables.join(';\n ')}; + background-color: var(--col-white-grey); + color: var(--col-text-light); +} + +.night-mode-colors { + ${nightModeVariables.join(';\n ')}; + background-color: var(--col-night-grey-blue); + color: var(--col-night-text); +}`; +}; + +export const ThemeStyleCss = ({theme, useCases, nightModeUseCases}) => { + return ( + + ) +} \ No newline at end of file diff --git a/Plan/react/dashboard/src/nightModeUseCases.json b/Plan/react/dashboard/src/nightModeUseCases.json new file mode 100644 index 0000000000..71e7435bea --- /dev/null +++ b/Plan/react/dashboard/src/nightModeUseCases.json @@ -0,0 +1,73 @@ +{ + "theme": "var(--col-night-grey-blue)", + "themeText": "var(--col-plan)", + "layout": { + "background": "var(--col-night-black)", + "divider": "var(--col-night-blue)" + }, + "sidebar": { + "navigationItem": { + "background": "var(--col-night-dark-blue)", + "text": "var(--col-night-text)", + "hover": "var(--col-night-dark-grey-blue)", + "active": { + "background": "var(--col-night-dark-blue)" + } + }, + "collapsibleSection": { + "background": "var(--col-theme)", + "text": "var(--col-night-text)", + "hover": "var(--col-night-dark-grey-blue)", + "border": "var(--col-night-blue)" + } + }, + "cards": { + "background": "var(--col-night-dark-blue)", + "border": "var(--col-night-blue)", + "header": { + "background": "var(--col-night-dark-blue)", + "border": "var(--col-night-blue)" + } + }, + "forms": { + "buttons": { + "secondaryButton": "var(--col-night-grey-blue)", + "secondaryButtonBorder": "var(--col-night-blue)" + }, + "input": { + "background": "var(--col-night-dark-blue)", + "border": "var(--col-night-blue)", + "text": "var(--col-night-text)" + }, + "select": { + "background": "var(--col-night-dark-blue)", + "border": "var(--col-night-blue)", + "text": "var(--col-night-text)" + }, + "checkbox": { + "checked": { + "background": "var(--col-night-blue)", + "border": "var(--col-night-blue)" + } + }, + "dropdown": { + "item": { + "text": "var(--col-night-text)", + "hover": "var(--col-night-blue)" + }, + "header": "var(--col-night-text)", + "border": "var(--col-night-blue)" + } + }, + "calendar": { + "today": "var(--col-night-grey-blue)", + "popover": { + "body": "var(--col-night-dark-blue)", + "header": "var(--col-night-dark-blue)", + "text": "var(--col-night-text)" + }, + "borders": "var(--col-night-blue)", + "links": "var(--col-night-text)", + "button": "var(--col-plan)" + } +} \ No newline at end of file diff --git a/Plan/react/dashboard/src/style/main.sass b/Plan/react/dashboard/src/style/main.sass index a5dae798c1..cb2b188b9a 100644 --- a/Plan/react/dashboard/src/style/main.sass +++ b/Plan/react/dashboard/src/style/main.sass @@ -38,4 +38,49 @@ p, span, td, .h3, a, button height: 5px position: absolute bottom: 0 - margin-left: -1rem \ No newline at end of file + margin-left: -1rem + +// Color Box Styles +.color-box-wrapper + width: 100% + height: 40px + border: 1px solid #ddd + border-radius: 4px + padding: 8px 0 8px 12px + display: flex + align-items: center + justify-content: space-between + cursor: default + + > span + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + font-size: 0.9rem + margin-right: 8px + + > div + padding-right: 10px + height: 100% + display: flex + align-items: center + justify-content: center + font-size: 0.8rem + font-family: monospace + +.background-color-box + .color-box-wrapper + background-color: var(--box-color) + color: var(--box-contrast-color) + +.text-color-box + .color-box-wrapper + background-color: var(--box-bg-color, white) + + > span, + > div + color: var(--box-color) + + &.night-mode + .color-box-wrapper + background-color: var(--color-night-dark-blue) \ No newline at end of file diff --git a/Plan/react/dashboard/src/theme.json b/Plan/react/dashboard/src/theme.json new file mode 100644 index 0000000000..601645c00c --- /dev/null +++ b/Plan/react/dashboard/src/theme.json @@ -0,0 +1,92 @@ +{ + "defaultTheme": "plan", + "nightText": { + "default": "var(--col-text-dark)", + "buttons": "var(--col-text-dark)", + "transparentLight": "var(--col-text-dark)", + "headings": "var(--col-text-dark)", + "cardText": "var(--col-text-dark)", + "bodyText": "var(--col-text-dark)", + "placeholder": "var(--col-text-dark)" + }, + "colors": { + "white": "#ffffff", + "black": "#555555", + "text-light": "#333", + "text-light-disabled": "#858796", + "text-dark": "#fff", + "text-dark-disabled": "#ccc", + "plan": "#368F17", + "red": "#F44336", + "pink": "#E91E63", + "purple": "#9C27B0", + "deep-purple": "#673AB7", + "indigo": "#3F51B5", + "blue": "#2196F3", + "light-blue": "#03A9F4", + "cyan": "#00BCD4", + "teal": "#009688", + "green": "#4CAF50", + "light-green": "#8BC34A", + "lime": "#CDDC39", + "yellow": "#ffe821", + "amber": "#FFC107", + "orange": "#FF9800", + "deep-orange": "#FF5722", + "brown": "#795548", + "blue-grey": "#607D8B", + "tps-yellow": "#e5cc12", + "cpu-yellow": "#e0d264", + "chunk-brown": "#b58310", + "ram-green": "#7dcc24", + "entity-purple": "#ac69ef", + "success": "#1CC88A", + "warning": "#F6C23E", + "danger": "#e74A3B", + "alert-success": "#d2f4e8", + "alert-warning": "#fdf3d8", + "alert-danger": "#fadbd8", + "bright-blue": "#1E90FF", + "ping-amber": "#ffd54f", + "map-green": "#EEFFEE", + "alert-success-text": "#0f6848", + "alert-warning-text": "#806520", + "alert-danger-text": "#78261f", + "secondary": "#6c757d", + "cool-grey": "#6e707e", + "ink": "#222222", + "dark-slate": "#212529", + "medium-slate": "#3a3b45", + "light-slate": "#5a5c69", + "grey": "#9E9E9E", + "light-grey": "#dddddd", + "white-grey": "#f8f9fc", + "pale-grey": "#eaecf4", + "cement-grey": "#e3e6f0", + "stone-grey": "#d1d3e2" + }, + "nightColors": { + "night-black": "#282a36", + "night-dark-blue": "#44475a", + "night-blue": "#6272a4", + "night-grey-blue": "#646e8c", + "night-dark-grey-blue": "#606270", + "night-text": "#eee8d5" + }, + "pieColors": [ + "#0099C6", + "#66AA00", + "#316395", + "#994499", + "#22AA99", + "#AAAA11", + "#6633CC", + "#E67300", + "#329262", + "#5574A6" + ], + "font": { + "styleSheet": "https://fonts.googleapis.com/css?family=Nunito:400,700,800,900&display=swap&subset=latin-ext", + "family": "Nunito" + } +} \ No newline at end of file diff --git a/Plan/react/dashboard/src/useCases.json b/Plan/react/dashboard/src/useCases.json new file mode 100644 index 0000000000..fae5b3ca7b --- /dev/null +++ b/Plan/react/dashboard/src/useCases.json @@ -0,0 +1,182 @@ +{ + "theme": "var(--col-plan)", + "themeText": "var(--col-theme)", + "layout": { + "sidebar": "var(--col-theme)", + "background": "var(--col-white-grey)", + "divider": "var(--col-cement-grey)", + "loader": { + "border": "var(--col-plan)", + "background": "var(--col-plan)" + }, + "helpIcon": "var(--col-light-blue)" + }, + "sidebar": { + "navigationItem": { + "background": "var(--col-theme)", + "text": "var(--col-text-dark)", + "hover": "var(--col-theme)", + "active": { + "background": "var(--col-theme)" + } + }, + "collapsibleSection": { + "background": "var(--col-white)", + "text": "var(--col-text-light)", + "hover": "var(--col-pale-grey)", + "border": "var(--col-pale-grey)" + } + }, + "cards": { + "background": "var(--col-white)", + "border": "var(--col-cement-grey)", + "header": { + "background": "var(--col-white-grey)", + "border": "var(--col-cement-grey)" + } + }, + "infoBox": { + "info": "var(--col-alert-success)", + "infoText": "var(--col-alert-success-text)", + "notice": "var(--col-alert-warning)", + "noticeText": "var(--col-alert-warning-text)", + "error": "var(--col-alert-danger)", + "errorText": "var(--col-alert-danger-text)" + }, + "calendar": { + "today": "var(--col-white)", + "popover": { + "body": "var(--col-white)", + "header": "var(--col-white)", + "text": "var(--col-text-light)" + }, + "borders": "var(--col-cement-grey)", + "links": "var(--col-text-light)", + "button": "var(--col-theme)" + }, + "forms": { + "buttons": { + "activeButton": "var(--col-theme)", + "secondaryButton": "var(--col-white)", + "secondaryButtonBorder": "var(--col-secondary)" + }, + "input": { + "background": "var(--col-white)", + "border": "var(--col-cement-grey)", + "text": "var(--col-text-light)" + }, + "select": { + "background": "var(--col-white)", + "border": "var(--col-cement-grey)", + "text": "var(--col-text-light)" + }, + "checkbox": { + "checked": { + "background": "var(--col-theme)", + "border": "var(--col-cement-grey)" + } + }, + "dropdown": { + "item": { + "text": "var(--col-text-light)", + "hover": "var(--col-white-grey)" + }, + "header": "var(--col-text-light)", + "border": "var(--col-cement-grey)" + } + }, + "graphs": { + "punchCard": "var(--col-ink)", + "playersOnline": "var(--col-bright-blue)", + "tps": { + "high": "var(--col-green)", + "medium": "var(--col-tps-yellow)", + "low": "var(--col-red)" + }, + "cpu": "var(--col-cpu-yellow)", + "ram": "var(--col-ram-green)", + "chunks": "var(--col-chunk-brown)", + "entities": "var(--col-entity-purple)", + "worldMap": { + "high": "var(--col-green)", + "low": "var(--col-map-green)" + }, + "ping": { + "max": "var(--col-amber)", + "avg": "var(--col-warning)", + "min": "var(--col-ping-amber)" + } + }, + "data": { + "servers": "var(--col-light-green)", + "trend": { + "better": "var(--col-success)", + "same": "var(--col-warning)", + "worse": "var(--col-danger)" + }, + "play": { + "playtime": "var(--col-green)", + "playtimeActive": "var(--col-green)", + "playtimeAfk": "var(--col-grey)", + "sessions": "var(--col-teal)", + "sessionLength": "var(--col-teal)", + "gamemode": "var(--col-teal)", + "firstSeen": "var(--col-light-green)", + "lastSeen": "var(--col-teal)" + }, + "players": { + "count": "var(--col-black)", + "online": "var(--col-blue)", + "unique": "var(--col-light-blue)", + "new": "var(--col-light-green)", + "activityIndex": "var(--col-amber)", + "veryActive": "var(--col-green)", + "active": "var(--col-light-green)", + "regular": "var(--col-lime)", + "irregular": "var(--col-amber)", + "inactive": "var(--col-blue-grey)" + }, + "playerPeakLast": "var(--col-light-blue)", + "playerPeakAllTime": "var(--col-light-green)", + "performance": { + "uptime": "var(--col-light-green)", + "downtime": "var(--col-red)", + "tps": "var(--col-red)", + "tpsLowSpikes": "var(--col-red)", + "tpsAverage": "var(--col-orange)", + "cpu": "var(--col-amber)", + "ram": "var(--col-light-green)", + "entities": "var(--col-purple)", + "chunks": "var(--col-blue-grey)", + "disk": "var(--col-green)", + "ping": "var(--col-amber)" + }, + "calculated": { + "insights": "var(--col-red)", + "joinAddresses": "var(--col-amber)", + "retention": "var(--col-indigo)", + "retentionNewPlayers": "var(--col-light-green)", + "geolocation": "var(--col-green)", + "allowList": "var(--col-orange)", + "pluginVersions": "var(--col-indigo)" + }, + "playerVersus": { + "playerKills": "var(--col-red)", + "mobKills": "var(--col-green)", + "deaths": "var(--col-black)", + "top-3": { + "first": "var(--col-amber)", + "second": "var(--col-grey)", + "third": "var(--col-brown)" + } + }, + "playerStatus": { + "online": "var(--col-green)", + "offline": "var(--col-red)", + "banned": "var(--col-red)", + "operator": "var(--col-blue)", + "kicks": "var(--col-brown)", + "nicknames": "var(--col-purple)" + } + } +} \ No newline at end of file diff --git a/Plan/react/dashboard/src/util/colors.js b/Plan/react/dashboard/src/util/colors.js index 6205aff26d..dde6c20e37 100644 --- a/Plan/react/dashboard/src/util/colors.js +++ b/Plan/react/dashboard/src/util/colors.js @@ -139,6 +139,10 @@ export const hslToHsv = ([h, s, l]) => { return [h, hsvS, hsvV]; } +export const hsvToHex = (hsv) => { + return rgbToHexString(hsvToRgb(hsv)); +} + export const hsvToRgb = ([h, s, v]) => { let r, g, b; @@ -200,6 +204,16 @@ export const randomHSVColor = (i) => { return [hue, saturation, value] } +export const rgmStringToArray = (rgbString) => { + const colors = rgbString.substring(4, rgbString.length - 1); + const split = colors.split(','); + return [ + Number.parseInt(split[0].trim()), + Number.parseInt(split[1].trim()), + Number.parseInt(split[2].trim()) + ]; +} + export const rgbToHexString = ([r, g, b]) => { return '#' + rgbToHex(r) + rgbToHex(g) + rgbToHex(b); } @@ -258,6 +272,104 @@ export const withReducedSaturation = (hex, reduceSaturationPercentage) => { return 'hsl(' + h * 360 + ',' + s * 100 * saturationReduction + '%,' + l * 95 + '%)'; } +export const calculateCssHexColor = (cssColor) => { + const colorCalculationElement = document.createElement('div'); + colorCalculationElement.style.display = 'none'; + colorCalculationElement.style.color = cssColor; + document.body.appendChild(colorCalculationElement); + const rgbString = window.getComputedStyle(colorCalculationElement, null).getPropertyValue("color"); + const hex = rgbToHexString(rgmStringToArray(rgbString)); + document.body.removeChild(colorCalculationElement); + return hex; +} + +export const calculateCssColors = (cssSelector) => { + const colors = { + color: null, + backgroundColor: null, + borderColor: null + }; + + // Search through all document stylesheets + for (const stylesheet of document.styleSheets) { + try { + // Skip if we can't access the rules (e.g., cross-origin stylesheets) + if (!stylesheet.cssRules) continue; + + // Look through all rules in the stylesheet + for (const rule of stylesheet.cssRules) { + if (rule instanceof CSSStyleRule && rule.selectorText === cssSelector) { + const style = rule.style; + + // Get color if set + if (style.color) { + colors.color = style.color; + } + + // Get background-color if set + if (style.backgroundColor) { + colors.backgroundColor = style.backgroundColor; + } + + // Get border-color if set + if (style.borderColor) { + colors.borderColor = style.borderColor; + } + } + } + } catch (e) { + // Skip stylesheets we can't access + + } + } + + return colors; +} + +export const extractUniqueSelectors = (cssString) => { + // Remove line breaks and extra spaces to simplify parsing + const normalizedCss = cssString.replace(/\n/g, ' ').replace(/\s+/g, ' '); + + // Match all CSS selectors before curly braces, handling multiple selectors separated by commas + const selectorMatches = normalizedCss.match(/[^}]+?{/g) || []; + + // List of pseudo-classes we want to keep + const keepPseudoClasses = [':hover', ':checked', ':active', ':focus']; + + // Process each selector group + const allSelectors = selectorMatches + .map(match => { + // Remove the trailing curly brace and trim + const selectorGroup = match.slice(0, -1).trim(); + // Split by comma and trim each selector + return selectorGroup.split(',').map(s => s.trim()); + }) + .flat(); + + // Remove unwanted selectors and deduplicate + const uniqueSelectors = [...new Set(allSelectors)] + .filter(selector => { + if (!selector || + selector === ':root' || + selector.includes('@') || // Remove any @media or other @ rules + selector.includes('::') || // Remove pseudo-elements + selector === '*' // Remove universal selector + ) { + return false; + } + + // Check if selector contains any pseudo-class + if (selector.includes(':')) { + // Only keep selector if it contains one of our wanted pseudo-classes + return keepPseudoClasses.some(pseudo => selector.includes(pseudo)); + } + + return true; + }); + + return uniqueSelectors; +} + const createNightModeColorCss = () => { return ':root {' + getColors() .filter(color => color.name !== 'white' && color.name !== 'black' && color.name !== 'plan') @@ -291,4 +403,24 @@ export const createNightModeCss = () => { `.col-theme{--color-theme: var(--color-night-text-dark-bg)}` + `:root {--bs-heading-color:var(--color-night-text-dark-bg); --bs-card-color:var(--color-night-text-dark-bg); --bs-body-color:var(--color-night-text-dark-bg); --bs-body-bg:var(--color-night-dark-grey-blue); --bs-btn-active-border-color:var(--color-night-blue);}` + createNightModeColorCss() -} \ No newline at end of file +} + +export const getContrastColor = (hexcolor) => { + const hex = hexcolor.replace('#', ''); + if (hex.length === 6) { + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000000' : '#ffffff'; + } else { + const rLetter = hex.substring(0, 1); + const gLetter = hex.substring(1, 2); + const bLetter = hex.substring(2, 3); + const r = parseInt(rLetter + rLetter, 16); + const g = parseInt(gLetter + gLetter, 16); + const b = parseInt(bLetter + bLetter, 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000000' : '#ffffff'; + } +}; diff --git a/Plan/react/dashboard/src/util/mutator.js b/Plan/react/dashboard/src/util/mutator.js new file mode 100644 index 0000000000..47a7a04bda --- /dev/null +++ b/Plan/react/dashboard/src/util/mutator.js @@ -0,0 +1,27 @@ +// Function to flatten nested object into dot notation +export const flattenObject = (obj, prefix = '') => { + return Object.entries(obj).reduce((acc, [key, value]) => { + const newKey = prefix ? `${prefix}-${key}` : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + Object.assign(acc, flattenObject(value, newKey)); + } else if (!Array.isArray(value)) { + // Convert camelCase to kebab-case + const cssKey = newKey.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + acc[cssKey] = value; + } + return acc; + }, {}); +}; + +// Function to merge two objects recursively, with override taking precedence +export const mergeUseCases = (base, override) => { + const merged = {...base}; + for (const key in override) { + if (typeof override[key] === 'object' && !Array.isArray(override[key])) { + merged[key] = mergeUseCases(base[key] || {}, override[key]); + } else { + merged[key] = override[key]; + } + } + return merged; +}; \ No newline at end of file diff --git a/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx b/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx new file mode 100644 index 0000000000..f7cd932174 --- /dev/null +++ b/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx @@ -0,0 +1,323 @@ +import React, {useEffect, useRef, useState} from 'react'; +import Sidebar from "../../components/navigation/Sidebar"; +import Header from "../../components/navigation/Header"; +import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome"; +import {faPalette, faTimes} from "@fortawesome/free-solid-svg-icons"; +import {useTranslation} from "react-i18next"; +import theme from "../../theme.json"; +import SideNavTabs from "../../components/layout/SideNavTabs"; +import ColorSelectorModal from "../../components/modal/ColorSelectorModal"; +import {Card, Col, Dropdown, Form, Row} from "react-bootstrap"; +import CardHeader from "../../components/cards/CardHeader"; +import useCases from "../../useCases.json"; +import nightModeUseCases from "../../nightModeUseCases.json"; +import {ThemeStyleCss} from "../../components/theme/ThemeStyleCss"; +import {ColorBox} from '../../components/theme/ColorBox'; +import {mergeUseCases} from '../../util/mutator'; + +const ColorDropdown = ({colors, value, onChange, label, onRemoveOverride = null, marginLeft = 0}) => { + // Extract name from CSS variable or use first color as default + const selectedName = value?.replace('var(--col-', '').replace(')', '') || Object.keys(colors)[0]; + const isTextColor = selectedName.includes('text') || label.includes('Text'); + const cssColor = `var(--col-${selectedName})`; + const contrastColor = `var(--contrast-col-${selectedName})`; + const [isOpen, setIsOpen] = useState(false); + const selectedItemRef = useRef(null); + const dropdownMenuRef = useRef(null); + + useEffect(() => { + if (isOpen && selectedItemRef.current && dropdownMenuRef.current) { + setTimeout(() => { + const menuElement = dropdownMenuRef.current; + const selectedElement = selectedItemRef.current; + + // Check if the menu is positioned at the top using data-popper-placement + const isDropup = menuElement.getAttribute('data-popper-placement') === 'top-start'; + + if (isDropup) { + // For dropup, position the selected item at the bottom: + // Calculate how far from the bottom the item should be + const itemHeight = selectedElement.offsetHeight; + const menuHeight = menuElement.clientHeight; + const itemOffset = selectedElement.offsetTop; + + // Set scroll position to show the item at the bottom + menuElement.scrollTop = itemOffset - menuHeight + itemHeight; + } else { + // For dropdown, position at the top as before + menuElement.scrollTop = selectedElement.offsetTop; + } + }, 0); + } + }, [isOpen]); + + return ( + + + {label} + + + +
    + + + {selectedName} + + + + {['theme', ...Object.keys(colors)].map(name => { + const isItemTextColor = name.includes('text') || label.includes('Text'); + const isSelected = name === selectedName; + return ( + { + onChange?.(`var(--col-${name})`); + setIsOpen(false); + }} + style={{ + backgroundColor: isItemTextColor ? 'transparent' : `var(--col-${name})`, + color: isItemTextColor ? `var(--col-${name})` : `var(--contrast-col-${name})` + }} + ref={isSelected ? selectedItemRef : null} + > + {name} + + ); + })} + + +
    + {onRemoveOverride && ( + + )} +
    + + + ); +}; + +const formatLabel = (key) => { + // Convert camelCase to Title Case with spaces + return key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); +}; + +const UseCase = ({path, value, onChange, colors, isNightMode, baseValue, onRemoveOverride}) => { + const level = Math.max(0, path.length - 1); + + if (typeof value === 'string') { + const hasOverride = isNightMode && value !== baseValue; + return ( + onChange(newValue, path)} + label={formatLabel(path[path.length - 1])} + marginLeft={level * 20} + onRemoveOverride={hasOverride ? () => onRemoveOverride?.(path) : null} + /> + ); + } + + if (Array.isArray(value)) { + return null; + } + + return ( + <> + {typeof value === 'object' && !Array.isArray(value) && path.length > 0 && ( + + + {level === 0 &&
    } +
    + {formatLabel(path[path.length - 1])} +
    + + + )} + {Object.entries(value).map(([key, val]) => ( + + ))} + + ); +}; + +const UseCaseSection = ({useCases, colors, baseUseCases = null, isNightMode = false, onUpdate}) => { + // For night mode, we need to merge the base use cases with overrides + const mergedUseCases = isNightMode && baseUseCases ? mergeUseCases(baseUseCases, useCases) : useCases; + + const handleColorChange = (newValue, path) => { + const result = {...useCases}; + let current = result; + + for (let i = 0; i < path.length - 1; i++) { + current = current[path[i]]; + } + + current[path[path.length - 1]] = newValue; + onUpdate?.(result); + }; + + const handleRemoveOverride = (path) => { + // Create a new object without the override, but maintain structure + const removeOverride = (obj, pathArr) => { + if (pathArr.length === 0) return obj; + + const [current, ...rest] = pathArr; + const result = {...obj}; + + if (rest.length === 0) { + // We've reached the target property, remove it + delete result[current]; + // If the parent object becomes empty, return null to signal removal + return Object.keys(result).length === 0 ? null : result; + } + + // Continue traversing + const nested = removeOverride(obj[current] || {}, rest); + if (nested === null) { + delete result[current]; + return Object.keys(result).length === 0 ? null : result; + } + result[current] = nested; + return result; + }; + + // Get the new state without the override + const newState = removeOverride(useCases, path) || {}; + + // Update parent + onUpdate?.(newState); + }; + + return ( +
    +
    {isNightMode ? 'Night mode overrides' : 'Use Cases'}
    + + + + +
    +
    + ); +}; + +const ColorSection = ({title, colors}) => ( +
    +
    {title}
    +
    + {Object.entries(colors).map(([name, color]) => ( + + ))} +
    +
    +); + +const ThemeEditorPage = () => { + const {t} = useTranslation(); + const backgroundColors = theme.colors; + const [currentUseCases, setCurrentUseCases] = useState(useCases); + const [currentNightModeUseCases, setCurrentNightModeUseCases] = useState(nightModeUseCases); + useEffect(() => { + setCurrentUseCases(useCases); + }, [useCases]); + useEffect(() => { + setCurrentNightModeUseCases(nightModeUseCases); + }, [nightModeUseCases]); + + const colorSlices = [ + { + header:
    + Colors +
    , + body: <> + + +
    + + + + + + + + + + } + ]; + + return ( + <> + + +
    +
    +
    +
    + + + + + + +
    + +
    +
    + + ); +}; + +export default ThemeEditorPage; \ No newline at end of file From 0c9c6e59566aa3cd2422e462fda3ebcc0b0b96c1 Mon Sep 17 00:00:00 2001 From: Aurora Lahtela <24460436+AuroraLS3@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:51:02 +0300 Subject: [PATCH 02/57] Further implementation - Take most colors into use in css - Add some missing colors - Translations - Color CRUD functionalities - Use case examples - Reimplement color translation functions to use class based system - Fixes to color translation functions --- .../plan/settings/locale/lang/HtmlLang.java | 13 + Plan/react/dashboard/src/App.jsx | 1 + .../dashboard/src/components/CardTabs.jsx | 2 +- .../dashboard/src/components/Datapoint.jsx | 2 +- .../components/accordion/ServerAccordion.jsx | 27 +- .../components/accordion/SessionAccordion.jsx | 22 +- .../src/components/cards/CardHeader.jsx | 2 +- .../cards/common/AddressGroupCard.jsx | 2 +- .../cards/common/GeolocationsCard.jsx | 2 +- .../cards/common/InsightsFor30DaysCard.jsx | 4 +- .../components/cards/common/PingTableCard.jsx | 2 +- .../cards/common/PlayerListCard.jsx | 4 +- .../cards/common/PlayerRetentionGraphCard.jsx | 2 +- .../cards/common/PluginCurrentCard.jsx | 2 +- .../cards/common/PluginHistoryCard.jsx | 2 +- .../cards/common/PvpKillsTableCard.jsx | 4 +- .../cards/common/RecentSessionsCard.jsx | 4 +- .../components/cards/common/ServerPieCard.jsx | 2 +- .../components/cards/common/WorldPieCard.jsx | 4 +- .../cards/network/QuickViewDataCard.jsx | 16 +- .../cards/network/QuickViewGraphCard.jsx | 2 +- .../cards/network/ServersTableCard.jsx | 4 +- .../cards/player/ConnectionsCard.jsx | 4 +- .../components/cards/player/NicknamesCard.jsx | 4 +- .../cards/player/PlayerOverviewCard.jsx | 40 +- .../cards/player/PvpPveAsNumbersCard.jsx | 4 +- .../cards/query/SessionsWithinViewCard.jsx | 20 +- .../server/graphs/CurrentPlayerbaseCard.jsx | 5 +- .../server/graphs/JoinAddressGraphCard.jsx | 7 +- .../server/graphs/OnlineActivityCard.jsx | 4 +- .../graphs/PlayerbaseDevelopmentCard.jsx | 6 +- .../insights/OnlineActivityInsightsCard.jsx | 10 +- .../insights/PerformanceInsightsCard.jsx | 11 +- .../server/insights/PvpPveInsightsCard.jsx | 6 +- .../server/insights/SessionInsightsCard.jsx | 8 +- .../tables/AllowlistBounceTableCard.jsx | 4 +- .../tables/OnlineActivityAsNumbersCard.jsx | 4 +- .../tables/PerformanceAsNumbersCard.jsx | 2 +- .../server/tables/PlayerbaseTrendsCard.jsx | 19 +- .../server/tables/PvpPveAsNumbersCard.jsx | 4 +- .../tables/ServerWeekComparisonCard.jsx | 18 +- .../server/values/ServerAsNumbersCard.jsx | 26 +- .../components/datapoint/CurrentUptime.jsx | 2 +- .../components/extensions/ExtensionCard.jsx | 4 +- .../src/components/navigation/Loader.jsx | 2 +- .../navigation/PageNavigationItem.jsx | 2 +- .../src/components/navigation/Sidebar.jsx | 8 +- .../table/OnlineActivityAsNumbersTable.jsx | 21 +- .../table/PerformanceAsNumbersTable.jsx | 22 +- .../table/PlayerPvpPveAsNumbersTable.jsx | 14 +- .../table/ServerPvpPveAsNumbersTable.jsx | 12 +- .../src/components/theme/ColorBox.jsx | 63 +- .../src/components/theme/ColorDropdown.jsx | 124 +++ .../src/components/theme/ColorEditForm.jsx | 113 +++ .../src/components/theme/ColorSection.jsx | 15 + .../src/components/theme/ExampleSection.jsx | 57 ++ .../src/components/theme/ThemeStyleCss.jsx | 59 +- .../theme/usecase/CalendarUseCase.jsx | 36 + .../components/theme/usecase/CardUseCase.jsx | 28 + .../theme/usecase/DataCalculatedUseCase.jsx | 30 + .../theme/usecase/DataPerformanceUseCase.jsx | 35 + .../theme/usecase/DataPlayUseCase.jsx | 27 + .../theme/usecase/DataPlayerStatusUseCase.jsx | 24 + .../theme/usecase/DataPlayerVersusUseCase.jsx | 24 + .../theme/usecase/DataPlayersUseCase.jsx | 28 + .../components/theme/usecase/DataUseCase.jsx | 23 + .../theme/usecase/InfoBoxUseCase.jsx | 14 + .../theme/usecase/SidebarUseCase.jsx | 34 + .../components/theme/usecase/TrendUseCase.jsx | 34 + .../src/components/trend/BigTrend.jsx | 14 +- .../src/components/trend/SmallTrend.jsx | 10 +- .../hooks/context/colorEditContextHook.jsx | 76 ++ .../src/hooks/interaction/hoverHook.jsx | 27 + .../dashboard/src/nightModeUseCases.json | 79 +- .../dashboard/src/style/default-colors.css | 750 ++++++++++++++++++ Plan/react/dashboard/src/style/main.sass | 2 +- Plan/react/dashboard/src/style/sb-admin-2.css | 33 +- Plan/react/dashboard/src/style/style.css | 171 +--- Plan/react/dashboard/src/theme.json | 18 +- Plan/react/dashboard/src/useCases.json | 241 +++--- Plan/react/dashboard/src/util/Color.js | 193 +++++ Plan/react/dashboard/src/util/colors.js | 107 ++- Plan/react/dashboard/src/views/ErrorView.jsx | 2 +- .../src/views/layout/ThemeEditorPage.jsx | 285 +++---- .../src/views/network/NetworkOverview.jsx | 14 +- .../src/views/network/NetworkPerformance.jsx | 2 +- .../src/views/player/PlayerOverview.jsx | 20 +- .../src/views/player/PlayerPvpPve.jsx | 14 +- .../src/views/player/PlayerServers.jsx | 12 +- .../src/views/player/PlayerSessions.jsx | 4 +- .../src/views/server/ServerOverview.jsx | 16 +- 91 files changed, 2456 insertions(+), 820 deletions(-) create mode 100644 Plan/react/dashboard/src/components/theme/ColorDropdown.jsx create mode 100644 Plan/react/dashboard/src/components/theme/ColorEditForm.jsx create mode 100644 Plan/react/dashboard/src/components/theme/ColorSection.jsx create mode 100644 Plan/react/dashboard/src/components/theme/ExampleSection.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/CalendarUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/CardUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataCalculatedUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataPerformanceUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataPlayUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataPlayerStatusUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataPlayerVersusUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataPlayersUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/DataUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/InfoBoxUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/SidebarUseCase.jsx create mode 100644 Plan/react/dashboard/src/components/theme/usecase/TrendUseCase.jsx create mode 100644 Plan/react/dashboard/src/hooks/context/colorEditContextHook.jsx create mode 100644 Plan/react/dashboard/src/hooks/interaction/hoverHook.jsx create mode 100644 Plan/react/dashboard/src/style/default-colors.css create mode 100644 Plan/react/dashboard/src/util/Color.js diff --git a/Plan/common/src/main/java/com/djrapitops/plan/settings/locale/lang/HtmlLang.java b/Plan/common/src/main/java/com/djrapitops/plan/settings/locale/lang/HtmlLang.java index 92c82c317e..44087c7b44 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/settings/locale/lang/HtmlLang.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/settings/locale/lang/HtmlLang.java @@ -442,6 +442,19 @@ public enum HtmlLang implements Lang { MANAGE_ALERT_SAVE_FAIL("html.label.managePage.alert.saveFail", "Failed to save changes: {{error}}"), MANAGE_ALERT_SAVE_SUCCESS("html.label.managePage.alert.saveSuccess", "Changes saved successfully!"), + THEME_EDITOR_TITLE("html.label.themeEditor.title", "Theme Editor"), + THEME_EDITOR_COLORS("html.label.themeEditor.colors", "Colors"), + THEME_EDITOR_NIGHT_COLORS("html.label.themeEditor.nightColors", "Night mode"), + THEME_EDITOR_USE_CASES("html.label.themeEditor.useCases", "Use cases"), + THEME_EDITOR_NIGHT_MODE_OVERRIDES("html.label.themeEditor.nightModeOverrides", "Night mode overrides"), + THEME_EDITOR_EXAMPLE("html.label.themeEditor.example", "Example"), + THEME_EDITOR_ADD_COLOR("html.label.themeEditor.addColor", "Add color"), + THEME_EDITOR_DELETE_COLORS("html.label.themeEditor.deleteColors", "Delete colors"), + THEME_EDITOR_FINISH("html.label.themeEditor.finish", "Finish"), + THEME_EDITOR_ALREADY_EXISTS_WARNING("html.label.themeEditor.alreadyExistsWarning", "Color with that name already exists - It will be overridden!"), + THEME_EDITOR_MISSING("html.label.themeEditor.missing", "Missing color"), + THEME_EDITOR_REMOVE_OVERRIDE("html.label.themeEditor.removeOverride", "Remove night mode override"), + INFO_NO_UPTIME("html.description.noUptimeCalculation", "Server is offline, or has never restarted with Plan installed."), WARNING_NO_GAME_SERVERS("html.description.noGameServers", "Some data requires Plan to be installed on game servers."), WARNING_PERFORMANCE_NO_GAME_SERVERS("html.description.performanceNoGameServers", "TPS, Entity or Chunk data is not gathered from proxy servers since they don't have game tick loop."), diff --git a/Plan/react/dashboard/src/App.jsx b/Plan/react/dashboard/src/App.jsx index ff5e2e9064..145b9731ce 100644 --- a/Plan/react/dashboard/src/App.jsx +++ b/Plan/react/dashboard/src/App.jsx @@ -1,5 +1,6 @@ import './style/main.sass'; import './style/sb-admin-2.css' +import './style/default-colors.css'; import './style/style.css'; import './style/mobile.css'; import 'react-bootstrap-range-slider/dist/react-bootstrap-range-slider.css'; diff --git a/Plan/react/dashboard/src/components/CardTabs.jsx b/Plan/react/dashboard/src/components/CardTabs.jsx index b9eaf2e9bf..a5e558bf47 100644 --- a/Plan/react/dashboard/src/components/CardTabs.jsx +++ b/Plan/react/dashboard/src/components/CardTabs.jsx @@ -6,7 +6,7 @@ const TabButton = ({id, name, href, icon, color, active, disabled}) => { const navigate = useNavigate(); return (
  • - -

    +
    -

    +
    ) diff --git a/Plan/react/dashboard/src/hooks/context/colorEditContextHook.jsx b/Plan/react/dashboard/src/hooks/context/colorEditContextHook.jsx index 3db12b1996..b591da2ba2 100644 --- a/Plan/react/dashboard/src/hooks/context/colorEditContextHook.jsx +++ b/Plan/react/dashboard/src/hooks/context/colorEditContextHook.jsx @@ -34,7 +34,7 @@ export const ColorEditContextProvider = ({colors, saveFunction, deleteFunction, if (name.length) { saveFunction(name, color, previous); } else { - saveFunction('new-color', color, previous); + saveFunction('new-color-' + Math.floor(Math.random() * 1000), color, previous); } discardEdit(); } diff --git a/Plan/react/dashboard/src/hooks/context/themeContextHook.jsx b/Plan/react/dashboard/src/hooks/context/themeContextHook.jsx index 69561527ff..05c6343c96 100644 --- a/Plan/react/dashboard/src/hooks/context/themeContextHook.jsx +++ b/Plan/react/dashboard/src/hooks/context/themeContextHook.jsx @@ -1,4 +1,4 @@ -import {createContext, useContext, useEffect, useState} from "react"; +import {createContext, useContext, useEffect, useMemo, useState} from "react"; import {useTheme} from "../themeHook.jsx"; import useCases from "../../useCases.json"; import nightModeUseCases from "../../nightModeUseCases.json"; @@ -24,9 +24,11 @@ export const ThemeStorageContextProvider = ({children}) => { setCurrentNightModeUseCases(nightModeUseCases); }, [nightModeUseCases]); - const sharedState = { - name, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases - }; + const sharedState = useMemo(() => { + return { + name, setName, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases + } + }, [name, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases]); return ( {children} diff --git a/Plan/react/dashboard/src/hooks/context/themeEditContextHook.jsx b/Plan/react/dashboard/src/hooks/context/themeEditContextHook.jsx index 4963688d32..94101573ef 100644 --- a/Plan/react/dashboard/src/hooks/context/themeEditContextHook.jsx +++ b/Plan/react/dashboard/src/hooks/context/themeEditContextHook.jsx @@ -1,24 +1,32 @@ import {createContext, useContext, useMemo, useState} from "react"; import {useThemeStorage} from "./themeContextHook.jsx"; -import {nameToCssVariable} from "../../util/colors.js"; -import {recursiveFindAndReplaceValue} from "../../util/mutator.js"; +import {cssVariableToName, nameToCssVariable} from "../../util/colors.js"; +import {flattenObject, recursiveFindAndReplaceValue} from "../../util/mutator.js"; +import {useTranslation} from "react-i18next"; const ThemeEditContext = createContext({}); export const ThemeEditContextProvider = ({children}) => { + const {t} = useTranslation(); const [edits, setEdits] = useState([]); const [redos, setRedos] = useState([]); - const {name, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases} = useThemeStorage(); + const { + name, + setName, + currentColors, + currentNightColors, + currentUseCases, + currentNightModeUseCases + } = useThemeStorage(); const applyEdits = (type, object) => { - console.group('Applying edits', edits.length) + console.debug('Applying edits', edits.length) let result = object; const applicable = edits.filter(edit => edit.type.includes(type)); for (let applicableEdit of applicable) { - console.log('Applying', applicableEdit.name, 'to', applicableEdit.type) + console.debug('Applying', applicableEdit.name, 'to', applicableEdit.type) result = applicableEdit.operation(result, type); } - console.groupEnd(); return result; } @@ -36,17 +44,34 @@ export const ThemeEditContextProvider = ({children}) => { } const redo = () => { - addEdit(redos[redos.length - 1]); + const toRedo = redos[redos.length - 1]; + if (toRedo.length) { + toRedo.forEach(edit => { + addEdit(edit) + }) + } else { + addEdit(toRedo); + } setRedos(redos.slice(0, -1)); } + const discardChanges = () => { + if (!edits.length) { + setRedos([]); + } else { + const undone = [...edits]; + setEdits([]); + setRedos(prev => [...prev, undone]); + } + } + const updateUseCaseColorName = (current, oldName, newName) => { const oldVariable = nameToCssVariable(oldName); const newVariable = nameToCssVariable(newName); return recursiveFindAndReplaceValue(current, oldVariable, newVariable); } - const handleColorSave = (current, setFunction) => (name, color, previous) => { + const handleColorSave = (current) => (name, color, previous) => { const newObj = {}; for (const [key, value] of Object.entries(current)) { if (key === previous) { @@ -61,10 +86,10 @@ export const ThemeEditContextProvider = ({children}) => { return newObj; } const saveColor = (name, color, previous) => { - const renamed = name !== previous; + const renamed = previous && name !== previous; if (renamed) { addEdit({ - name: 'rename-edit-color-' + previous + '-to-' + name, + name: t('html.label.themeEditor.changes.renameColor', {previous, name, color}), type: 'color,useCase,nightModeUseCase', operation: (current, type) => { if (type === 'color') { return handleColorSave(current)(name, color, previous); @@ -75,7 +100,10 @@ export const ThemeEditContextProvider = ({children}) => { }) } else { addEdit({ - name: 'edit-color-' + name, + name: t(previous ? 'html.label.themeEditor.changes.setColor' : 'html.label.themeEditor.changes.addColor', { + name, + color + }), type: 'color', operation: (current) => handleColorSave(current)(name, color, previous) }) } @@ -84,7 +112,7 @@ export const ThemeEditContextProvider = ({children}) => { const renamed = name !== previous; if (renamed) { addEdit({ - name: 'rename-edit-color-' + previous + '-to-' + name, + name: t('html.label.themeEditor.changes.renameColor', {previous, name, color}), type: 'nightColor,nightModeUseCase', operation: (current, type) => { if (type === 'nightColor') { return handleColorSave(current)(name, color, previous); @@ -95,7 +123,10 @@ export const ThemeEditContextProvider = ({children}) => { }) } else { addEdit({ - name: 'edit-color-' + name, + name: t(previous ? 'html.label.themeEditor.changes.setColor' : 'html.label.themeEditor.changes.addColor', { + name, + color + }), type: 'nightColor', operation: (current) => handleColorSave(current)(name, color, previous) }) } @@ -107,12 +138,12 @@ export const ThemeEditContextProvider = ({children}) => { return copy; } const deleteColor = name => addEdit({ - name: 'delete-color-' + name, + name: t('html.label.themeEditor.changes.deleteColor', {name}), type: 'color', operation: current => handleDelete(current)(name) }) const deleteNightColor = name => addEdit({ - name: 'delete-color-' + name, + name: t('html.label.themeEditor.changes.deleteColor', {name}), type: 'nightColor', operation: current => handleDelete(current)(name) }) @@ -157,28 +188,63 @@ export const ThemeEditContextProvider = ({children}) => { return removeOverride(current, path) || {}; }; const updateUseCase = (newValue, path) => addEdit({ - name: 'update-use-case(' + path + '): ' + newValue, + name: t('html.label.themeEditor.changes.changeUseCase', { + path: path.join('.'), + name: cssVariableToName(newValue) + }), type: 'useCase', operation: current => handleColorChange(current, newValue, path) }); const updateNightUseCase = (newValue, path) => addEdit({ - name: 'update-use-case(' + path + '): ' + newValue, + name: t('html.label.themeEditor.changes.changeNightMode', { + path: path.join('.'), + name: cssVariableToName(newValue) + }), type: 'nightModeUseCase', operation: current => handleColorChange(current, newValue, path) }); const removeNightOverride = (path) => addEdit({ - name: 'delete-use-case(' + path + ')', + name: t('html.label.themeEditor.changes.removeNightMode', {path: path.join('.')}), type: 'nightModeUseCase', operation: current => handleRemoveOverride(current, path) }); const sharedState = useMemo(() => { + const editedColors = applyEdits('color', currentColors); + const editedNightColors = applyEdits('nightColor', currentNightColors); + const editedUseCases = applyEdits('useCase', currentUseCases); + const editedNightModeUseCases = applyEdits('nightModeUseCase', currentNightModeUseCases); + + const issues = []; + + const allColorsExist = () => { + const referenceColors = Object.keys(editedUseCases.referenceColors) + const colorMissing = name => { + const exists = editedColors[name] || editedNightColors[name] || referenceColors.includes(name); + if (!exists) console.warn(name, "doesn't exist on color maps") + return !exists; + } + const missingUseCase = Object.entries(flattenObject(editedUseCases)) + .filter(e => colorMissing(cssVariableToName(e[1]))); + const missingNightModeUseCase = Object.entries(flattenObject(editedNightModeUseCases)) + .filter(e => colorMissing(cssVariableToName(e[1]))); + + missingUseCase.forEach(e => issues.push( + t('html.label.themeEditor.issues.missingUseCase', {name: e[0], colorName: cssVariableToName(e[1])}))); + missingNightModeUseCase.forEach(e => issues.push( + t('html.label.themeEditor.issues.missingNightCase', {name: e[0], colorName: cssVariableToName(e[1])}))); + + return !missingUseCase.length && !missingNightModeUseCase.length + } + + const savePossible = edits.length > 0 && allColorsExist() + return { - name, - currentColors: applyEdits('color', currentColors), - currentNightColors: applyEdits('nightColor', currentNightColors), - currentUseCases: applyEdits('useCase', currentUseCases), - currentNightModeUseCases: applyEdits('nightModeUseCase', currentNightModeUseCases), + name, setName, + currentColors: editedColors, + currentNightColors: editedNightColors, + currentUseCases: editedUseCases, + currentNightModeUseCases: editedNightModeUseCases, editCount: edits.length, redoCount: redos.length, deleteColor, @@ -189,9 +255,14 @@ export const ThemeEditContextProvider = ({children}) => { updateNightUseCase, removeNightOverride, undo, - redo + redo, + discardChanges, + edits, + redos, + issues, + savePossible } - }, [edits]); + }, [edits, name]); return ( {children} diff --git a/Plan/react/dashboard/src/style/main.sass b/Plan/react/dashboard/src/style/main.sass index 461bb9cc78..ca59fc6896 100644 --- a/Plan/react/dashboard/src/style/main.sass +++ b/Plan/react/dashboard/src/style/main.sass @@ -126,5 +126,42 @@ p, span, td, .h3, a, button .editor-toast position: fixed - top: 5rem - right: 1.7rem \ No newline at end of file + top: 8.5rem + right: 1.9rem + transition: top 0.5s + z-index: 100 + width: 22.2rem + --bs-toast-bg: var(--color-cards-background) + + &.scrolled + top: 1em + +.disabled-feedback + width: 100% + margin-top: 0.25rem + font-size: 0.875em + color: var(--color-secondary) + +.edit-history + margin: 0 + margin-top: 1rem + padding-left: 0 + list-style: none + + .edit + color: var(--color-text) + + .redo + color: color-mix(in srgb, var(--color-text), transparent 50%) + + &.nested + margin-left: 1rem + +.issues + margin: 0 + margin-top: 1rem + padding-left: 0 + list-style: none + + .issue + color: var(--color-danger) \ No newline at end of file diff --git a/Plan/react/dashboard/src/style/style.css b/Plan/react/dashboard/src/style/style.css index d8d9aacb3d..ee5a0664ea 100644 --- a/Plan/react/dashboard/src/style/style.css +++ b/Plan/react/dashboard/src/style/style.css @@ -1403,3 +1403,7 @@ ul.filters { .link:hover { color: var(--bs-link-hover-color) } + +.card-header .btn.float-end { + margin-top: -0.5rem; +} \ No newline at end of file diff --git a/Plan/react/dashboard/src/util/Color.js b/Plan/react/dashboard/src/util/Color.js index 78f2801dc6..1684bedfa3 100644 --- a/Plan/react/dashboard/src/util/Color.js +++ b/Plan/react/dashboard/src/util/Color.js @@ -15,11 +15,15 @@ import { } from "./colors.js"; export const getColorConverter = color => { - if (typeof color === 'string') { - if (color.startsWith('#')) return new HexColor(color); - if (color.startsWith("rgb")) return new RgbaColor(color); - if (color.startsWith("hsl")) return new HslaColor(color); - if (color.startsWith("hsv")) return new HsvColor(color); + try { + if (typeof color === 'string') { + if (color.startsWith('#')) return new HexColor(color); + if (color.startsWith("rgb(") || color.startsWith("rgba(") && color.endsWith(')')) return new RgbaColor(color); + if (color.startsWith("hsl(") || color.startsWith("hsla(") && color.endsWith(')')) return new HslaColor(color); + if (color.startsWith("hsv(") && color.endsWith(')')) return new HsvColor(color); + } + } catch (e) { + console.warn('failed to parse color', color, e); } return undefined; } diff --git a/Plan/react/dashboard/src/util/colors.js b/Plan/react/dashboard/src/util/colors.js index 6f0ffa4f67..2aa820cb89 100644 --- a/Plan/react/dashboard/src/util/colors.js +++ b/Plan/react/dashboard/src/util/colors.js @@ -245,7 +245,7 @@ export const rgbaStringToArray = (rgbaString) => { Number(split[0].trim()), Number(split[1].trim()), Number(split[2].trim()), - Number(split[3].trim()) + split.length === 4 ? Number(split[3].trim()) : 1 ]; } diff --git a/Plan/react/dashboard/src/views/layout/ServerPage.jsx b/Plan/react/dashboard/src/views/layout/ServerPage.jsx index a567b000c6..99e68f5c4a 100644 --- a/Plan/react/dashboard/src/views/layout/ServerPage.jsx +++ b/Plan/react/dashboard/src/views/layout/ServerPage.jsx @@ -211,25 +211,23 @@ const ServerPage = () => { } return ( - <> - - -
    -
    -
    -
    - - - -
    - -
    + + +
    +
    +
    +
    + + + +
    +
    - - +
    +
    ) } diff --git a/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx b/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx index 7eed56e9b7..c0e399f7d1 100644 --- a/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx +++ b/Plan/react/dashboard/src/views/layout/ThemeEditorPage.jsx @@ -1,48 +1,20 @@ -import React, {useState} from 'react'; +import React from 'react'; import Sidebar from "../../components/navigation/Sidebar"; import Header from "../../components/navigation/Header"; -import {faFileSignature, faPalette} from "@fortawesome/free-solid-svg-icons"; import {useTranslation} from "react-i18next"; import ColorSelectorModal from "../../components/modal/ColorSelectorModal"; -import {Card, Col, Row} from "react-bootstrap"; -import CardHeader from "../../components/cards/CardHeader"; import {ThemeStyleCss} from "../../components/theme/ThemeStyleCss"; -import ExampleSection from "../../components/theme/ExampleSection.jsx"; -import ColorSection from "../../components/theme/ColorSection.jsx"; -import {ColorEditContextProvider} from "../../hooks/context/colorEditContextHook.jsx"; -import ColorEditForm from "../../components/theme/ColorEditForm.jsx"; -import UseCaseSection from "../../components/theme/UseCaseSection.jsx"; -import TextInput from "../../components/input/TextInput.jsx"; import {useThemeEditContext} from "../../hooks/context/themeEditContextHook.jsx"; -import EditorMenuToast from "../../components/theme/EditorMenuToast.jsx"; +import {SwitchTransition} from "react-transition-group"; +import {Outlet} from "react-router-dom"; const ThemeEditorPage = () => { const {t} = useTranslation(); const { - name, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases, - deleteColor, - deleteNightColor, - saveColor, - saveNightColor, - updateUseCase, - updateNightUseCase, - removeNightOverride + name, currentColors, currentNightColors, currentUseCases, currentNightModeUseCases } = useThemeEditContext(); - const [hoveredItem, setHoveredItem] = useState(undefined); - const [nightHover, setNightHover] = useState(false); - const onHoverChange = (id, state, night) => { - if (state === 'enter') { - setHoveredItem(id); - setNightHover(night); - } - } - - const referenceColors = currentUseCases.referenceColors; - const nightReferenceColors = currentNightModeUseCases.referenceColors; const title = t("html.label.themeEditor.title"); - const colors = {...referenceColors, '-': "", ...currentColors}; - const nightColors = {...nightReferenceColors, '-': "", ...currentNightColors, ...colors}; return ( <> {
    - - - - - onHoverChange(undefined, 'enter', false)} className={'mb-4'}> - -
    {t('html.label.themeEditor.themeName')}
    - !newValue.length || newValue.length > 100} - invalidFeedback={t('html.label.themeEditor.invalidName')} - placeholder={t('html.label.themeEditor.themeName')} - value={name} - setValue={newValue => setName(newValue)} - /> - -
    - onHoverChange(undefined, 'enter', false)}> - - - - - - - - - - - -
    - - - - - - - - - - - - -
    - -
    + + +