diff --git a/.stylelintrc.json b/.stylelintrc.json index 9a153673..5c94ba04 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,17 +1,19 @@ { - "customSyntax": "postcss-styled-syntax", "extends": [ "stylelint-config-standard", - "stylelint-config-styled-components", + "stylelint-config-css-modules", "stylelint-config-recess-order" ], "rules": { - "declaration-empty-line-before": null, - "declaration-block-no-redundant-longhand-properties": null, - "selector-class-pattern": null, + "block-no-empty": null, "selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }], "selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }], - "declaration-colon-newline-after": null, - "property-no-vendor-prefix": null + "declaration-block-no-shorthand-property-overrides": null, + "declaration-block-no-redundant-longhand-properties": null, + "at-rule-no-unknown": [true, { "ignoreAtRules": ["mixin"] }], + "function-no-unknown": [true, { "ignoreFunctions": ["darken", "alpha", "lighten"] }], + "declaration-property-value-no-unknown": null, + "no-descending-specificity": null, + "no-empty-source": null } } diff --git a/.vscode/settings.json b/.vscode/settings.json index 808acd7e..f524cf9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,10 +26,6 @@ "source.formatDocument": "explicit" }, "css.validate": true, - "less.validate": false, - "scss.validate": true, - "scss.lint.unknownAtRules": "warning", - "scss.lint.unknownProperties": "warning", "javascript.validate.enable": false, "javascript.format.enable": false, "typescript.format.enable": false, @@ -49,8 +45,14 @@ "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"], "typescript.tsdk": "node_modules\\typescript\\lib", "typescript.preferences.importModuleSpecifier": "non-relative", - "stylelint.validate": ["css", "scss", "typescript", "typescriptreact"], + "stylelint.config": null, + "stylelint.validate": ["css", "postcss"], "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.preferences.autoImportFileExcludePatterns": [ + "@mantine/core", + "@mantine/modals", + "@mantine/dates" + ], "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true, "folderTemplates.structures": [ @@ -63,14 +65,14 @@ "template": "Functional Component with CSS Modules" }, { - "fileName": ".module.scss" + "fileName": ".module.css" } ] } ], "folderTemplates.fileTemplates": { "Functional Component with CSS Modules": [ - "import styles from './.module.scss';", + "import styles from './.module.css';", "", "interface Props {}", "", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 30ce24bd..10307350 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -47,7 +47,7 @@ const config: UserConfig = { renderer: { css: { modules: { - generateScopedName: '[name]__[local]__[hash:base64:5]', + generateScopedName: 'fs-[name]-[local]', localsConvention: 'camelCase', }, }, diff --git a/eslint.config.mjs b/eslint.config.mjs index 04a62ef9..87ecdc94 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,6 +46,7 @@ export default tseslint.config( 'react-refresh/only-export-components': 'off', 'react/display-name': 'off', semi: ['error', 'always'], + 'single-attribute-per-line': 'off', }, }, eslintConfigPrettier, diff --git a/package.json b/package.json index 1a41e677..ad6b7527 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,12 @@ "dev:watch": "electron-vite dev --watch", "i18next": "i18next -c src/i18n/i18next-parser.config.js", "postinstall": "electron-builder install-app-deps", - "lint": "eslint --cache .", - "lint:fix": "eslint --cache --fix .", + "lint": "pnpm run lint-code && pnpm run lint-styles", + "lint-code": "eslint --cache .", + "lint-code:fix": "eslint --cache --fix .", + "lint-styles": "stylelint 'src/**/*.{css,scss}'", + "lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix", + "lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix", "package": "pnpm run build && electron-builder", "package:dev": "pnpm run build && electron-builder --dir", "package:linux": "pnpm run build && electron-builder --linux", @@ -54,16 +58,18 @@ "@ag-grid-community/infinite-row-model": "^28.2.1", "@ag-grid-community/react": "^28.2.1", "@ag-grid-community/styles": "^28.2.1", + "@atlaskit/pragmatic-drag-and-drop": "1.4.0", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", - "@emotion/react": "^11.10.4", - "@mantine/core": "^6.0.22", - "@mantine/dates": "^6.0.22", - "@mantine/form": "^6.0.22", - "@mantine/hooks": "^6.0.22", - "@mantine/modals": "^6.0.22", - "@mantine/notifications": "^6.0.22", - "@mantine/utils": "^6.0.22", + "@mantine/colors-generator": "^8.1.1", + "@mantine/core": "^8.1.1", + "@mantine/dates": "^8.1.1", + "@mantine/form": "^8.1.1", + "@mantine/hooks": "^8.1.1", + "@mantine/modals": "^8.1.1", + "@mantine/notifications": "^8.1.1", "@tanstack/react-query": "^4.32.1", "@tanstack/react-query-devtools": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1", @@ -84,7 +90,6 @@ "electron-updater": "^6.3.9", "fast-average-color": "^9.3.0", "format-duration": "^2.0.0", - "framer-motion": "^11.0.0", "fuse.js": "^6.6.2", "i18next": "^21.10.0", "idb-keyval": "^6.2.1", @@ -93,43 +98,46 @@ "lodash": "^4.17.21", "md5": "^2.3.0", "memoize-one": "^6.0.0", + "motion": "^12.18.1", "mpris-service": "^2.1.2", "nanoid": "^3.3.3", "node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f", "overlayscrollbars": "^2.11.1", "overlayscrollbars-react": "^0.5.6", "qs": "^6.14.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "react-error-boundary": "^3.1.4", "react-i18next": "^11.18.6", - "react-icons": "^4.10.1", + "react-icons": "^5.5.0", + "react-image": "^4.1.0", + "react-loading-skeleton": "^3.5.0", "react-player": "^2.11.0", "react-router": "^6.16.0", "react-router-dom": "^6.16.0", - "react-simple-img": "^3.0.0", "react-virtualized-auto-sizer": "^1.0.17", "react-window": "^1.8.9", "react-window-infinite-loader": "^1.0.9", "semver": "^7.5.4", - "styled-components": "^6.0.8", "swiper": "^9.3.1", + "use-sync-external-store": "^1.5.0", "ws": "^8.18.2", "zod": "^3.22.3", - "zustand": "^4.3.9" + "zustand": "^5.0.5" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", "@types/electron-localshortcut": "^3.1.0", - "@types/lodash": "^4.14.188", - "@types/md5": "^2.3.2", - "@types/node": "^22.14.1", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.1", + "@types/lodash": "^4.17.18", + "@types/md5": "^2.3.5", + "@types/node": "^22.15.32", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@types/react-window": "^1.8.5", "@types/react-window-infinite-loader": "^1.0.6", "@types/source-map-support": "^0.5.10", - "@types/styled-components": "^5.1.26", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^7.1.0", @@ -145,20 +153,15 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "i18next-parser": "^9.0.2", - "postcss-styled-syntax": "^0.5.0", + "postcss-preset-mantine": "^1.17.0", "prettier": "^3.5.3", "prettier-plugin-packagejson": "^2.5.14", - "react": "^18.3.1", - "react-dom": "^18.3.1", "sass-embedded": "^1.89.0", - "stylelint": "^15.10.3", - "stylelint-config-css-modules": "^4.3.0", - "stylelint-config-recess-order": "^4.3.0", - "stylelint-config-standard": "^34.0.0", - "stylelint-config-standard-scss": "^4.0.0", - "stylelint-config-styled-components": "^0.1.1", + "stylelint": "^16.14.1", + "stylelint-config-css-modules": "^4.4.0", + "stylelint-config-recess-order": "^7.1.0", + "stylelint-config-standard": "^38.0.0", "typescript": "^5.8.3", - "typescript-plugin-styled-components": "^3.0.0", "vite": "^6.3.5", "vite-plugin-conditional-import": "^0.1.7", "vite-plugin-dynamic-import": "^1.6.0", @@ -166,7 +169,9 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "abstract-socket", "electron", + "electron-winstaller", "esbuild" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86e0bb2f..7a81f2eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,52 +19,58 @@ importers: version: 28.2.1 '@ag-grid-community/react': specifier: ^28.2.1 - version: 28.2.1(@ag-grid-community/core@28.2.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 28.2.1(@ag-grid-community/core@28.2.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@ag-grid-community/styles': specifier: ^28.2.1 version: 28.2.1 + '@atlaskit/pragmatic-drag-and-drop': + specifier: 1.4.0 + version: 1.4.0 + '@atlaskit/pragmatic-drag-and-drop-auto-scroll': + specifier: ^2.1.0 + version: 2.1.1 + '@atlaskit/pragmatic-drag-and-drop-hitbox': + specifier: ^1.0.3 + version: 1.1.0 '@electron-toolkit/preload': specifier: ^3.0.1 version: 3.0.2(electron@35.4.0) '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@35.4.0) - '@emotion/react': - specifier: ^11.10.4 - version: 11.14.0(@types/react@18.3.22)(react@18.3.1) + '@mantine/colors-generator': + specifier: ^8.1.1 + version: 8.1.1(chroma-js@3.1.2) '@mantine/core': - specifier: ^6.0.22 - version: 6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/dates': - specifier: ^6.0.22 - version: 6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(dayjs@1.11.13)(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/form': - specifier: ^6.0.22 - version: 6.0.22(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(react@19.1.0) '@mantine/hooks': - specifier: ^6.0.22 - version: 6.0.22(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(react@19.1.0) '@mantine/modals': - specifier: ^6.0.22 - version: 6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/notifications': - specifier: ^6.0.22 - version: 6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/utils': - specifier: ^6.0.22 - version: 6.0.22(react@18.3.1) + specifier: ^8.1.1 + version: 8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^4.32.1 - version: 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query-devtools': specifier: ^4.32.1 - version: 4.36.1(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.36.1(@tanstack/react-query@4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query-persist-client': specifier: ^4.32.1 - version: 4.36.1(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.36.1(@tanstack/react-query@4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@ts-rest/core': specifier: ^3.23.0 - version: 3.52.1(@types/node@22.15.21)(zod@3.25.23) + version: 3.52.1(@types/node@22.15.32)(zod@3.25.23) '@xhayper/discord-rpc': specifier: ^1.0.24 version: 1.2.1 @@ -73,7 +79,7 @@ importers: version: 4.5.0 auto-text-size: specifier: ^0.2.3 - version: 0.2.3(react@18.3.1) + version: 0.2.3(react@19.1.0) axios: specifier: ^1.6.0 version: 1.9.0 @@ -85,7 +91,7 @@ importers: version: 2.1.1 cmdk: specifier: ^0.2.0 - version: 0.2.1(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.2.1(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) dayjs: specifier: ^1.11.6 version: 1.11.13 @@ -113,9 +119,6 @@ importers: format-duration: specifier: ^2.0.0 version: 2.0.0 - framer-motion: - specifier: ^11.0.0 - version: 11.18.2(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -140,6 +143,9 @@ importers: memoize-one: specifier: ^6.0.0 version: 6.0.0 + motion: + specifier: ^12.18.1 + version: 12.18.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) mpris-service: specifier: ^2.1.2 version: 2.1.2 @@ -154,49 +160,58 @@ importers: version: 2.11.3 overlayscrollbars-react: specifier: ^0.5.6 - version: 0.5.6(overlayscrollbars@2.11.3)(react@18.3.1) + version: 0.5.6(overlayscrollbars@2.11.3)(react@19.1.0) qs: specifier: ^6.14.0 version: 6.14.0 + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) react-error-boundary: specifier: ^3.1.4 - version: 3.1.4(react@18.3.1) + version: 3.1.4(react@19.1.0) react-i18next: specifier: ^11.18.6 - version: 11.18.6(i18next@21.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 11.18.6(i18next@21.10.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-icons: - specifier: ^4.10.1 - version: 4.12.0(react@18.3.1) + specifier: ^5.5.0 + version: 5.5.0(react@19.1.0) + react-image: + specifier: ^4.1.0 + version: 4.1.0(@babel/runtime@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@19.1.0) react-player: specifier: ^2.11.0 - version: 2.16.0(react@18.3.1) + version: 2.16.0(react@19.1.0) react-router: specifier: ^6.16.0 - version: 6.30.1(react@18.3.1) + version: 6.30.1(react@19.1.0) react-router-dom: specifier: ^6.16.0 - version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-simple-img: - specifier: ^3.0.0 - version: 3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.30.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-virtualized-auto-sizer: specifier: ^1.0.17 - version: 1.0.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-window: specifier: ^1.8.9 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.8.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-window-infinite-loader: specifier: ^1.0.9 - version: 1.0.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) semver: specifier: ^7.5.4 version: 7.7.2 - styled-components: - specifier: ^6.0.8 - version: 6.1.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) swiper: specifier: ^9.3.1 version: 9.4.1 + use-sync-external-store: + specifier: ^1.5.0 + version: 1.5.0(react@19.1.0) ws: specifier: ^8.18.2 version: 8.18.2 @@ -204,8 +219,8 @@ importers: specifier: ^3.22.3 version: 3.25.23 zustand: - specifier: ^4.3.9 - version: 4.5.7(@types/react@18.3.22)(immer@9.0.21)(react@18.3.1) + specifier: ^5.0.5 + version: 5.0.5(@types/react@18.3.23)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)) devDependencies: '@electron-toolkit/eslint-config-prettier': specifier: ^3.0.0 @@ -215,25 +230,25 @@ importers: version: 3.1.0(eslint@9.27.0)(typescript@5.8.3) '@electron-toolkit/tsconfig': specifier: ^1.0.1 - version: 1.0.1(@types/node@22.15.21) + version: 1.0.1(@types/node@22.15.32) '@types/electron-localshortcut': specifier: ^3.1.0 version: 3.1.3 '@types/lodash': - specifier: ^4.14.188 - version: 4.17.17 + specifier: ^4.17.18 + version: 4.17.18 '@types/md5': - specifier: ^2.3.2 + specifier: ^2.3.5 version: 2.3.5 '@types/node': - specifier: ^22.14.1 - version: 22.15.21 + specifier: ^22.15.32 + version: 22.15.32 '@types/react': - specifier: ^18.3.1 - version: 18.3.22 + specifier: ^18.3.23 + version: 18.3.23 '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.22) + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.23) '@types/react-window': specifier: ^1.8.5 version: 1.8.8 @@ -243,15 +258,12 @@ importers: '@types/source-map-support': specifier: ^0.5.10 version: 0.5.10 - '@types/styled-components': - specifier: ^5.1.26 - version: 5.1.34 '@types/ws': specifier: ^8.18.1 version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.5.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2)) + version: 4.5.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2)) concurrently: specifier: ^7.1.0 version: 7.6.0 @@ -269,7 +281,7 @@ importers: version: 3.2.1 electron-vite: specifier: ^3.1.0 - version: 3.1.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2)) + version: 3.1.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2)) eslint: specifier: ^9.24.0 version: 9.27.0 @@ -291,51 +303,36 @@ importers: i18next-parser: specifier: ^9.0.2 version: 9.3.0 - postcss-styled-syntax: - specifier: ^0.5.0 - version: 0.5.0(postcss@8.5.3) + postcss-preset-mantine: + specifier: ^1.17.0 + version: 1.17.0(postcss@8.5.3) prettier: specifier: ^3.5.3 version: 3.5.3 prettier-plugin-packagejson: specifier: ^2.5.14 version: 2.5.14(prettier@3.5.3) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) sass-embedded: specifier: ^1.89.0 version: 1.89.0 stylelint: - specifier: ^15.10.3 - version: 15.11.0(typescript@5.8.3) + specifier: ^16.14.1 + version: 16.20.0(typescript@5.8.3) stylelint-config-css-modules: - specifier: ^4.3.0 - version: 4.4.0(stylelint@15.11.0(typescript@5.8.3)) + specifier: ^4.4.0 + version: 4.4.0(stylelint@16.20.0(typescript@5.8.3)) stylelint-config-recess-order: - specifier: ^4.3.0 - version: 4.6.0(stylelint@15.11.0(typescript@5.8.3)) + specifier: ^7.1.0 + version: 7.1.0(stylelint-order@6.0.4(stylelint@16.20.0(typescript@5.8.3)))(stylelint@16.20.0(typescript@5.8.3)) stylelint-config-standard: - specifier: ^34.0.0 - version: 34.0.0(stylelint@15.11.0(typescript@5.8.3)) - stylelint-config-standard-scss: - specifier: ^4.0.0 - version: 4.0.0(postcss@8.5.3)(stylelint@15.11.0(typescript@5.8.3)) - stylelint-config-styled-components: - specifier: ^0.1.1 - version: 0.1.1 + specifier: ^38.0.0 + version: 38.0.0(stylelint@16.20.0(typescript@5.8.3)) typescript: specifier: ^5.8.3 version: 5.8.3 - typescript-plugin-styled-components: - specifier: ^3.0.0 - version: 3.0.0(typescript@5.8.3) vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2) + version: 6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2) vite-plugin-conditional-import: specifier: ^0.1.7 version: 0.1.7 @@ -344,7 +341,7 @@ importers: version: 1.6.0 vite-plugin-ejs: specifier: ^1.7.0 - version: 1.7.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2)) + version: 1.7.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2)) packages: @@ -374,6 +371,18 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.1': + resolution: {integrity: sha512-VAQEb3NVLY9Q5ZgC5Eiws9Uf6xOINY9/pAZMdbOVlF90uRXEkmpYqdTL+zeyZ8U8deuqYCmXr7oWIEnxpNQVzA==} + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': + resolution: {integrity: sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==} + + '@atlaskit/pragmatic-drag-and-drop@1.4.0': + resolution: {integrity: sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==} + + '@atlaskit/pragmatic-drag-and-drop@1.7.4': + resolution: {integrity: sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -466,28 +475,28 @@ packages: '@bufbuild/protobuf@2.4.0': resolution: {integrity: sha512-RN9M76x7N11QRihKovEglEjjVCQEA9PRBVnDgk9xw8JHLrcUrp4FpAVSPSH91cNbcTft3u2vpLN4GMbiKY9PJw==} - '@csstools/css-parser-algorithms@2.7.1': - resolution: {integrity: sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-tokenizer@2.4.1': - resolution: {integrity: sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} - '@csstools/media-query-list-parser@2.1.13': - resolution: {integrity: sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^2.7.1 - '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/selector-specificity@3.1.1': - resolution: {integrity: sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} peerDependencies: - postcss-selector-parser: ^6.0.13 + postcss-selector-parser: ^7.0.0 '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} @@ -505,6 +514,9 @@ packages: resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} engines: {node: '>=18'} + '@dual-bundle/import-meta-resolve@4.1.0': + resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} + '@electron-toolkit/eslint-config-prettier@3.0.0': resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==} peerDependencies: @@ -582,56 +594,6 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emotion/babel-plugin@11.13.5': - resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} - - '@emotion/cache@11.14.0': - resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} - - '@emotion/hash@0.9.2': - resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - - '@emotion/is-prop-valid@1.2.2': - resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} - - '@emotion/memoize@0.8.1': - resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - - '@emotion/react@11.14.0': - resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} - peerDependencies: - '@types/react': '*' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - - '@emotion/serialize@1.3.3': - resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} - - '@emotion/sheet@1.4.0': - resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - - '@emotion/unitless@0.10.0': - resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - - '@emotion/unitless@0.8.1': - resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} - - '@emotion/use-insertion-effect-with-fallbacks@1.2.0': - resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} - peerDependencies: - react: '>=16.8.0' - - '@emotion/utils@1.4.2': - resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} - - '@emotion/weak-memoize@0.4.0': - resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} engines: {node: '>=18'} @@ -826,14 +788,14 @@ packages: '@floating-ui/dom@1.7.0': resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==} - '@floating-ui/react-dom@1.3.0': - resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.19.2': - resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==} + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -893,6 +855,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@keyv/serialize@1.0.3': + resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -901,58 +866,57 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mantine/core@6.0.22': - resolution: {integrity: sha512-6kv0eY7n565fyjgS20qUYeCSxg3f1TJ5vurzbP1HHtFXXKSY0bYoqqDoHipFCt6NxsPQGeiC6cC0c/IWIlxoKQ==} + '@mantine/colors-generator@8.1.1': + resolution: {integrity: sha512-C2mxqdkFme4lbmNFKSaK1kwTUTK0q1CmHWyheUG/2xVT1P6da+fQvnfzzs32k8I3jubK5Toy25+XxWXQ+4w9vw==} peerDependencies: - '@mantine/hooks': 6.0.22 - react: '>=16.8.0' - react-dom: '>=16.8.0' + chroma-js: '>=2.4.2' - '@mantine/dates@6.0.22': - resolution: {integrity: sha512-RwZzaRtyCdwXWrszjoDFUrYdy2s6sAZgXZzp+ytp0KJDm63+H+4ri1Qkv7bWKVBgrTP7alsxCIGHV2weEOZKog==} + '@mantine/core@8.1.1': + resolution: {integrity: sha512-fW5phaeraz5F2+rnu3cz9aqzyOdvK8ZKi3fWgfhURVt8lo7+oZOVmqsKnvnxw4clJvj/A51wLCmgN9uhlm0n2g==} peerDependencies: - '@mantine/core': 6.0.22 - '@mantine/hooks': 6.0.22 + '@mantine/hooks': 8.1.1 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + + '@mantine/dates@8.1.1': + resolution: {integrity: sha512-ewFtUQNMvvdVgsITx8QSeptS17/Ygy5K9YMkdV8AwMHuB9S2UZBmBpn0TTfFtEEwsBf8nBtSbYGY/dQFZDP+AA==} + peerDependencies: + '@mantine/core': 8.1.1 + '@mantine/hooks': 8.1.1 dayjs: '>=1.0.0' - react: '>=16.8.0' + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x - '@mantine/form@6.0.22': - resolution: {integrity: sha512-M73HwndjrbPekswcs/DI8ez7E4etWsuE25omdMDhhUTsTMPD/k/WB38yr7AMqI4nCuy2kGwQ5KL70s0fnkFfKw==} + '@mantine/form@8.1.1': + resolution: {integrity: sha512-9QGiPa51SMjbxA25fOwiWFkayIzZzTeOdhFv6pJgxSe3/4DxI0fmJUzxXnBpXuPAYYyHyeHm1I1gRGreplFGVg==} peerDependencies: - react: '>=16.8.0' + react: ^18.x || ^19.x - '@mantine/hooks@6.0.22': - resolution: {integrity: sha512-e10//QTN2sAmC4Ryeu5X5L/TsxnrjXMOaGq3dxFPIPsCSwLzyxqySfjzVViWmoPWAj0Ak9MvE2MHFjzmOpA80w==} + '@mantine/hooks@8.1.1': + resolution: {integrity: sha512-C+p9pPDPl/IMcVCN44XMa9MbuUOEJS3PG9Wklw9Z+ooJqkBnc7aXs3xTAjIsaUCinR9hMqT19cwiYb+W88leVg==} peerDependencies: - react: '>=16.8.0' + react: ^18.x || ^19.x - '@mantine/modals@6.0.22': - resolution: {integrity: sha512-1k4bc6eFBGwKLaySXqAFCxyAHKv+p5bOh06RaYx9S9KyospR61M8Nw6BE3q7CdQRH2MQp8vgVue+h6y7pcWFFQ==} + '@mantine/modals@8.1.1': + resolution: {integrity: sha512-XEpaUjALtEG63TnnSgd4DiWtIZ9G6pAEB597VZtPlrbm87lrb5APr2BUkXY0unayVIRteG+TpRSjrii/mStpQA==} peerDependencies: - '@mantine/core': 6.0.22 - '@mantine/hooks': 6.0.22 - react: '>=16.8.0' - react-dom: '>=16.8.0' + '@mantine/core': 8.1.1 + '@mantine/hooks': 8.1.1 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x - '@mantine/notifications@6.0.22': - resolution: {integrity: sha512-x7iIil2yC81fEv/7YK6NYn6CKaftlw0E/hdprmxGWFhy87W9sYiYzPqigXZh11IJZFFW9ZPftpjPQFvDwE4KOw==} + '@mantine/notifications@8.1.1': + resolution: {integrity: sha512-eCPsCVkvb5ce0FIbbBiQPGhmgaXgv+9KXPzr3BEfWiR33BSaPs8Q0NaweyZGpEICerOodeAKVXWTGf1KbfEN/g==} peerDependencies: - '@mantine/core': 6.0.22 - '@mantine/hooks': 6.0.22 - react: '>=16.8.0' - react-dom: '>=16.8.0' + '@mantine/core': 8.1.1 + '@mantine/hooks': 8.1.1 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x - '@mantine/styles@6.0.22': - resolution: {integrity: sha512-Rud/IQp2EFYDiP4csRy2XBrho/Ct+W2/b+XbvCRTeQTmpFy/NfAKm/TWJa5zPvuv/iLTjGkVos9SHw/DteESpQ==} + '@mantine/store@8.1.1': + resolution: {integrity: sha512-VmFhaMT+W0YhX2KyoOc7xYYg9nN1LhG8eTFebOshwSi0I2HIONXtgtLg0wBSi3ZXI22dNicCBS/ukA3X534ZBg==} peerDependencies: - '@emotion/react': '>=11.9.0' - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@mantine/utils@6.0.22': - resolution: {integrity: sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==} - peerDependencies: - react: '>=16.8.0' + react: ^18.x || ^19.x '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -987,9 +951,6 @@ packages: resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@radix-ui/number@1.0.0': - resolution: {integrity: sha512-Ofwh/1HX69ZfJRiRBMTy7rgjAzHmwe4kW9C9Y99HTRUcYLUuVT0KESFj15rPjRgKJs20GPq8Bm5aEDJ8DuA3vA==} - '@radix-ui/primitive@1.0.0': resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} @@ -1009,11 +970,6 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-direction@1.0.0': - resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-dismissable-layer@1.0.0': resolution: {integrity: sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==} peerDependencies: @@ -1054,28 +1010,11 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-primitive@1.0.1': - resolution: {integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - - '@radix-ui/react-scroll-area@1.0.2': - resolution: {integrity: sha512-k8VseTxI26kcKJaX0HPwkvlNBPTs56JRdYzcZ/vzrNUkDlvXBy8sMc7WvCpYzZkHgb+hd72VW9MqkqecGtuNgg==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-slot@1.0.0': resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-slot@1.0.1': - resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - '@radix-ui/react-use-callback-ref@1.0.0': resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} peerDependencies: @@ -1295,9 +1234,6 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} - '@types/hoist-non-react-statics@3.3.6': - resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} - '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} @@ -1307,8 +1243,8 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/lodash@4.17.17': - resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/lodash@4.17.18': + resolution: {integrity: sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==} '@types/md5@2.3.5': resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==} @@ -1316,20 +1252,11 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} - '@types/minimist@1.2.5': - resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.15.21': - resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} - - '@types/normalize-package-data@2.4.4': - resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - - '@types/parse-json@4.0.2': - resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/node@22.15.32': + resolution: {integrity: sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==} '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -1348,8 +1275,8 @@ packages: '@types/react-window@1.8.8': resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} - '@types/react@18.3.22': - resolution: {integrity: sha512-vUhG0YmQZ7kL/tmKLrD3g5zXbXXreZXB3pmROW8bg3CnLnpjkRVwUlLne7Ufa2r9yJ8+/6B73RzhAek5TBKh2Q==} + '@types/react@18.3.23': + resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -1357,12 +1284,6 @@ packages: '@types/source-map-support@0.5.10': resolution: {integrity: sha512-tgVP2H469x9zq34Z0m/fgPewGhg/MLClalNOiPIzQlXrSS2YrKu/xCdSCKnEDwkFha51VKEKB6A9wW26/ZNwzA==} - '@types/styled-components@5.1.34': - resolution: {integrity: sha512-mmiVvwpYklFIv9E8qfxuPyIt/OuyIrn6gMOAMOFUO3WJfSrSE+sGUoa4PiZj77Ut7bKZpaa6o1fBKS/4TOEvnA==} - - '@types/stylis@4.2.5': - resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} - '@types/symlink-or-copy@1.2.2': resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==} @@ -1404,23 +1325,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/types@8.32.1': resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/typescript-estree@8.32.1': resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1434,10 +1342,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/visitor-keys@8.32.1': resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1583,10 +1487,6 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - arrify@1.0.1: - resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} - engines: {node: '>=0.10.0'} - assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1635,10 +1535,6 @@ packages: b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - babel-plugin-macros@3.1.0: - resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} - engines: {node: '>=10', npm: '>=6'} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1651,6 +1547,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bind-event-listener@3.0.0: + resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -1735,6 +1634,9 @@ packages: resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} engines: {node: '>=8'} + cacheable@1.10.0: + resolution: {integrity: sha512-SSgQTAnhd7WlJXnGlIi4jJJOiHzgnM5wRMEPaXAU4kECTAMpBoYKoZ9i5zHmclIEZbxcu3j7yY/CF8DTmwIsHg==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1751,16 +1653,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase-keys@7.0.2: - resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} - engines: {node: '>=12'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - camelize@1.0.1: - resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} caniuse-lite@1.0.30001718: resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} @@ -1783,6 +1678,9 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chroma-js@3.1.2: + resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==} + chromium-pickle-js@0.2.0: resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} @@ -1824,10 +1722,6 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} - clsx@1.1.1: - resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1893,9 +1787,6 @@ packages: config-file-ts@0.2.8-rc1: resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} - convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1909,12 +1800,8 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cosmiconfig@7.1.0: - resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} - engines: {node: '>=10'} - - cosmiconfig@8.3.6: - resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -1940,10 +1827,6 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} - css-color-keywords@1.0.0: - resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} - engines: {node: '>=4'} - css-functions-list@3.2.3: resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} engines: {node: '>=12 || >=16'} @@ -1951,13 +1834,6 @@ packages: css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} - css-to-react-native@3.2.0: - resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} - - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1971,9 +1847,6 @@ packages: engines: {node: '>=4'} hasBin: true - csstype@3.0.9: - resolution: {integrity: sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==} - csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2020,18 +1893,6 @@ packages: supports-color: optional: true - decamelize-keys@1.1.1: - resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} - engines: {node: '>=0.10.0'} - - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - decamelize@5.0.1: - resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} - engines: {node: '>=10'} - decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2389,9 +2250,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2455,9 +2313,8 @@ packages: picomatch: optional: true - file-entry-cache@7.0.2: - resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==} - engines: {node: '>=12.0.0'} + file-entry-cache@10.1.1: + resolution: {integrity: sha512-zcmsHjg2B2zjuBgjdnB+9q0+cWcgWfykIcsDkWDB4GTPtl1eXUA+gTI6sO0u01AqK3cliHryTU55/b2Ow1hfZg==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -2473,9 +2330,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-root@1.1.0: - resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -2484,14 +2338,13 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flat-cache@6.1.10: + resolution: {integrity: sha512-B6/v1f0NwjxzmeOhzfXPGWpKBVA207LS7lehaVKQnFrVktcFRfkzjZZ2gwj2i1TkEUMQht7ZMJbABUT5N+V1Nw==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -2519,8 +2372,8 @@ packages: format-duration@2.0.0: resolution: {integrity: sha512-ARqJ9qXm71pw3SGAY7bibf8lRLvltOXLjWjzzR3UrUjHu1zdeYpA/Z+u+ltdhrfRa440OjEsHNzdmuZViqqQWQ==} - framer-motion@11.18.2: - resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + framer-motion@12.18.1: + resolution: {integrity: sha512-6o4EDuRPLk4LSZ1kRnnEOurbQ86MklVk+Y1rFBUKiF+d2pCdvMjWVu0ZkyMVCTwl5UyTH2n/zJEJx+jvTYuxow==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2701,10 +2554,6 @@ packages: gulp-sort@2.0.0: resolution: {integrity: sha512-MyTel3FXOdh1qhw1yKhpimQrAmur9q1X0ZigLmCOxouQD+BD3za9/89O+HfbgBQvvh4igEbp0/PUWO+VqGYG1g==} - hard-rejection@2.1.0: - resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} - engines: {node: '>=6'} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2742,8 +2591,8 @@ packages: resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==} hasBin: true - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hookified@1.9.1: + resolution: {integrity: sha512-u3pxtGhKjcSXnGm1CX6aXS9xew535j3lkOCegbA6jdyh0BaAjTbXI4aslKstCr6zUNtoCxFGFKwjbSHdGrMB8g==} hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} @@ -2837,10 +2686,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-lazy@4.0.0: - resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} - engines: {node: '>=8'} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2849,10 +2694,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} @@ -2970,10 +2811,6 @@ packages: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} - is-plain-obj@1.1.0: - resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} - engines: {node: '>=0.10.0'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3125,6 +2962,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.3.4: + resolution: {integrity: sha512-ypEvQvInNpUe+u+w8BIcPkQvEqXquyyibWE/1NB5T2BTzIpS5cGEV1LZskDzPSTvNAaT4+5FutvzlvnkxOSKlw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -3133,9 +2973,6 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} - known-css-properties@0.29.0: - resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} - known-css-properties@0.36.0: resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==} @@ -3230,14 +3067,6 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - map-obj@1.0.1: - resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} - engines: {node: '>=0.10.0'} - - map-obj@4.3.0: - resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} - engines: {node: '>=8'} - map-stream@0.1.0: resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} @@ -3259,9 +3088,6 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -3274,9 +3100,9 @@ packages: memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} - meow@10.1.5: - resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} @@ -3315,10 +3141,6 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -3334,10 +3156,6 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - minimist-options@4.1.0: - resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} - engines: {node: '>= 6'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3390,11 +3208,25 @@ packages: resolution: {integrity: sha512-IXnMcJ6ZyTuhRmJSjzvHSRhlVPiN9Jwc6e59V0bEJ0ba6OBeX2L0E+mRN1QseeOF4mM+F1Rit6Nh7o+rl2Yn/A==} engines: {node: '>0.9'} - motion-dom@11.18.1: - resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + motion-dom@12.18.1: + resolution: {integrity: sha512-dR/4EYT23Snd+eUSLrde63Ws3oXQtJNw/krgautvTfwrN/2cHfCZMdu6CeTxVfRRWREW3Fy1f5vobRDiBb/q+w==} - motion-utils@11.18.1: - resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + motion-utils@12.18.1: + resolution: {integrity: sha512-az26YDU4WoDP0ueAkUtABLk2BIxe28d8NH1qWT8jPGhPyf44XTdDUh8pDk9OPphaSrR9McgpcJlgwSOIw/sfkA==} + + motion@12.18.1: + resolution: {integrity: sha512-w1ns2hWQ4COhOvnZf4rg4mW0Pl36mzcShpgt0fSfI6qJxKUbi3kHho/HSKeJFRoY0TO1m5/7C8lG1+Li0uC9Fw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true mpris-service@2.1.2: resolution: {integrity: sha512-AC6WepCnFWwOME9OWplHZ8ps/BB+g9QrEpUKCv7wX82fDPzR3nPrypOFmL/Fm0JloEAu6QTWSfDLLc6mM/jinw==} @@ -3446,10 +3278,6 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true - normalize-package-data@3.0.3: - resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} - engines: {node: '>=10'} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3639,23 +3467,40 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + postcss-media-query-parser@0.2.3: resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + postcss-mixins@9.0.4: + resolution: {integrity: sha512-XVq5jwQJDRu5M1XGkdpgASqLk37OqkH4JCFDXl/Dn7janOJjCTEKL+36cnRVy7bMtoBzALfO7bV7nTIsFnUWLA==} + engines: {node: '>=14.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-preset-mantine@1.17.0: + resolution: {integrity: sha512-ji1PMDBUf2Vsx/HE5faMSs1+ff6qE6YRulTr4Ja+6HD3gop8rSMTCYdpN7KrdsEg079kfBKkO/PaKhG9uR0zwQ==} + peerDependencies: + postcss: '>=8.0.0' + postcss-resolve-nested-selector@0.1.6: resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} - postcss-safe-parser@6.0.0: - resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} - engines: {node: '>=12.0'} + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} peerDependencies: - postcss: ^8.3.3 - - postcss-scss@4.0.9: - resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.4.29 + postcss: ^8.4.31 postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} @@ -3665,24 +3510,20 @@ packages: resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} engines: {node: '>=4'} + postcss-simple-vars@7.0.1: + resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} + engines: {node: '>=14.0'} + peerDependencies: + postcss: ^8.2.1 + postcss-sorting@8.0.2: resolution: {integrity: sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==} peerDependencies: postcss: ^8.4.20 - postcss-styled-syntax@0.5.0: - resolution: {integrity: sha512-kgYPNbcppION92+tMNVtAPQK9PU24sZc6jAqdF64YlfXVNFx4zRuKEzqLJuC4rFhTTrxoR9dHXSAl/OIBshKRw==} - engines: {node: '>=14.17'} - peerDependencies: - postcss: ^8.4.21 - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} @@ -3767,10 +3608,13 @@ packages: quick-temp@0.1.8: resolution: {integrity: sha512-YsmIFfD9j2zaFwJkzI6eMG7y0lQP7YeWzgtFgNl38pGWZBSXJooZbOWwkcRot7Vt0Fg9L23pX0tqWU3VvLDsiA==} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: - react: ^18.3.1 + react: ^19.1.0 react-error-boundary@3.1.4: resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} @@ -3794,14 +3638,32 @@ packages: react-native: optional: true - react-icons@4.12.0: - resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: react: '*' + react-image@4.1.0: + resolution: {integrity: sha512-qwPNlelQe9Zy14K2pGWSwoL+vHsAwmJKS6gkotekDgRpcnRuzXNap00GfibD3eEPYu3WCPlyIUUNzcyHOrLHjw==} + peerDependencies: + '@babel/runtime': '>=7' + react: '>=16.8' + react-dom: '>=16.8' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-number-format@5.4.4: + resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-player@2.16.0: resolution: {integrity: sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==} peerDependencies: @@ -3854,12 +3716,6 @@ packages: peerDependencies: react: '>=16.8' - react-simple-img@3.0.0: - resolution: {integrity: sha512-I0sG/GgY9c+04BgWf1YRlipWBQxR3oG2s/bagU8EO7zals3/Vkfk1PJMeYh/wHfjxJtUmal+y7HWEBm4MzXVsQ==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3870,14 +3726,14 @@ packages: '@types/react': optional: true - react-textarea-autosize@8.3.4: - resolution: {integrity: sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==} + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} engines: {node: '>=10'} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-transition-group@4.4.2: - resolution: {integrity: sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==} + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: react: '>=16.6.0' react-dom: '>=16.6.0' @@ -3902,22 +3758,14 @@ packages: react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true - read-pkg-up@8.0.0: - resolution: {integrity: sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==} - engines: {node: '>=12'} - - read-pkg@6.0.0: - resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==} - engines: {node: '>=12'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -3925,10 +3773,6 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - redent@4.0.0: - resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} - engines: {node: '>=12'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3974,11 +3818,6 @@ packages: resolution: {integrity: sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==} engines: {node: '>= 10.13.0'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} - hasBin: true - resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true @@ -4187,8 +4026,8 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -4225,9 +4064,6 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4309,10 +4145,6 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} - engines: {node: '>=0.10.0'} - source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4320,18 +4152,6 @@ packages: spawn-command@0.0.2: resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.21: - resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} - split@0.3.3: resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} @@ -4399,95 +4219,54 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} - strip-indent@4.0.0: - resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} - engines: {node: '>=12'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - style-search@0.1.0: - resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==} - - styled-components@6.1.18: - resolution: {integrity: sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw==} - engines: {node: '>= 16'} - peerDependencies: - react: '>= 16.8.0' - react-dom: '>= 16.8.0' - stylelint-config-css-modules@4.4.0: resolution: {integrity: sha512-J93MtxPjRzs/TjwbJ5y9SQy4iIqULXwL1CF1yx2tQCJfS/VZUcDAmoGOwqlLbhHXSQtZO5XQiA75NVWUR3KDCQ==} peerDependencies: stylelint: ^14.5.1 || ^15.0.0 || ^16.0.0 - stylelint-config-recess-order@4.6.0: - resolution: {integrity: sha512-V76fhv3YtcNXh/hyAuAdSzi5FmcrG54Mp2AThJ3D/PTMTSYzUPd7GIhP6z9mTqnRhmkk6YTfcu/JWB8h+Yrcaw==} + stylelint-config-recess-order@7.1.0: + resolution: {integrity: sha512-rFc4Z6SCGgEohr1khsmAZ83X56Tdi2dHY/GB7VT3qJkpKU1V2w+mYlK+b7Za5gpsxEng3jnb4FzWyIl/KTH0AQ==} peerDependencies: - stylelint: '>=15' + stylelint: '>=16.18' + stylelint-order: '>=7' - stylelint-config-recommended-scss@6.0.0: - resolution: {integrity: sha512-6QOe2/OzXV2AP5FE12A7+qtKdZik7Saf42SMMl84ksVBBPpTdrV+9HaCbPYiRMiwELY9hXCVdH4wlJ+YJb5eig==} + stylelint-config-recommended@16.0.0: + resolution: {integrity: sha512-4RSmPjQegF34wNcK1e1O3Uz91HN8P1aFdFzio90wNK9mjgAI19u5vsU868cVZboKzCaa5XbpvtTzAAGQAxpcXA==} + engines: {node: '>=18.12.0'} peerDependencies: - stylelint: ^14.4.0 + stylelint: ^16.16.0 - stylelint-config-recommended@13.0.0: - resolution: {integrity: sha512-EH+yRj6h3GAe/fRiyaoO2F9l9Tgg50AOFhaszyfov9v6ayXJ1IkSHwTxd7lB48FmOeSGDPLjatjO11fJpmarkQ==} - engines: {node: ^14.13.1 || >=16.0.0} + stylelint-config-standard@38.0.0: + resolution: {integrity: sha512-uj3JIX+dpFseqd/DJx8Gy3PcRAJhlEZ2IrlFOc4LUxBX/PNMEQ198x7LCOE2Q5oT9Vw8nyc4CIL78xSqPr6iag==} + engines: {node: '>=18.12.0'} peerDependencies: - stylelint: ^15.10.0 - - stylelint-config-recommended@7.0.0: - resolution: {integrity: sha512-yGn84Bf/q41J4luis1AZ95gj0EQwRX8lWmGmBwkwBNSkpGSpl66XcPTulxGa/Z91aPoNGuIGBmFkcM1MejMo9Q==} - peerDependencies: - stylelint: ^14.4.0 - - stylelint-config-standard-scss@4.0.0: - resolution: {integrity: sha512-xizu8PTEyB6zYXBiVg6VtvUYn9m57x+6ZtaOdaxsfpbe5eagLPGNlbYnKfm/CfN69ArUpnwR6LjgsTHzlGbtXQ==} - peerDependencies: - stylelint: ^14.4.0 - - stylelint-config-standard@25.0.0: - resolution: {integrity: sha512-21HnP3VSpaT1wFjFvv9VjvOGDtAviv47uTp3uFmzcN+3Lt+RYRv6oAplLaV51Kf792JSxJ6svCJh/G18E9VnCA==} - peerDependencies: - stylelint: ^14.4.0 - - stylelint-config-standard@34.0.0: - resolution: {integrity: sha512-u0VSZnVyW9VSryBG2LSO+OQTjN7zF9XJaAJRX/4EwkmU0R2jYwmBSN10acqZisDitS0CLiEiGjX7+Hrq8TAhfQ==} - engines: {node: ^14.13.1 || >=16.0.0} - peerDependencies: - stylelint: ^15.10.0 - - stylelint-config-styled-components@0.1.1: - resolution: {integrity: sha512-z5Xz/9GmvxO6e/DLzBMwkB85zHxEEjN6K7Cj80Bi+o/9vR9eS3GX3E9VuMnX9WLFYulqbqLtTapGGY28JBiy9Q==} + stylelint: ^16.18.0 stylelint-order@6.0.4: resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==} peerDependencies: stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1 - stylelint-scss@4.7.0: - resolution: {integrity: sha512-TSUgIeS0H3jqDZnby1UO1Qv3poi1N8wUYIJY6D1tuUq2MN3lwp/rITVo0wD+1SWTmRm0tNmGO0b7nKInnqF6Hg==} - peerDependencies: - stylelint: ^14.5.1 || ^15.0.0 - stylelint-scss@6.12.0: resolution: {integrity: sha512-U7CKhi1YNkM1pXUXl/GMUXi8xKdhl4Ayxdyceie1nZ1XNIdaUgMV6OArpooWcDzEggwgYD0HP/xIgVJo9a655w==} engines: {node: '>=18.12.0'} peerDependencies: stylelint: ^16.0.2 - stylelint@15.11.0: - resolution: {integrity: sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==} - engines: {node: ^14.13.1 || >=16.0.0} + stylelint@16.20.0: + resolution: {integrity: sha512-B5Myu9WRxrgKuLs3YyUXLP2H0mrbejwNxPmyADlACWwFsrL8Bmor/nTSh4OMae5sHjOz6gkSeccQH34gM4/nAw==} + engines: {node: '>=18.12.0'} hasBin: true - stylis@4.2.0: - resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - - stylis@4.3.2: - resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} + sugarss@4.0.1: + resolution: {integrity: sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} @@ -4599,10 +4378,6 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - trim-newlines@4.1.1: - resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} - engines: {node: '>=12'} - truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} @@ -4612,21 +4387,9 @@ packages: peerDependencies: typescript: '>=4.8.4' - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4635,14 +4398,14 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4666,16 +4429,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - typescript-plugin-styled-components@3.0.0: - resolution: {integrity: sha512-QWlhTl6NqsFxtJyxn7pJjm3RhgzXSByUftZ3AoQClrMMpa4yAaHuJKTN1gFpH3Ti+Rwm56fNUfG9pXSBU+WW3A==} - peerDependencies: - typescript: ~4.8 || 5 - - typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -4785,9 +4538,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - value-or-function@4.0.0: resolution: {integrity: sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==} engines: {node: '>= 10.13.0'} @@ -4970,14 +4720,6 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4996,13 +4738,14 @@ packages: zod@3.25.23: resolution: {integrity: sha512-Od2bdMosahjSrSgJtakrwjMDb1zM1A3VIHCPGveZt/3/wlrTWBya2lmEh2OYe4OIu8mPTmmr0gnLHIWQXdtWBg==} - zustand@4.5.7: - resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} - engines: {node: '>=12.7.0'} + zustand@5.0.5: + resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==} + engines: {node: '>=12.20.0'} peerDependencies: - '@types/react': '>=16.8' + '@types/react': '>=18.0.0' immer: '>=9.0.6' - react: '>=16.8' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' peerDependenciesMeta: '@types/react': optional: true @@ -5010,6 +4753,8 @@ packages: optional: true react: optional: true + use-sync-external-store: + optional: true snapshots: @@ -5025,12 +4770,12 @@ snapshots: dependencies: '@ag-grid-community/core': 28.2.1 - '@ag-grid-community/react@28.2.1(@ag-grid-community/core@28.2.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ag-grid-community/react@28.2.1(@ag-grid-community/core@28.2.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@ag-grid-community/core': 28.2.1 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) '@ag-grid-community/styles@28.2.1': {} @@ -5039,6 +4784,28 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.1': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.7.4 + '@babel/runtime': 7.27.1 + + '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.7.4 + '@babel/runtime': 7.27.1 + + '@atlaskit/pragmatic-drag-and-drop@1.4.0': + dependencies: + '@babel/runtime': 7.27.1 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + + '@atlaskit/pragmatic-drag-and-drop@1.7.4': + dependencies: + '@babel/runtime': 7.27.1 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5158,20 +4925,20 @@ snapshots: '@bufbuild/protobuf@2.4.0': {} - '@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1)': + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-tokenizer@2.4.1': {} + '@csstools/css-tokenizer@3.0.4': {} - '@csstools/media-query-list-parser@2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) - '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.2)': + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.0)': dependencies: - postcss-selector-parser: 6.1.2 + postcss-selector-parser: 7.1.0 '@develar/schema-utils@2.6.5': dependencies: @@ -5194,6 +4961,8 @@ snapshots: '@discordjs/util@1.1.1': {} + '@dual-bundle/import-meta-resolve@4.1.0': {} + '@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.27.0)(prettier@3.5.3)': dependencies: eslint: 9.27.0 @@ -5218,9 +4987,9 @@ snapshots: dependencies: electron: 35.4.0 - '@electron-toolkit/tsconfig@1.0.1(@types/node@22.15.21)': + '@electron-toolkit/tsconfig@1.0.1(@types/node@22.15.32)': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 '@electron-toolkit/utils@4.0.0(electron@35.4.0)': dependencies: @@ -5336,78 +5105,6 @@ snapshots: - supports-color optional: true - '@emotion/babel-plugin@11.13.5': - dependencies: - '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.27.1 - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/serialize': 1.3.3 - babel-plugin-macros: 3.1.0 - convert-source-map: 1.9.0 - escape-string-regexp: 4.0.0 - find-root: 1.1.0 - source-map: 0.5.7 - stylis: 4.2.0 - transitivePeerDependencies: - - supports-color - - '@emotion/cache@11.14.0': - dependencies: - '@emotion/memoize': 0.9.0 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - stylis: 4.2.0 - - '@emotion/hash@0.9.2': {} - - '@emotion/is-prop-valid@1.2.2': - dependencies: - '@emotion/memoize': 0.8.1 - - '@emotion/memoize@0.8.1': {} - - '@emotion/memoize@0.9.0': {} - - '@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.1 - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - hoist-non-react-statics: 3.3.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.22 - transitivePeerDependencies: - - supports-color - - '@emotion/serialize@1.3.3': - dependencies: - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/unitless': 0.10.0 - '@emotion/utils': 1.4.2 - csstype: 3.1.3 - - '@emotion/sheet@1.4.0': {} - - '@emotion/unitless@0.10.0': {} - - '@emotion/unitless@0.8.1': {} - - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': - dependencies: - react: 18.3.1 - - '@emotion/utils@1.4.2': {} - - '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.4': optional: true @@ -5536,18 +5233,18 @@ snapshots: '@floating-ui/core': 1.7.0 '@floating-ui/utils': 0.2.9 - '@floating-ui/react-dom@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/dom': 1.7.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@floating-ui/react@0.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react@0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@floating-ui/react-dom': 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.9 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) tabbable: 6.2.0 '@floating-ui/utils@0.2.9': {} @@ -5603,6 +5300,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@keyv/serialize@1.0.3': + dependencies: + buffer: 6.0.3 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -5616,67 +5317,62 @@ snapshots: transitivePeerDependencies: - supports-color - '@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/colors-generator@8.1.1(chroma-js@3.1.2)': dependencies: - '@floating-ui/react': 0.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 6.0.22(react@18.3.1) - '@mantine/styles': 6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/utils': 6.0.22(react@18.3.1) - '@radix-ui/react-scroll-area': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.0(@types/react@18.3.22)(react@18.3.1) - react-textarea-autosize: 8.3.4(@types/react@18.3.22)(react@18.3.1) + chroma-js: 3.1.2 + + '@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mantine/hooks': 8.1.1(react@19.1.0) + clsx: 2.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-number-format: 5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-remove-scroll: 2.7.0(@types/react@18.3.23)(react@19.1.0) + react-textarea-autosize: 8.5.9(@types/react@18.3.23)(react@19.1.0) + type-fest: 4.41.0 transitivePeerDependencies: - - '@emotion/react' - '@types/react' - '@mantine/dates@6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(dayjs@1.11.13)(react@18.3.1)': + '@mantine/dates@8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@mantine/core': 6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 6.0.22(react@18.3.1) - '@mantine/utils': 6.0.22(react@18.3.1) + '@mantine/core': 8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mantine/hooks': 8.1.1(react@19.1.0) + clsx: 2.1.1 dayjs: 1.11.13 - react: 18.3.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@mantine/form@6.0.22(react@18.3.1)': + '@mantine/form@8.1.1(react@19.1.0)': dependencies: fast-deep-equal: 3.1.3 klona: 2.0.6 - react: 18.3.1 + react: 19.1.0 - '@mantine/hooks@6.0.22(react@18.3.1)': + '@mantine/hooks@8.1.1(react@19.1.0)': dependencies: - react: 18.3.1 + react: 19.1.0 - '@mantine/modals@6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/modals@8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@mantine/core': 6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 6.0.22(react@18.3.1) - '@mantine/utils': 6.0.22(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@mantine/core': 8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mantine/hooks': 8.1.1(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@mantine/notifications@6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/notifications@8.1.1(@mantine/core@8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.1.1(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@mantine/core': 6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 6.0.22(react@18.3.1) - '@mantine/utils': 6.0.22(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-transition-group: 4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/core': 8.1.1(@mantine/hooks@8.1.1(react@19.1.0))(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mantine/hooks': 8.1.1(react@19.1.0) + '@mantine/store': 8.1.1(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@mantine/styles@6.0.22(@emotion/react@11.14.0(@types/react@18.3.22)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/store@8.1.1(react@19.1.0)': dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.22)(react@18.3.1) - clsx: 1.1.1 - csstype: 3.0.9 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@mantine/utils@6.0.22(react@18.3.1)': - dependencies: - react: 18.3.1 + react: 19.1.0 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -5707,159 +5403,122 @@ snapshots: '@pkgr/core@0.2.4': {} - '@radix-ui/number@1.0.0': - dependencies: - '@babel/runtime': 7.27.1 - '@radix-ui/primitive@1.0.0': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': + '@radix-ui/react-compose-refs@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 + react: 19.1.0 - '@radix-ui/react-context@1.0.0(react@18.3.1)': + '@radix-ui/react-context@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 + react: 19.1.0 - '@radix-ui/react-dialog@1.0.0(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.0.0(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/primitive': 1.0.0 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-context': 1.0.0(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.0(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.0(react@18.3.1) - '@radix-ui/react-portal': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.0(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.0(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) + '@radix-ui/react-context': 1.0.0(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.0.0(react@19.1.0) + '@radix-ui/react-focus-scope': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.0.0(react@19.1.0) + '@radix-ui/react-portal': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.0.0(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.1.0) aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.4(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.5.4(@types/react@18.3.23)(react@19.1.0) transitivePeerDependencies: - '@types/react' - '@radix-ui/react-direction@1.0.0(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.1 - react: 18.3.1 - - '@radix-ui/react-dismissable-layer@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 '@radix-ui/primitive': 1.0.0 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.0.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.0.0(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-focus-guards@1.0.0(react@18.3.1)': + '@radix-ui/react-focus-guards@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 + react: 19.1.0 - '@radix-ui/react-focus-scope@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-id@1.0.0(react@18.3.1)': + '@radix-ui/react-id@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) - react: 18.3.1 + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.1.0) + react: 19.1.0 - '@radix-ui/react-portal@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-primitive@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@1.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-slot': 1.0.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-slot': 1.0.0(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-primitive@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-slot@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-slot': 1.0.1(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.0(react@19.1.0) + react: 19.1.0 - '@radix-ui/react-scroll-area@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-use-callback-ref@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/number': 1.0.0 - '@radix-ui/primitive': 1.0.0 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-context': 1.0.0(react@18.3.1) - '@radix-ui/react-direction': 1.0.0(react@18.3.1) - '@radix-ui/react-presence': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 - '@radix-ui/react-slot@1.0.0(react@18.3.1)': + '@radix-ui/react-use-controllable-state@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - react: 18.3.1 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) + react: 19.1.0 - '@radix-ui/react-slot@1.0.1(react@18.3.1)': + '@radix-ui/react-use-escape-keydown@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - react: 18.3.1 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.1.0) + react: 19.1.0 - '@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)': + '@radix-ui/react-use-layout-effect@1.0.0(react@19.1.0)': dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 - - '@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.1 - '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) - react: 18.3.1 - - '@radix-ui/react-use-escape-keydown@1.0.0(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.1 - '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) - react: 18.3.1 - - '@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.1 - react: 18.3.1 + react: 19.1.0 '@remix-run/router@1.23.0': {} @@ -5945,33 +5604,33 @@ snapshots: dependencies: '@tanstack/query-core': 4.36.1 - '@tanstack/react-query-devtools@4.36.1(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-query-devtools@4.36.1(@tanstack/react-query@4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/match-sorter-utils': 8.19.4 - '@tanstack/react-query': 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-query': 4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) superjson: 1.13.3 - use-sync-external-store: 1.5.0(react@18.3.1) + use-sync-external-store: 1.5.0(react@19.1.0) - '@tanstack/react-query-persist-client@4.36.1(@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@tanstack/react-query-persist-client@4.36.1(@tanstack/react-query@4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: '@tanstack/query-persist-client-core': 4.36.1 - '@tanstack/react-query': 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': 4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-query@4.36.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/query-core': 4.36.1 - react: 18.3.1 - use-sync-external-store: 1.5.0(react@18.3.1) + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) optionalDependencies: - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.1.0(react@19.1.0) '@tootallnate/once@2.0.0': {} - '@ts-rest/core@3.52.1(@types/node@22.15.21)(zod@3.25.23)': + '@ts-rest/core@3.52.1(@types/node@22.15.32)(zod@3.25.23)': optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 zod: 3.25.23 '@types/babel__core@7.20.5': @@ -5999,7 +5658,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.0.4 '@types/keyv': 3.1.4 - '@types/node': 22.15.21 + '@types/node': 22.15.32 '@types/responselike': 1.0.3 '@types/debug@4.1.12': @@ -6016,12 +5675,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 22.15.21 - - '@types/hoist-non-react-statics@3.3.6': - dependencies: - '@types/react': 18.3.22 - hoist-non-react-statics: 3.3.2 + '@types/node': 22.15.32 '@types/http-cache-semantics@4.0.4': {} @@ -6029,68 +5683,54 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 - '@types/lodash@4.17.17': {} + '@types/lodash@4.17.18': {} '@types/md5@2.3.5': {} '@types/minimatch@3.0.5': {} - '@types/minimist@1.2.5': {} - '@types/ms@2.1.0': {} - '@types/node@22.15.21': + '@types/node@22.15.32': dependencies: undici-types: 6.21.0 - '@types/normalize-package-data@2.4.4': {} - - '@types/parse-json@4.0.2': {} - '@types/plist@3.0.5': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 xmlbuilder: 15.1.1 optional: true '@types/prop-types@15.7.14': {} - '@types/react-dom@18.3.7(@types/react@18.3.22)': + '@types/react-dom@18.3.7(@types/react@18.3.23)': dependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 '@types/react-window-infinite-loader@1.0.9': dependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 '@types/react-window': 1.8.8 '@types/react-window@1.8.8': dependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - '@types/react@18.3.22': + '@types/react@18.3.23': dependencies: '@types/prop-types': 15.7.14 csstype: 3.1.3 '@types/responselike@1.0.3': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 '@types/source-map-support@0.5.10': dependencies: source-map: 0.6.1 - '@types/styled-components@5.1.34': - dependencies: - '@types/hoist-non-react-statics': 3.3.6 - '@types/react': 18.3.22 - csstype: 3.1.3 - - '@types/stylis@4.2.5': {} - '@types/symlink-or-copy@1.2.2': {} '@types/trusted-types@2.0.7': @@ -6101,11 +5741,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 '@types/yauzl@2.10.3': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 optional: true '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.3))(eslint@9.27.0)(typescript@5.8.3)': @@ -6153,24 +5793,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@8.32.1': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.1.6)': - dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.1 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.7.2 - tsutils: 3.21.0(typescript@5.1.6) - optionalDependencies: - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.32.1 @@ -6196,17 +5820,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@5.62.0': - dependencies: - '@typescript-eslint/types': 5.62.0 - eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.32.1': dependencies: '@typescript-eslint/types': 8.32.1 eslint-visitor-keys: 4.2.0 - '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2))': + '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2))': dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) @@ -6214,7 +5833,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.9 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2) + vite: 6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2) transitivePeerDependencies: - supports-color @@ -6406,8 +6025,6 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - arrify@1.0.1: {} - assert-plus@1.0.0: optional: true @@ -6427,9 +6044,9 @@ snapshots: audiomotion-analyzer@4.5.0: {} - auto-text-size@0.2.3(react@18.3.1): + auto-text-size@0.2.3(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 available-typed-arrays@1.0.7: dependencies: @@ -6445,12 +6062,6 @@ snapshots: b4a@1.6.7: {} - babel-plugin-macros@3.1.0: - dependencies: - '@babel/runtime': 7.27.1 - cosmiconfig: 7.1.0 - resolve: 1.22.10 - balanced-match@1.0.2: {} balanced-match@2.0.0: {} @@ -6460,6 +6071,8 @@ snapshots: base64-js@1.5.1: {} + bind-event-listener@3.0.0: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -6608,6 +6221,11 @@ snapshots: normalize-url: 6.1.0 responselike: 2.0.1 + cacheable@1.10.0: + dependencies: + hookified: 1.9.1 + keyv: 5.3.4 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6627,16 +6245,7 @@ snapshots: callsites@3.1.0: {} - camelcase-keys@7.0.2: - dependencies: - camelcase: 6.3.0 - map-obj: 4.3.0 - quick-lru: 5.1.1 - type-fest: 1.4.0 - - camelcase@6.3.0: {} - - camelize@1.0.1: {} + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001718: {} @@ -6672,6 +6281,8 @@ snapshots: chownr@2.0.0: {} + chroma-js@3.1.2: {} + chromium-pickle-js@0.2.0: {} ci-info@3.9.0: {} @@ -6706,15 +6317,13 @@ snapshots: clone@2.1.2: {} - clsx@1.1.1: {} - clsx@2.1.1: {} - cmdk@0.2.1(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + cmdk@0.2.1(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@radix-ui/react-dialog': 1.0.0(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@radix-ui/react-dialog': 1.0.0(@types/react@18.3.23)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@types/react' @@ -6778,8 +6387,6 @@ snapshots: glob: 10.4.5 typescript: 5.8.3 - convert-source-map@1.9.0: {} - convert-source-map@2.0.0: {} copy-anything@3.0.5: @@ -6791,20 +6398,12 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@7.1.0: - dependencies: - '@types/parse-json': 4.0.2 - import-fresh: 3.3.1 - parse-json: 5.2.0 - path-type: 4.0.0 - yaml: 1.10.2 - - cosmiconfig@8.3.6(typescript@5.8.3): + cosmiconfig@9.0.0(typescript@5.8.3): dependencies: + env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.0 parse-json: 5.2.0 - path-type: 4.0.0 optionalDependencies: typescript: 5.8.3 @@ -6828,8 +6427,6 @@ snapshots: crypt@0.0.2: {} - css-color-keywords@1.0.0: {} - css-functions-list@3.2.3: {} css-select@5.1.0: @@ -6840,29 +6437,15 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 - css-to-react-native@3.2.0: - dependencies: - camelize: 1.0.1 - css-color-keywords: 1.0.0 - postcss-value-parser: 4.2.0 - - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 - optional: true css-what@6.1.0: {} cssesc@3.0.0: {} - csstype@3.0.9: {} - csstype@3.1.3: {} data-view-buffer@1.0.2: @@ -6913,15 +6496,6 @@ snapshots: dependencies: ms: 2.1.3 - decamelize-keys@1.1.1: - dependencies: - decamelize: 1.2.0 - map-obj: 1.0.1 - - decamelize@1.2.0: {} - - decamelize@5.0.1: {} - decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -7154,7 +6728,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@3.1.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2)): + electron-vite@3.1.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2)): dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.27.1) @@ -7162,7 +6736,7 @@ snapshots: esbuild: 0.25.4 magic-string: 0.30.17 picocolors: 1.1.1 - vite: 6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2) + vite: 6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2) transitivePeerDependencies: - supports-color @@ -7181,7 +6755,7 @@ snapshots: electron@35.4.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 22.15.21 + '@types/node': 22.15.32 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -7475,8 +7049,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@2.0.2: {} - esutils@2.0.3: {} event-stream@3.3.4: @@ -7540,9 +7112,9 @@ snapshots: optionalDependencies: picomatch: 4.0.2 - file-entry-cache@7.0.2: + file-entry-cache@10.1.1: dependencies: - flat-cache: 3.2.0 + flat-cache: 6.1.10 file-entry-cache@8.0.0: dependencies: @@ -7559,8 +7131,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-root@1.1.0: {} - find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -7570,17 +7140,17 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - rimraf: 3.0.2 - flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 + flat-cache@6.1.10: + dependencies: + cacheable: 1.10.0 + flatted: 3.3.3 + hookified: 1.9.1 + flatted@3.3.3: {} follow-redirects@1.15.9: {} @@ -7603,15 +7173,14 @@ snapshots: format-duration@2.0.0: {} - framer-motion@11.18.2(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.18.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - motion-dom: 11.18.1 - motion-utils: 11.18.1 + motion-dom: 12.18.1 + motion-utils: 12.18.1 tslib: 2.8.1 optionalDependencies: - '@emotion/is-prop-valid': 1.2.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) from@0.1.7: {} @@ -7842,8 +7411,6 @@ snapshots: dependencies: through2: 2.0.5 - hard-rejection@2.1.0: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7879,9 +7446,7 @@ snapshots: hexy@0.2.11: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 + hookified@1.9.1: {} hosted-git-info@4.1.0: dependencies: @@ -8001,14 +7566,10 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-lazy@4.0.0: {} - imurmurhash@0.1.4: {} indent-string@4.0.0: {} - indent-string@5.0.0: {} - infer-owner@1.0.4: {} inflight@1.0.6: @@ -8122,8 +7683,6 @@ snapshots: is-obj@2.0.0: {} - is-plain-obj@1.1.0: {} - is-plain-obj@4.1.0: {} is-plain-object@5.0.0: {} @@ -8266,14 +7825,15 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.3.4: + dependencies: + '@keyv/serialize': 1.0.3 + kind-of@6.0.3: {} klona@2.0.6: {} - known-css-properties@0.29.0: {} - - known-css-properties@0.36.0: - optional: true + known-css-properties@0.36.0: {} lazy-val@1.0.5: {} @@ -8370,10 +7930,6 @@ snapshots: - bluebird - supports-color - map-obj@1.0.1: {} - - map-obj@4.3.0: {} - map-stream@0.1.0: {} matcher-collection@2.0.1: @@ -8396,10 +7952,7 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 - mdn-data@2.0.30: {} - - mdn-data@2.12.2: - optional: true + mdn-data@2.12.2: {} mdn-data@2.21.0: optional: true @@ -8408,20 +7961,7 @@ snapshots: memoize-one@6.0.0: {} - meow@10.1.5: - dependencies: - '@types/minimist': 1.2.5 - camelcase-keys: 7.0.2 - decamelize: 5.0.1 - decamelize-keys: 1.1.1 - hard-rejection: 2.1.0 - minimist-options: 4.1.0 - normalize-package-data: 3.0.3 - read-pkg-up: 8.0.0 - redent: 4.0.0 - trim-newlines: 4.1.1 - type-fest: 1.4.0 - yargs-parser: 20.2.9 + meow@13.2.0: {} merge2@1.4.1: {} @@ -8446,8 +7986,6 @@ snapshots: mimic-response@3.1.0: {} - min-indent@1.0.1: {} - minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 @@ -8464,12 +8002,6 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimist-options@4.1.0: - dependencies: - arrify: 1.0.1 - is-plain-obj: 1.1.0 - kind-of: 6.0.3 - minimist@1.2.8: {} minipass-collect@1.0.2: @@ -8517,11 +8049,19 @@ snapshots: mktemp@0.4.0: {} - motion-dom@11.18.1: + motion-dom@12.18.1: dependencies: - motion-utils: 11.18.1 + motion-utils: 12.18.1 - motion-utils@11.18.1: {} + motion-utils@12.18.1: {} + + motion@12.18.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + framer-motion: 12.18.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) mpris-service@2.1.2: dependencies: @@ -8563,13 +8103,6 @@ snapshots: dependencies: abbrev: 1.1.1 - normalize-package-data@3.0.3: - dependencies: - hosted-git-info: 4.1.0 - is-core-module: 2.16.1 - semver: 7.7.2 - validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -8652,10 +8185,10 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - overlayscrollbars-react@0.5.6(overlayscrollbars@2.11.3)(react@18.3.1): + overlayscrollbars-react@0.5.6(overlayscrollbars@2.11.3)(react@19.1.0): dependencies: overlayscrollbars: 2.11.3 - react: 18.3.1 + react: 19.1.0 overlayscrollbars@2.11.3: {} @@ -8762,15 +8295,36 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-media-query-parser@0.2.3: {} + postcss-js@4.0.1(postcss@8.5.3): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.3 + + postcss-media-query-parser@0.2.3: + optional: true + + postcss-mixins@9.0.4(postcss@8.5.3): + dependencies: + fast-glob: 3.3.3 + postcss: 8.5.3 + postcss-js: 4.0.1(postcss@8.5.3) + postcss-simple-vars: 7.0.1(postcss@8.5.3) + sugarss: 4.0.1(postcss@8.5.3) + + postcss-nested@6.2.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-selector-parser: 6.1.2 + + postcss-preset-mantine@1.17.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-mixins: 9.0.4(postcss@8.5.3) + postcss-nested: 6.2.0(postcss@8.5.3) postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@6.0.0(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - - postcss-scss@4.0.9(postcss@8.5.3): + postcss-safe-parser@7.0.1(postcss@8.5.3): dependencies: postcss: 8.5.3 @@ -8783,29 +8337,17 @@ snapshots: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - optional: true + + postcss-simple-vars@7.0.1(postcss@8.5.3): + dependencies: + postcss: 8.5.3 postcss-sorting@8.0.2(postcss@8.5.3): dependencies: postcss: 8.5.3 - postcss-styled-syntax@0.5.0(postcss@8.5.3): - dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) - estree-walker: 2.0.2 - postcss: 8.5.3 - typescript: 5.1.6 - transitivePeerDependencies: - - supports-color - postcss-value-parser@4.2.0: {} - postcss@8.4.49: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.3: dependencies: nanoid: 3.3.11 @@ -8876,138 +8418,147 @@ snapshots: rimraf: 2.7.1 underscore.string: 3.3.6 - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + raf-schd@4.0.3: {} - react-error-boundary@3.1.4(react@18.3.1): + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-error-boundary@3.1.4(react@19.1.0): dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 + react: 19.1.0 react-fast-compare@3.2.2: {} - react-i18next@11.18.6(i18next@21.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-i18next@11.18.6(i18next@21.10.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.1 html-parse-stringify: 3.0.1 i18next: 21.10.0 - react: 18.3.1 + react: 19.1.0 optionalDependencies: - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.1.0(react@19.1.0) - react-icons@4.12.0(react@18.3.1): + react-icons@5.5.0(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 + + react-image@4.1.0(@babel/runtime@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) react-is@16.13.1: {} - react-player@2.16.0(react@18.3.1): + react-loading-skeleton@3.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + + react-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + react-player@2.16.0(react@19.1.0): dependencies: deepmerge: 4.3.1 load-script: 1.0.0 memoize-one: 5.2.1 prop-types: 15.8.1 - react: 18.3.1 + react: 19.1.0 react-fast-compare: 3.2.2 react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.8(@types/react@18.3.22)(react@18.3.1): + react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + react-style-singleton: 2.2.3(@types/react@18.3.23)(react@19.1.0) tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - react-remove-scroll@2.5.4(@types/react@18.3.22)(react@18.3.1): + react-remove-scroll@2.5.4(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.22)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.23)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@18.3.23)(react@19.1.0) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.22)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.22)(react@18.3.1) + use-callback-ref: 1.3.3(@types/react@18.3.23)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@18.3.23)(react@19.1.0) optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - react-remove-scroll@2.7.0(@types/react@18.3.22)(react@18.3.1): + react-remove-scroll@2.7.0(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.22)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.23)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@18.3.23)(react@19.1.0) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.22)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.22)(react@18.3.1) + use-callback-ref: 1.3.3(@types/react@18.3.23)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@18.3.23)(react@19.1.0) optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.30.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@remix-run/router': 1.23.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-router: 6.30.1(react@19.1.0) - react-router@6.30.1(react@18.3.1): + react-router@6.30.1(react@19.1.0): dependencies: '@remix-run/router': 1.23.0 - react: 18.3.1 + react: 19.1.0 - react-simple-img@3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-style-singleton@2.2.3(@types/react@18.3.22)(react@18.3.1): + react-style-singleton@2.2.3(@types/react@18.3.23)(react@19.1.0): dependencies: get-nonce: 1.0.1 - react: 18.3.1 + react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - react-textarea-autosize@8.3.4(@types/react@18.3.22)(react@18.3.1): + react-textarea-autosize@8.5.9(@types/react@18.3.23)(react@19.1.0): dependencies: '@babel/runtime': 7.27.1 - react: 18.3.1 - use-composed-ref: 1.4.0(@types/react@18.3.22)(react@18.3.1) - use-latest: 1.3.0(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + use-composed-ref: 1.4.0(@types/react@18.3.23)(react@19.1.0) + use-latest: 1.3.0(@types/react@18.3.23)(react@19.1.0) transitivePeerDependencies: - '@types/react' - react-transition-group@4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.1 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - react-virtualized-auto-sizer@1.0.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtualized-auto-sizer@1.0.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - react-window-infinite-loader@1.0.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-window-infinite-loader@1.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-window@1.8.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.1 memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - react@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.1.0: {} read-binary-file-arch@1.0.6: dependencies: @@ -9015,19 +8566,6 @@ snapshots: transitivePeerDependencies: - supports-color - read-pkg-up@8.0.0: - dependencies: - find-up: 5.0.0 - read-pkg: 6.0.0 - type-fest: 1.4.0 - - read-pkg@6.0.0: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 3.0.3 - parse-json: 5.2.0 - type-fest: 1.4.0 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -9044,11 +8582,6 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - redent@4.0.0: - dependencies: - indent-string: 5.0.0 - strip-indent: 4.0.0 - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9093,12 +8626,6 @@ snapshots: dependencies: value-or-function: 4.0.0 - resolve@1.22.10: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: dependencies: is-core-module: 2.16.1 @@ -9301,9 +8828,7 @@ snapshots: sax@1.4.1: {} - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 + scheduler@0.26.0: {} semver-compare@1.0.0: optional: true @@ -9343,8 +8868,6 @@ snapshots: setimmediate@1.0.5: {} - shallowequal@1.1.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9442,26 +8965,10 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 - source-map@0.5.7: {} - source-map@0.6.1: {} spawn-command@0.0.2: {} - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.21 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.21 - - spdx-license-ids@3.0.21: {} - split@0.3.3: dependencies: through: 2.3.8 @@ -9563,91 +9070,35 @@ snapshots: dependencies: ansi-regex: 6.1.0 - strip-indent@4.0.0: - dependencies: - min-indent: 1.0.1 - strip-json-comments@3.1.1: {} - style-search@0.1.0: {} - - styled-components@6.1.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + stylelint-config-css-modules@4.4.0(stylelint@16.20.0(typescript@5.8.3)): dependencies: - '@emotion/is-prop-valid': 1.2.2 - '@emotion/unitless': 0.8.1 - '@types/stylis': 4.2.5 - css-to-react-native: 3.2.0 - csstype: 3.1.3 - postcss: 8.4.49 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - shallowequal: 1.1.0 - stylis: 4.3.2 - tslib: 2.6.2 - - stylelint-config-css-modules@4.4.0(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - stylelint: 15.11.0(typescript@5.8.3) + stylelint: 16.20.0(typescript@5.8.3) optionalDependencies: - stylelint-scss: 6.12.0(stylelint@15.11.0(typescript@5.8.3)) + stylelint-scss: 6.12.0(stylelint@16.20.0(typescript@5.8.3)) - stylelint-config-recess-order@4.6.0(stylelint@15.11.0(typescript@5.8.3)): + stylelint-config-recess-order@7.1.0(stylelint-order@6.0.4(stylelint@16.20.0(typescript@5.8.3)))(stylelint@16.20.0(typescript@5.8.3)): dependencies: - stylelint: 15.11.0(typescript@5.8.3) - stylelint-order: 6.0.4(stylelint@15.11.0(typescript@5.8.3)) + stylelint: 16.20.0(typescript@5.8.3) + stylelint-order: 6.0.4(stylelint@16.20.0(typescript@5.8.3)) - stylelint-config-recommended-scss@6.0.0(postcss@8.5.3)(stylelint@15.11.0(typescript@5.8.3)): + stylelint-config-recommended@16.0.0(stylelint@16.20.0(typescript@5.8.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.3) - stylelint: 15.11.0(typescript@5.8.3) - stylelint-config-recommended: 7.0.0(stylelint@15.11.0(typescript@5.8.3)) - stylelint-scss: 4.7.0(stylelint@15.11.0(typescript@5.8.3)) - transitivePeerDependencies: - - postcss + stylelint: 16.20.0(typescript@5.8.3) - stylelint-config-recommended@13.0.0(stylelint@15.11.0(typescript@5.8.3)): + stylelint-config-standard@38.0.0(stylelint@16.20.0(typescript@5.8.3)): dependencies: - stylelint: 15.11.0(typescript@5.8.3) + stylelint: 16.20.0(typescript@5.8.3) + stylelint-config-recommended: 16.0.0(stylelint@16.20.0(typescript@5.8.3)) - stylelint-config-recommended@7.0.0(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - stylelint: 15.11.0(typescript@5.8.3) - - stylelint-config-standard-scss@4.0.0(postcss@8.5.3)(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - stylelint: 15.11.0(typescript@5.8.3) - stylelint-config-recommended-scss: 6.0.0(postcss@8.5.3)(stylelint@15.11.0(typescript@5.8.3)) - stylelint-config-standard: 25.0.0(stylelint@15.11.0(typescript@5.8.3)) - transitivePeerDependencies: - - postcss - - stylelint-config-standard@25.0.0(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - stylelint: 15.11.0(typescript@5.8.3) - stylelint-config-recommended: 7.0.0(stylelint@15.11.0(typescript@5.8.3)) - - stylelint-config-standard@34.0.0(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - stylelint: 15.11.0(typescript@5.8.3) - stylelint-config-recommended: 13.0.0(stylelint@15.11.0(typescript@5.8.3)) - - stylelint-config-styled-components@0.1.1: {} - - stylelint-order@6.0.4(stylelint@15.11.0(typescript@5.8.3)): + stylelint-order@6.0.4(stylelint@16.20.0(typescript@5.8.3)): dependencies: postcss: 8.5.3 postcss-sorting: 8.0.2(postcss@8.5.3) - stylelint: 15.11.0(typescript@5.8.3) + stylelint: 16.20.0(typescript@5.8.3) - stylelint-scss@4.7.0(stylelint@15.11.0(typescript@5.8.3)): - dependencies: - postcss-media-query-parser: 0.2.3 - postcss-resolve-nested-selector: 0.1.6 - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - stylelint: 15.11.0(typescript@5.8.3) - - stylelint-scss@6.12.0(stylelint@15.11.0(typescript@5.8.3)): + stylelint-scss@6.12.0(stylelint@16.20.0(typescript@5.8.3)): dependencies: css-tree: 3.1.0 is-plain-object: 5.0.0 @@ -9657,47 +9108,45 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.0 postcss-value-parser: 4.2.0 - stylelint: 15.11.0(typescript@5.8.3) + stylelint: 16.20.0(typescript@5.8.3) optional: true - stylelint@15.11.0(typescript@5.8.3): + stylelint@16.20.0(typescript@5.8.3): dependencies: - '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) - '@csstools/css-tokenizer': 2.4.1 - '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) - '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.0) + '@dual-bundle/import-meta-resolve': 4.1.0 balanced-match: 2.0.0 colord: 2.9.3 - cosmiconfig: 8.3.6(typescript@5.8.3) + cosmiconfig: 9.0.0(typescript@5.8.3) css-functions-list: 3.2.3 - css-tree: 2.3.1 + css-tree: 3.1.0 debug: 4.4.1 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 - file-entry-cache: 7.0.2 + file-entry-cache: 10.1.1 global-modules: 2.0.0 globby: 11.1.0 globjoin: 0.1.4 html-tags: 3.3.1 - ignore: 5.3.2 - import-lazy: 4.0.0 + ignore: 7.0.4 imurmurhash: 0.1.4 is-plain-object: 5.0.0 - known-css-properties: 0.29.0 + known-css-properties: 0.36.0 mathml-tag-names: 2.1.3 - meow: 10.1.5 + meow: 13.2.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 postcss: 8.5.3 postcss-resolve-nested-selector: 0.1.6 - postcss-safe-parser: 6.0.0(postcss@8.5.3) - postcss-selector-parser: 6.1.2 + postcss-safe-parser: 7.0.1(postcss@8.5.3) + postcss-selector-parser: 7.1.0 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 string-width: 4.2.3 - strip-ansi: 6.0.1 - style-search: 0.1.0 supports-hyperlinks: 3.2.0 svg-tags: 1.0.0 table: 6.9.0 @@ -9706,9 +9155,9 @@ snapshots: - supports-color - typescript - stylis@4.2.0: {} - - stylis@4.3.2: {} + sugarss@4.0.1(postcss@8.5.3): + dependencies: + postcss: 8.5.3 sumchecker@3.0.1: dependencies: @@ -9832,8 +9281,6 @@ snapshots: tree-kill@1.2.2: {} - trim-newlines@4.1.1: {} - truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 @@ -9842,17 +9289,8 @@ snapshots: dependencies: typescript: 5.8.3 - tslib@1.14.1: {} - - tslib@2.6.2: {} - tslib@2.8.1: {} - tsutils@3.21.0(typescript@5.1.6): - dependencies: - tslib: 1.14.1 - typescript: 5.1.6 - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -9860,10 +9298,10 @@ snapshots: type-fest@0.13.1: optional: true - type-fest@1.4.0: {} - type-fest@2.19.0: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -9907,12 +9345,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-plugin-styled-components@3.0.0(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - typescript@5.1.6: {} - typescript@5.8.3: {} unbox-primitive@1.1.0: @@ -9961,53 +9393,48 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@18.3.22)(react@18.3.1): + use-callback-ref@1.3.3(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - use-composed-ref@1.4.0(@types/react@18.3.22)(react@18.3.1): + use-composed-ref@1.4.0(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - use-isomorphic-layout-effect@1.2.1(@types/react@18.3.22)(react@18.3.1): + use-isomorphic-layout-effect@1.2.1(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - use-latest@1.3.0(@types/react@18.3.22)(react@18.3.1): + use-latest@1.3.0(@types/react@18.3.23)(react@19.1.0): dependencies: - react: 18.3.1 - use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.22)(react@18.3.1) + react: 19.1.0 + use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.23)(react@19.1.0) optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - use-sidecar@1.1.3(@types/react@18.3.22)(react@18.3.1): + use-sidecar@1.1.3(@types/react@18.3.23)(react@19.1.0): dependencies: detect-node-es: 1.1.0 - react: 18.3.1 + react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 - use-sync-external-store@1.5.0(react@18.3.1): + use-sync-external-store@1.5.0(react@19.1.0): dependencies: - react: 18.3.1 + react: 19.1.0 utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - value-or-function@4.0.0: {} varint@6.0.0: {} @@ -10070,12 +9497,12 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.17 - vite-plugin-ejs@1.7.0(vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2)): + vite-plugin-ejs@1.7.0(vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2)): dependencies: ejs: 3.1.10 - vite: 6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2) + vite: 6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2) - vite@6.3.5(@types/node@22.15.21)(sass-embedded@1.89.0)(terser@5.39.2): + vite@6.3.5(@types/node@22.15.32)(sass-embedded@1.89.0)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.2): dependencies: esbuild: 0.25.4 fdir: 6.4.4(picomatch@4.0.2) @@ -10084,9 +9511,10 @@ snapshots: rollup: 4.41.0 tinyglobby: 0.2.13 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.32 fsevents: 2.3.3 sass-embedded: 1.89.0 + sugarss: 4.0.1(postcss@8.5.3) terser: 5.39.2 void-elements@3.1.0: {} @@ -10199,10 +9627,6 @@ snapshots: yallist@4.0.0: {} - yaml@1.10.2: {} - - yargs-parser@20.2.9: {} - yargs-parser@21.1.1: {} yargs@17.7.2: @@ -10224,10 +9648,9 @@ snapshots: zod@3.25.23: {} - zustand@4.5.7(@types/react@18.3.22)(immer@9.0.21)(react@18.3.1): - dependencies: - use-sync-external-store: 1.5.0(react@18.3.1) + zustand@5.0.5(@types/react@18.3.23)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)): optionalDependencies: - '@types/react': 18.3.22 + '@types/react': 18.3.23 immer: 9.0.21 - react: 18.3.1 + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 00000000..9cc0bd2d --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + 'postcss-preset-mantine': {}, + }, +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a429a65c..0727e63d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -28,6 +28,8 @@ "action_other": "actions", "add": "add", "additionalParticipants": "additional participants", + "newVersion": "a new version has been installed ({{version}})", + "viewReleaseNotes": "view release notes", "albumGain": "album gain", "albumPeak": "album peak", "areYouSure": "are you sure?", @@ -268,6 +270,7 @@ "title": "lyric search" }, "queryEditor": { + "title": "query editor", "input_optionMatchAll": "match all", "input_optionMatchAny": "match any" }, @@ -421,6 +424,7 @@ "folders": "$t(entity.folder_other)", "genres": "$t(entity.genre_other)", "home": "$t(common.home)", + "myLibrary": "my library", "nowPlaying": "now playing", "playlists": "$t(entity.playlist_other)", "search": "$t(common.search)", @@ -774,6 +778,8 @@ }, "view": { "card": "card", + "grid": "grid", + "list": "list", "poster": "poster", "table": "table" } diff --git a/src/remote/app.tsx b/src/remote/app.tsx index db16ca3e..36c98207 100644 --- a/src/remote/app.tsx +++ b/src/remote/app.tsx @@ -1,7 +1,7 @@ import { MantineProvider } from '@mantine/core'; import { useEffect } from 'react'; -import './styles/global.scss'; +import './styles/global.css'; import { Shell } from '/@/remote/components/shell'; import { useIsDark, useReconnect } from '/@/remote/store'; @@ -16,8 +16,8 @@ export const App = () => { return ( { Modal: { styles: { body: { - background: 'var(--modal-bg)', + background: 'var(--theme-modal-bg)', height: '100vh', }, close: { marginRight: '0.5rem' }, content: { borderRadius: '5px' }, header: { - background: 'var(--modal-header-bg)', + background: 'var(--theme-modal-header-bg)', paddingBottom: '1rem', }, title: { fontSize: 'medium', fontWeight: 500 }, @@ -44,19 +44,8 @@ export const App = () => { }, }, defaultRadius: 'xs', - dir: 'ltr', focusRing: 'auto', - focusRingStyles: { - inputStyles: () => ({ - border: '1px solid var(--primary-color)', - }), - resetStyles: () => ({ outline: 'none' }), - styles: () => ({ - outline: '1px solid var(--primary-color)', - outlineOffset: '-1px', - }), - }, - fontFamily: 'var(--content-font-family)', + fontFamily: 'var(--theme-content-font-family)', fontSizes: { lg: '1.1rem', md: '1rem', @@ -65,8 +54,8 @@ export const App = () => { xs: '0.8rem', }, headings: { - fontFamily: 'var(--content-font-family)', - fontWeight: 700, + fontFamily: 'var(--theme-content-font-family)', + fontWeight: '700', }, other: {}, spacing: { @@ -77,8 +66,6 @@ export const App = () => { xs: '0rem', }, }} - withGlobalStyles - withNormalizeCSS > diff --git a/src/remote/components/buttons/image-button.tsx b/src/remote/components/buttons/image-button.tsx index 04837607..1d5744b2 100644 --- a/src/remote/components/buttons/image-button.tsx +++ b/src/remote/components/buttons/image-button.tsx @@ -12,7 +12,9 @@ export const ImageButton = () => { mr={5} onClick={() => toggleImage()} size="xl" - tooltip={showImage ? 'Hide Image' : 'Show Image'} + tooltip={{ + label: showImage ? 'Hide Image' : 'Show Image', + }} variant="default" > {showImage ? : } diff --git a/src/remote/components/buttons/reconnect-button.tsx b/src/remote/components/buttons/reconnect-button.tsx index 97ff42f2..997ce1b9 100644 --- a/src/remote/components/buttons/reconnect-button.tsx +++ b/src/remote/components/buttons/reconnect-button.tsx @@ -9,11 +9,13 @@ export const ReconnectButton = () => { return ( reconnect()} size="xl" - tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'} + tooltip={{ + label: connected ? 'Reconnect' : 'Not connected. Reconnect.', + }} variant="default" > diff --git a/src/remote/components/buttons/remote-button.module.css b/src/remote/components/buttons/remote-button.module.css new file mode 100644 index 00000000..2e852130 --- /dev/null +++ b/src/remote/components/buttons/remote-button.module.css @@ -0,0 +1,24 @@ +.button { + svg { + display: flex; + fill: var(--theme-colors-foreground); + } + + &:hover { + svg { + fill: var(--theme-colors-foreground); + } + } +} + +.button.active { + svg { + fill: var(--primary-color); + } + + &:hover { + svg { + fill: var(--primary-color) !important; + } + } +} diff --git a/src/remote/components/buttons/remote-button.tsx b/src/remote/components/buttons/remote-button.tsx index f211e81f..206acf10 100644 --- a/src/remote/components/buttons/remote-button.tsx +++ b/src/remote/components/buttons/remote-button.tsx @@ -1,53 +1,29 @@ -import { Button, type ButtonProps as MantineButtonProps, Tooltip } from '@mantine/core'; -import { forwardRef, MouseEvent, ReactNode, Ref } from 'react'; -import styled from 'styled-components'; +import clsx from 'clsx'; +import { forwardRef, ReactNode, Ref } from 'react'; -export interface ButtonProps extends StyledButtonProps { - tooltip: string; -} +import styles from './remote-button.module.css'; -interface StyledButtonProps extends MantineButtonProps { - $active?: boolean; +import { Button, ButtonProps } from '/@/shared/components/button/button'; + +interface RemoteButtonProps extends ButtonProps { children: ReactNode; - onClick?: (e: MouseEvent) => void; - onMouseDown?: (e: MouseEvent) => void; + isActive?: boolean; ref: Ref; } -const StyledButton = styled(Button)` - svg { - display: flex; - fill: ${({ $active: active }) => - active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'}; - stroke: var(--playerbar-btn-fg); - } - - &:hover { - background: var(--playerbar-btn-bg-hover); - - svg { - fill: ${({ $active: active }) => - active - ? 'var(--primary-color) !important' - : 'var(--playerbar-btn-fg-hover) !important'}; - } - } -`; - -export const RemoteButton = forwardRef( - ({ children, tooltip, ...props }: any, ref) => { +export const RemoteButton = forwardRef( + ({ children, isActive, tooltip, ...props }, ref) => { return ( - - - {children} - - + {children} + ); }, ); diff --git a/src/remote/components/buttons/theme-button.tsx b/src/remote/components/buttons/theme-button.tsx index 097776e5..bccdbfdf 100644 --- a/src/remote/components/buttons/theme-button.tsx +++ b/src/remote/components/buttons/theme-button.tsx @@ -3,7 +3,7 @@ import { RiMoonLine, RiSunLine } from 'react-icons/ri'; import { RemoteButton } from '/@/remote/components/buttons/remote-button'; import { useIsDark, useToggleDark } from '/@/remote/store'; -import { AppTheme } from '/@/shared/types/domain-types'; +import { AppTheme } from '/@/shared/themes/app-theme-types'; export const ThemeButton = () => { const isDark = useIsDark(); @@ -19,7 +19,9 @@ export const ThemeButton = () => { mr={5} onClick={() => toggleDark()} size="xl" - tooltip="Toggle Theme" + tooltip={{ + label: 'Toggle Theme', + }} variant="default" > {isDark ? : } diff --git a/src/remote/components/remote-container.tsx b/src/remote/components/remote-container.tsx index f2b295be..d0adab56 100644 --- a/src/remote/components/remote-container.tsx +++ b/src/remote/components/remote-container.tsx @@ -1,4 +1,4 @@ -import { Group, Image, Rating, Text, Title, Tooltip } from '@mantine/core'; +import { Image, Title } from '@mantine/core'; import formatDuration from 'format-duration'; import debounce from 'lodash/debounce'; import { useCallback } from 'react'; @@ -17,6 +17,10 @@ import { import { RemoteButton } from '/@/remote/components/buttons/remote-button'; import { WrapperSlider } from '/@/remote/components/wrapped-slider'; import { useInfo, useSend, useShowImage } from '/@/remote/store'; +import { Group } from '/@/shared/components/group/group'; +import { Rating } from '/@/shared/components/rating/rating'; +import { Text } from '/@/shared/components/text/text'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types'; export const RemoteContainer = () => { @@ -44,7 +48,7 @@ export const RemoteContainer = () => { Album: {song.album} Artist: {song.artistName} - + Duration: {formatDuration(song.duration)} {song.releaseDate && ( @@ -56,13 +60,15 @@ export const RemoteContainer = () => { </> )} <Group + gap={0} grow - spacing={0} > <RemoteButton disabled={!id} onClick={() => send({ event: 'previous' })} - tooltip="Previous track" + tooltip={{ + label: 'Previous track', + }} variant="default" > <RiSkipBackFill size={25} /> @@ -76,7 +82,9 @@ export const RemoteContainer = () => { send({ event: 'play' }); } }} - tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'} + tooltip={{ + label: id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play', + }} variant="default" > {id && status === PlayerStatus.PLAYING ? ( @@ -88,34 +96,40 @@ export const RemoteContainer = () => { <RemoteButton disabled={!id} onClick={() => send({ event: 'next' })} - tooltip="Next track" + tooltip={{ + label: 'Next track', + }} variant="default" > <RiSkipForwardFill size={25} /> </RemoteButton> </Group> <Group + gap={0} grow - spacing={0} > <RemoteButton - $active={shuffle || false} + isActive={shuffle || false} onClick={() => send({ event: 'shuffle' })} - tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'} + tooltip={{ + label: shuffle ? 'Shuffle tracks' : 'Shuffle disabled', + }} variant="default" > <RiShuffleFill size={25} /> </RemoteButton> <RemoteButton - $active={repeat !== undefined && repeat !== PlayerRepeat.NONE} + isActive={repeat !== undefined && repeat !== PlayerRepeat.NONE} onClick={() => send({ event: 'repeat' })} - tooltip={`Repeat ${ - repeat === PlayerRepeat.ONE - ? 'One' - : repeat === PlayerRepeat.ALL - ? 'all' - : 'none' - }`} + tooltip={{ + label: `Repeat ${ + repeat === PlayerRepeat.ONE + ? 'One' + : repeat === PlayerRepeat.ALL + ? 'all' + : 'none' + }`, + }} variant="default" > {repeat === undefined || repeat === PlayerRepeat.ONE ? ( @@ -125,14 +139,16 @@ export const RemoteContainer = () => { )} </RemoteButton> <RemoteButton - $active={song?.userFavorite} disabled={!id} + isActive={song?.userFavorite} onClick={() => { if (!id) return; send({ event: 'favorite', favorite: !song.userFavorite, id }); }} - tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'} + tooltip={{ + label: song?.userFavorite ? 'Unfavorite' : 'Favorite', + }} variant="default" > <RiHeartLine size={25} /> @@ -146,7 +162,7 @@ export const RemoteContainer = () => { <Rating onChange={debouncedSetRating} onDoubleClick={() => debouncedSetRating(0)} - sx={{ margin: 'auto' }} + style={{ margin: 'auto' }} value={song.userRating ?? 0} /> </Tooltip> @@ -169,8 +185,8 @@ export const RemoteContainer = () => { onChangeEnd={(e) => send({ event: 'volume', volume: e })} rightLabel={ <Text + fw={600} size="xs" - weight={600} > {volume ?? 0} </Text> diff --git a/src/remote/components/shell.tsx b/src/remote/components/shell.tsx index dff0ef08..dd80367b 100644 --- a/src/remote/components/shell.tsx +++ b/src/remote/components/shell.tsx @@ -1,14 +1,4 @@ -import { - AppShell, - Container, - Flex, - Grid, - Header, - Image, - MediaQuery, - Skeleton, - Title, -} from '@mantine/core'; +import { AppShell, Container, Flex, Grid, Image, Skeleton, Title } from '@mantine/core'; import { ImageButton } from '/@/remote/components/buttons/image-button'; import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button'; @@ -20,47 +10,35 @@ export const Shell = () => { const connected = useConnected(); return ( - <AppShell - header={ - <Header height={60}> - <Grid> - <Grid.Col span="auto"> - <div> - <Image - fit="contain" - height={60} - src="/favicon.ico" - width={60} - /> - </div> - </Grid.Col> - <MediaQuery - smallerThan="sm" - styles={{ display: 'none' }} - > - <Grid.Col - sm={6} - xs={0} - > - <Title ta="center">Feishin Remote - - + + + + +
+ +
+
+ + Feishin Remote + - - - - - - - -
- - } - padding="md" - > + + + + + + + + +
{connected ? ( diff --git a/src/remote/components/wrapped-slider.module.css b/src/remote/components/wrapped-slider.module.css new file mode 100644 index 00000000..b3807cd0 --- /dev/null +++ b/src/remote/components/wrapped-slider.module.css @@ -0,0 +1,21 @@ +.container { + display: flex; + width: 95%; + height: 20px; + margin: 10px 0; +} + +.wrapper { + display: flex; + flex: 6; + align-items: center; + height: 100%; +} + +.value-wrapper { + display: flex; + flex: 1; + align-self: flex-end; + justify-content: center; + max-width: 50px; +} diff --git a/src/remote/components/wrapped-slider.tsx b/src/remote/components/wrapped-slider.tsx index c0d83ed6..b17bf572 100644 --- a/src/remote/components/wrapped-slider.tsx +++ b/src/remote/components/wrapped-slider.tsx @@ -1,40 +1,16 @@ import { rem, Slider, SliderProps } from '@mantine/core'; import { ReactNode, useState } from 'react'; -import styled from 'styled-components'; -const SliderContainer = styled.div` - display: flex; - width: 95%; - height: 20px; - margin: 10px 0; -`; - -const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>` - display: flex; - flex: 1; - align-self: flex-end; - justify-content: center; - max-width: 50px; -`; - -const SliderWrapper = styled.div` - display: flex; - flex: 6; - align-items: center; - height: 100%; -`; +import styles from './wrapped-slider.module.css'; const PlayerbarSlider = ({ ...props }: SliderProps) => { return ( { }, track: { '&::before': { - backgroundColor: 'var(--playerbar-slider-track-bg)', right: 'calc(0.1rem * -1)', }, }, @@ -84,9 +59,9 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe const [seek, setSeek] = useState(0); return ( - - {leftLabel && {leftLabel}} - +
+ {leftLabel &&
{leftLabel}
} +
- - {rightLabel && {rightLabel}} - +
+ {rightLabel &&
{rightLabel}
} +
); }; diff --git a/src/remote/store/index.ts b/src/remote/store/index.ts index 3f5d81f9..02caf63b 100644 --- a/src/remote/store/index.ts +++ b/src/remote/store/index.ts @@ -1,11 +1,9 @@ -import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications'; - -import { hideNotification, showNotification } from '@mantine/notifications'; import merge from 'lodash/merge'; -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; +import { toast } from '/@/shared/components/toast/toast'; import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types'; export interface SettingsSlice extends SettingsState { @@ -36,55 +34,7 @@ const initialState: SettingsState = { showImage: true, }; -interface NotificationProps extends MantineNotificationProps { - type?: 'error' | 'warning'; -} - -const showToast = ({ type, ...props }: NotificationProps) => { - const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)'; - - const defaultTitle = type === 'warning' ? 'Warning' : 'Error'; - - const defaultDuration = type === 'error' ? 2000 : 1000; - - return showNotification({ - autoClose: defaultDuration, - styles: () => ({ - closeButton: { - '&:hover': { - background: 'transparent', - }, - }, - description: { - color: 'var(--toast-description-fg)', - fontSize: '1rem', - }, - loader: { - margin: '1rem', - }, - root: { - '&::before': { backgroundColor: color }, - background: 'var(--toast-bg)', - border: '2px solid var(--generic-border-color)', - bottom: '90px', - }, - title: { - color: 'var(--toast-title-fg)', - fontSize: '1.3rem', - }, - }), - title: defaultTitle, - ...props, - }); -}; - -const toast = { - error: (props: NotificationProps) => showToast({ type: 'error', ...props }), - hide: hideNotification, - warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }), -}; - -export const useRemoteStore = create()( +export const useRemoteStore = createWithEqualityFn()( persist( devtools( immer((set, get) => ({ diff --git a/src/remote/styles/global.css b/src/remote/styles/global.css new file mode 100644 index 00000000..bf7feea6 --- /dev/null +++ b/src/remote/styles/global.css @@ -0,0 +1,112 @@ +@import url('../../renderer/styles/ag-grid.css'); + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +body, +html { + position: absolute; + display: block; + width: 100%; + height: 100%; + overflow: hidden; + font-family: var(--theme-content-font-family); + font-size: var(--theme-root-font-size); + color: var(--theme-content-text-color); + user-select: none; + background: var(--theme-content-bg); +} + +@media only screen and (width < 640px) { + body, + html { + overflow-x: auto; + } +} + +#app { + height: inherit; +} + +*, +*::before, +*::after { + box-sizing: border-box; + text-rendering: optimizelegibility; + -webkit-tap-highlight-color: rgb(0 0 0 / 0%); + text-size-adjust: none; + outline: none; +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +/* ::-webkit-scrollbar-corner { + background: var(--theme-scrollbar-track-background); +} + +::-webkit-scrollbar-track { + background: var(--theme-scrollbar-track-background); +} + +::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-handle-background); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--theme-scrollbar-handle-hover-background); +} */ + +a { + text-decoration: none; +} + +button { + -webkit-app-region: no-drag; +} + +.overlay-scrollbar { + overflow: auto !important; +} + +.hide-scrollbar { + scrollbar-color: transparent transparent; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 1px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/remote/styles/global.scss b/src/remote/styles/global.scss deleted file mode 100644 index e74602c9..00000000 --- a/src/remote/styles/global.scss +++ /dev/null @@ -1,127 +0,0 @@ -@use '../../renderer/themes/default.scss'; -@use '../../renderer/themes/dark.scss'; -@use '../../renderer/themes/light.scss'; -@use '../../renderer/styles/ag-grid.scss'; - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body, -html { - position: absolute; - display: block; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: hidden; - color: var(--content-text-color); - background: var(--content-bg); - font-family: var(--content-font-family); - font-size: var(--root-font-size); - user-select: none; -} - -@media only screen and (max-width: 639px) { - body, - html { - overflow-x: auto; - } -} - -#app { - height: inherit; -} - -*, -*:before, -*:after { - box-sizing: border-box; - text-rendering: optimizeLegibility; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - -webkit-text-size-adjust: none; - outline: none; -} - -::-webkit-scrollbar { - width: 12px; - height: 12px; -} - -::-webkit-scrollbar-corner { - background: var(--scrollbar-track-bg); -} - -::-webkit-scrollbar-track { - background: var(--scrollbar-track-bg); -} - -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb-bg); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-bg-hover); -} - -a { - text-decoration: none; -} - -button { - -webkit-app-region: no-drag; -} - -.overlay-scrollbar { - overflow-y: overlay !important; - overflow-x: overlay !important; -} - -.hide-scrollbar { - scrollbar-width: thin; - scrollbar-color: transparent transparent; - - &::-webkit-scrollbar { - width: 1px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background-color: transparent; - } -} - -.hide-scrollbar::-webkit-scrollbar { - display: none; /* Safari and Chrome */ -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -.mantine-ScrollArea-thumb[data-state='visible'] { - animation: fadeIn 0.3s forwards; -} - -.mantine-ScrollArea-scrollbar[data-state='hidden'] { - animation: fadeOut 0.2s forwards; -} diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 10a4507f..7a62b872 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -2,8 +2,8 @@ import i18n from '/@/i18n/i18n'; import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller'; import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; -import { toast } from '/@/renderer/components/toast/index'; import { useAuthStore } from '/@/renderer/store'; +import { toast } from '/@/shared/components/toast/toast'; import { AuthenticationResponse, ControllerEndpoint, diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index dbddf4de..76a25c0d 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -7,10 +7,10 @@ import qs from 'qs'; import i18n from '/@/i18n/i18n'; import { authenticationFailure } from '/@/renderer/api/utils'; -import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { resultWithHeaders } from '/@/shared/api/utils'; +import { toast } from '/@/shared/components/toast/toast'; import { ServerListItem } from '/@/shared/types/domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index a733430a..21001388 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -5,8 +5,8 @@ import qs from 'qs'; import { z } from 'zod'; import i18n from '/@/i18n/i18n'; -import { toast } from '/@/renderer/components/toast/index'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; +import { toast } from '/@/shared/components/toast/toast'; import { ServerListItem } from '/@/shared/types/domain-types'; const c = initContract(); diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 09720a7f..8a445b95 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -1,5 +1,5 @@ -import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; +import { toast } from '/@/shared/components/toast/toast'; import { ServerListItem } from '/@/shared/types/types'; export const authenticationFailure = (currentServer: null | ServerListItem) => { diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index d6bff675..b0d5cf53 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -2,17 +2,21 @@ import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-mod import { ModuleRegistry } from '@ag-grid-community/core'; import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; import { MantineProvider } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; import isElectron from 'is-electron'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { initSimpleImg } from 'react-simple-img'; +import '@mantine/core/styles.css'; +import '@mantine/notifications/styles.css'; +import '@mantine/dates/styles.css'; -import './styles/global.scss'; +import './styles/global.css'; import '@ag-grid-community/styles/ag-grid.css'; import 'overlayscrollbars/overlayscrollbars.css'; +import './styles/overlayscrollbars.css'; + import i18n from '/@/i18n/i18n'; -import { toast } from '/@/renderer/components'; import { ContextMenuProvider } from '/@/renderer/features/context-menu'; import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import { PlayQueueHandlerContext } from '/@/renderer/features/player'; @@ -20,7 +24,6 @@ import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-co import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; -import { useTheme } from '/@/renderer/hooks'; import { useServerVersion } from '/@/renderer/hooks/use-server-version'; import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog'; import { AppRouter } from '/@/renderer/router/app-router'; @@ -34,71 +37,33 @@ import { useRemoteSettings, useSettingsStore, } from '/@/renderer/store'; +import { useAppTheme } from '/@/renderer/themes/use-app-theme'; import { sanitizeCss } from '/@/renderer/utils/sanitize'; import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data'; -import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/shared/types/types'; +import { toast } from '/@/shared/components/toast/toast'; +import { PlaybackType, PlayerStatus, WebAudio } from '/@/shared/types/types'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); -initSimpleImg({ threshold: 0.05 }, true); - const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const ipc = isElectron() ? window.api.ipc : null; const remote = isElectron() ? window.api.remote : null; const utils = isElectron() ? window.api.utils : null; export const App = () => { - const theme = useTheme(); - const accent = useSettingsStore((store) => store.general.accent); + const { mode, theme } = useAppTheme(); const language = useSettingsStore((store) => store.general.language); - const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio); - const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); + const { content, enabled } = useCssSettings(); const { type: playbackType } = usePlaybackSettings(); const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); const { clearQueue, restoreQueue } = useQueueControls(); const remoteSettings = useRemoteSettings(); - const textStyleRef = useRef(null); const cssRef = useRef(null); useDiscordRpc(); useServerVersion(); - useEffect(() => { - if (type === FontType.SYSTEM && system) { - const root = document.documentElement; - root.style.setProperty('--content-font-family', 'dynamic-font'); - - if (!textStyleRef.current) { - textStyleRef.current = document.createElement('style'); - document.body.appendChild(textStyleRef.current); - } - - textStyleRef.current.textContent = ` - @font-face { - font-family: "dynamic-font"; - src: local("${system}"); - }`; - } else if (type === FontType.CUSTOM && custom) { - const root = document.documentElement; - root.style.setProperty('--content-font-family', 'dynamic-font'); - - if (!textStyleRef.current) { - textStyleRef.current = document.createElement('style'); - document.body.appendChild(textStyleRef.current); - } - - textStyleRef.current.textContent = ` - @font-face { - font-family: "dynamic-font"; - src: url("feishin://${custom}"); - }`; - } else { - const root = document.documentElement; - root.style.setProperty('--content-font-family', builtIn); - } - }, [builtIn, custom, system, type]); - const [webAudio, setWebAudio] = useState(); useEffect(() => { @@ -121,16 +86,6 @@ export const App = () => { return () => {}; }, [content, enabled]); - useEffect(() => { - const root = document.documentElement; - root.style.setProperty('--primary-color', accent); - }, [accent]); - - useEffect(() => { - const root = document.documentElement; - root.style.setProperty('--image-fit', nativeImageAspect ? 'contain' : 'cover'); - }, [nativeImageAspect]); - const providerValue = useMemo(() => { return { handlePlayQueueAdd }; }, [handlePlayQueueAdd]); @@ -237,59 +192,14 @@ export const App = () => { return ( ({ - border: '1px solid var(--primary-color)', - }), - resetStyles: () => ({ outline: 'none' }), - styles: () => ({ - outline: '1px solid var(--primary-color)', - outlineOffset: '-1px', - }), - }, - fontFamily: 'var(--content-font-family)', - fontSizes: { - lg: '1.1rem', - md: '1rem', - sm: '0.9rem', - xl: '1.5rem', - xs: '0.8rem', - }, - headings: { - fontFamily: 'var(--content-font-family)', - fontWeight: 700, - }, - other: {}, - spacing: { - lg: '2rem', - md: '1rem', - sm: '0.5rem', - xl: '4rem', - xs: '0rem', - }, - }} - withGlobalStyles - withNormalizeCSS + defaultColorScheme={mode as 'dark' | 'light'} + theme={theme} > + diff --git a/src/renderer/components/accordion/index.tsx b/src/renderer/components/accordion/index.tsx deleted file mode 100644 index 8a579c72..00000000 --- a/src/renderer/components/accordion/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { AccordionProps as MantineAccordionProps } from '@mantine/core'; - -import { Accordion as MantineAccordion } from '@mantine/core'; -import styled from 'styled-components'; - -type AccordionProps = MantineAccordionProps; - -const StyledAccordion = styled(MantineAccordion)` - & .mantine-Accordion-panel { - background: var(--paper-bg); - } - - .mantine-Accordion-control { - background: var(--paper-bg); - } -`; - -export const Accordion = ({ children, ...props }: AccordionProps) => { - return {children}; -}; - -Accordion.Control = StyledAccordion.Control; -Accordion.Item = StyledAccordion.Item; -Accordion.Panel = StyledAccordion.Panel; diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 9aa8619c..f5dcbc2c 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -19,10 +19,10 @@ import { crossfadeHandler, gaplessHandler, } from '/@/renderer/components/audio-player/utils/list-handlers'; -import { toast } from '/@/renderer/components/toast'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store'; import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { toast } from '/@/shared/components/toast/toast'; import { PlaybackStyle, PlayerStatus } from '/@/shared/types/types'; export type AudioPlayerProgress = { diff --git a/src/renderer/components/badge/index.tsx b/src/renderer/components/badge/index.tsx deleted file mode 100644 index 309298db..00000000 --- a/src/renderer/components/badge/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { BadgeProps as MantineBadgeProps } from '@mantine/core'; - -import { createPolymorphicComponent, Badge as MantineBadge } from '@mantine/core'; -import styled from 'styled-components'; - -export type BadgeProps = MantineBadgeProps; - -const StyledBadge = styled(MantineBadge)` - border-radius: var(--badge-radius); - - .mantine-Badge-root { - color: var(--badge-fg); - } - - .mantine-Badge-inner { - color: var(--badge-fg); - } -`; - -const _Badge = ({ children, ...props }: BadgeProps) => { - return ( - - {children} - - ); -}; - -export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge); diff --git a/src/renderer/components/button/index.tsx b/src/renderer/components/button/index.tsx deleted file mode 100644 index 4cbb64dd..00000000 --- a/src/renderer/components/button/index.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { ButtonProps as MantineButtonProps, TooltipProps } from '@mantine/core'; -import type { Ref } from 'react'; - -import { createPolymorphicComponent, Button as MantineButton } from '@mantine/core'; -import { useTimeout } from '@mantine/hooks'; -import React, { forwardRef, useCallback, useRef, useState } from 'react'; -import styled from 'styled-components'; - -import { Spinner } from '/@/renderer/components/spinner'; -import { Tooltip } from '/@/renderer/components/tooltip'; - -export interface ButtonProps extends MantineButtonProps { - children: React.ReactNode; - loading?: boolean; - onClick?: (e: React.MouseEvent) => void; - onMouseDown?: (e: React.MouseEvent) => void; - tooltip?: Omit; -} - -interface StyledButtonProps extends ButtonProps { - ref: Ref; -} - -const StyledButton = styled(MantineButton)` - color: ${(props) => `var(--btn-${props.variant}-fg)`}; - background: ${(props) => `var(--btn-${props.variant}-bg)`}; - border: ${(props) => `var(--btn-${props.variant}-border)`}; - border-radius: ${(props) => `var(--btn-${props.variant}-radius)`}; - transition: - background 0.2s ease-in-out, - color 0.2s ease-in-out, - border 0.2s ease-in-out; - - svg { - fill: ${(props) => `var(--btn-${props.variant}-fg)`}; - transition: fill 0.2s ease-in-out; - } - - &:disabled { - color: ${(props) => `var(--btn-${props.variant}-fg)`}; - background: ${(props) => `var(--btn-${props.variant}-bg)`}; - - opacity: 0.6; - } - - &:not([data-disabled])&:hover { - color: ${(props) => `var(--btn-${props.variant}-fg) !important`}; - background: ${(props) => `var(--btn-${props.variant}-bg)`}; - filter: brightness(85%); - border: ${(props) => `var(--btn-${props.variant}-border-hover)`}; - - svg { - fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`}; - } - } - - &:not([data-disabled])&:focus-visible { - color: ${(props) => `var(--btn-${props.variant}-fg-hover)`}; - background: ${(props) => `var(--btn-${props.variant}-bg)`}; - filter: brightness(85%); - } - - & .mantine-Button-centerLoader { - display: none; - } - - & .mantine-Button-leftIcon { - display: flex; - height: 100%; - margin-right: 0.5rem; - } - - .mantine-Button-rightIcon { - display: flex; - margin-left: 0.5rem; - } -`; - -const ButtonChildWrapper = styled.span<{ $loading?: boolean }>` - color: ${(props) => props.$loading && 'transparent !important'}; -`; - -const SpinnerWrapper = styled.div` - position: absolute; - top: 50%; - left: 50%; - transform: translate3d(-50%, -50%, 0); -`; - -export const _Button = forwardRef( - ({ children, tooltip, ...props }: ButtonProps, ref) => { - if (tooltip) { - return ( - - - {children} - {props.loading && ( - - - - )} - - - ); - } - - return ( - - {children} - {props.loading && ( - - - - )} - - ); - }, -); - -export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button); - -interface HoldButtonProps extends ButtonProps { - timeoutProps: { - callback: () => void; - duration: number; - }; -} - -export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => { - const [, setTimeoutRemaining] = useState(timeoutProps.duration); - const [isRunning, setIsRunning] = useState(false); - const intervalRef = useRef(0); - - const callback = () => { - timeoutProps.callback(); - setTimeoutRemaining(timeoutProps.duration); - clearInterval(intervalRef.current); - setIsRunning(false); - }; - - const { clear, start } = useTimeout(callback, timeoutProps.duration); - - const startTimeout = useCallback(() => { - if (isRunning) { - clearInterval(intervalRef.current); - setIsRunning(false); - clear(); - } else { - setIsRunning(true); - start(); - - const intervalId = window.setInterval(() => { - setTimeoutRemaining((prev) => prev - 100); - }, 100); - - intervalRef.current = intervalId; - } - }, [clear, isRunning, start]); - - return ( - - ); -}; diff --git a/src/renderer/components/card/album-card.tsx b/src/renderer/components/card/album-card.tsx deleted file mode 100644 index deaa969d..00000000 --- a/src/renderer/components/card/album-card.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import type { CardRoute, CardRow, Play, PlayQueueAddOptions } from '/@/shared/types/types'; - -import { Center } from '@mantine/core'; -import { useCallback } from 'react'; -import { RiAlbumFill } from 'react-icons/ri'; -import { generatePath, useNavigate } from 'react-router'; -import { SimpleImg } from 'react-simple-img'; -import styled from 'styled-components'; - -import { CardControls } from '/@/renderer/components/card/card-controls'; -import { CardRows } from '/@/renderer/components/card/card-rows'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Album, AlbumArtist, Artist, LibraryItem } from '/@/shared/types/domain-types'; - -const CardWrapper = styled.div<{ - link?: boolean; -}>` - padding: 1rem; - cursor: ${({ link }) => link && 'pointer'}; - background: var(--card-default-bg); - border-radius: var(--card-default-radius); - transition: - border 0.2s ease-in-out, - background 0.2s ease-in-out; - - &:hover { - background: var(--card-default-bg-hover); - } - - &:hover div { - opacity: 1; - } - - &:hover * { - &::before { - opacity: 0.5; - } - } - - &:focus-visible { - outline: 1px solid #fff; - } -`; - -const StyledCard = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - height: 100%; - padding: 0; - border-radius: var(--card-default-radius); -`; - -const ImageSection = styled.div` - position: relative; - display: flex; - justify-content: center; - border-radius: var(--card-default-radius); - - &::before { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 100%; - height: 100%; - content: ''; - user-select: none; - background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); - opacity: 0; - transition: all 0.2s ease-in-out; - } -`; - -const Image = styled(SimpleImg)` - border-radius: var(--card-default-radius); - box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 20%); -`; - -const ControlsContainer = styled.div` - position: absolute; - bottom: 0; - z-index: 50; - width: 100%; - opacity: 0; - transition: all 0.2s ease-in-out; -`; - -const DetailSection = styled.div` - display: flex; - flex-direction: column; -`; - -const Row = styled.div<{ $secondary?: boolean }>` - width: 100%; - max-width: 100%; - height: 22px; - padding: 0 0.2rem; - overflow: hidden; - color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; - text-overflow: ellipsis; - white-space: nowrap; - user-select: none; -`; - -interface BaseGridCardProps { - controls: { - cardRows: CardRow[]; - itemType: LibraryItem; - playButtonBehavior: Play; - route: CardRoute; - }; - data: any; - handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; - loading?: boolean; - size: number; -} - -export const AlbumCard = ({ - controls, - data, - handlePlayQueueAdd, - loading, - size, -}: BaseGridCardProps) => { - const navigate = useNavigate(); - const { cardRows, itemType, route } = controls; - - const handleNavigate = useCallback(() => { - navigate( - generatePath( - route.route as string, - route.slugs?.reduce((acc, slug) => { - return { - ...acc, - [slug.slugProperty]: data[slug.idProperty], - }; - }, {}), - ), - ); - }, [data, navigate, route.route, route.slugs]); - - if (!loading) { - return ( - - - - {data?.imageUrl ? ( - - ) : ( -
- -
- )} - - - -
- - - -
-
- ); - } - - return ( - - - - - - - {(cardRows || []).map((_row: CardRow, index: number) => ( - 0 ? '50%' : '90%') : '100%'} - > - - - ))} - - - - ); -}; diff --git a/src/renderer/components/card/card-controls.module.css b/src/renderer/components/card/card-controls.module.css new file mode 100644 index 00000000..6701a475 --- /dev/null +++ b/src/renderer/components/card/card-controls.module.css @@ -0,0 +1,71 @@ +.play-button { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background-color: rgb(255 255 255); + border: none; + border-radius: 50%; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + transition: scale 0.2s linear; + + &:hover { + opacity: 1; + scale: 1.1; + } + + &:active { + opacity: 1; + scale: 1; + } + + svg { + fill: rgb(0 0 0); + stroke: rgb(0 0 0); + } +} + +.secondary-button { + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + transition: scale 0.2s linear; + + &:hover { + opacity: 1; + scale: 1.1; + } + + &:active { + opacity: 1; + scale: 1; + } +} + +.grid-card-controls-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.controls-row { + width: 100%; + height: calc(100% / 3); +} + +.bottom-controls { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 1rem 0.5rem; +} + +.favorite-wrapper { + svg { + fill: var(--theme-colors-primary-filled); + } +} diff --git a/src/renderer/components/card/card-controls.tsx b/src/renderer/components/card/card-controls.tsx index 7e96de75..db3df51b 100644 --- a/src/renderer/components/card/card-controls.tsx +++ b/src/renderer/components/card/card-controls.tsx @@ -1,110 +1,21 @@ import type { PlayQueueAddOptions } from '/@/shared/types/types'; -import type { UnstyledButtonProps } from '@mantine/core'; import type { MouseEvent } from 'react'; -import { Group } from '@mantine/core'; -import React from 'react'; -import { RiHeartFill, RiHeartLine, RiMore2Fill, RiPlayFill } from 'react-icons/ri'; -import styled from 'styled-components'; +import styles from './card-controls.module.css'; -import { _Button } from '/@/renderer/components/button'; import { ALBUM_CONTEXT_MENU_ITEMS, ARTIST_CONTEXT_MENU_ITEMS, } from '/@/renderer/features/context-menu/context-menu-items'; import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { LibraryItem } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -type PlayButtonType = React.ComponentPropsWithoutRef<'button'> & UnstyledButtonProps; - -const PlayButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - background-color: rgb(255 255 255); - border: none; - border-radius: 50%; - opacity: 0.8; - transition: opacity 0.2s ease-in-out; - transition: scale 0.2s linear; - - &:hover { - opacity: 1; - scale: 1.1; - } - - &:active { - opacity: 1; - scale: 1; - } - - svg { - fill: rgb(0 0 0); - stroke: rgb(0 0 0); - } -`; - -const SecondaryButton = styled(_Button)` - opacity: 0.8; - transition: opacity 0.2s ease-in-out; - transition: scale 0.2s linear; - - &:hover { - opacity: 1; - scale: 1.1; - } - - &:active { - opacity: 1; - scale: 1; - } -`; - -const GridCardControlsContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; -`; - -const ControlsRow = styled.div` - width: 100%; - height: calc(100% / 3); -`; - -// const TopControls = styled(ControlsRow)` -// display: flex; -// align-items: flex-start; -// justify-content: space-between; -// padding: 0.5rem; -// `; - -// const CenterControls = styled(ControlsRow)` -// display: flex; -// align-items: center; -// justify-content: center; -// padding: 0.5rem; -// `; - -const BottomControls = styled(ControlsRow)` - display: flex; - align-items: flex-end; - justify-content: space-between; - padding: 1rem 0.5rem; -`; - -const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` - svg { - fill: ${(props) => props.isFavorite && 'var(--primary-color)'}; - } -`; - export const CardControls = ({ handlePlayQueueAdd, itemData, @@ -134,46 +45,45 @@ export const CardControls = ({ ); return ( - - - - - - - +
+ + + + { e.preventDefault(); e.stopPropagation(); handleContextMenu(e, [itemData]); }} p={5} - sx={{ svg: { fill: 'white !important' } }} + style={{ svg: { fill: 'white !important' } }} variant="subtle" > - - + + - - +
+ ); }; diff --git a/src/renderer/components/card/card-rows.module.css b/src/renderer/components/card/card-rows.module.css new file mode 100644 index 00000000..52547251 --- /dev/null +++ b/src/renderer/components/card/card-rows.module.css @@ -0,0 +1,15 @@ +.row { + width: 100%; + max-width: 100%; + height: 22px; + padding: 0 0.2rem; + overflow: hidden; + text-overflow: ellipsis; + color: var(--theme-colors-foreground); + white-space: nowrap; + user-select: none; +} + +.row.secondary { + color: var(--theme-colors-foreground-muted); +} diff --git a/src/renderer/components/card/card-rows.tsx b/src/renderer/components/card/card-rows.tsx index d3b1441c..afbd9c44 100644 --- a/src/renderer/components/card/card-rows.tsx +++ b/src/renderer/components/card/card-rows.tsx @@ -1,27 +1,17 @@ +import clsx from 'clsx'; import formatDuration from 'format-duration'; import React from 'react'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Text } from '/@/renderer/components/text'; +import styles from './card-rows.module.css'; + import { AppRoute } from '/@/renderer/router/routes'; import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format'; +import { Text } from '/@/shared/components/text/text'; import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/shared/types/domain-types'; import { CardRow } from '/@/shared/types/types'; -const Row = styled.div<{ $secondary?: boolean }>` - width: 100%; - max-width: 100%; - height: 22px; - padding: 0 0.2rem; - overflow: hidden; - color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; - text-overflow: ellipsis; - white-space: nowrap; - user-select: none; -`; - interface CardRowsProps { data: any; rows: CardRow[] | CardRow[] | CardRow[]; @@ -33,17 +23,19 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { {rows.map((row, index: number) => { if (row.arrayProperty && row.route) { return ( - 0} +
0, + })} key={`row-${row.property}-${index}`} > {data[row.property].map((item: any, itemIndex: number) => ( {itemIndex > 0 && ( { )}{' '} 0} component={Link} + isLink + isMuted={index > 0} + isNoSelect onClick={(e) => e.stopPropagation()} overflow="hidden" size={index > 0 ? 'sm' : 'md'} @@ -79,17 +71,22 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { ))} - +
); } if (row.arrayProperty) { return ( - +
0, + })} + key={`row-${row.property}`} + > {data[row.property].map((item: any) => ( 0} + isMuted={index > 0} + isNoSelect key={`${data.id}-${item.id}`} overflow="hidden" size={index > 0 ? 'sm' : 'md'} @@ -98,17 +95,22 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { (row.format ? row.format(item) : item[row.arrayProperty])} ))} - +
); } return ( - +
0, + })} + key={`row-${row.property}`} + > {row.route ? ( e.stopPropagation()} overflow="hidden" to={generatePath( @@ -125,15 +127,15 @@ export const CardRows = ({ data, rows }: CardRowsProps) => { ) : ( 0} + isMuted={index > 0} + isNoSelect overflow="hidden" size={index > 0 ? 'sm' : 'md'} > {data && (row.format ? row.format(data) : data[row.property])} )} - +
); })} diff --git a/src/renderer/components/card/index.tsx b/src/renderer/components/card/index.tsx deleted file mode 100644 index a3b57698..00000000 --- a/src/renderer/components/card/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './album-card'; -export * from './card-rows'; diff --git a/src/renderer/components/card/poster-card.module.css b/src/renderer/components/card/poster-card.module.css new file mode 100644 index 00000000..224e72cf --- /dev/null +++ b/src/renderer/components/card/poster-card.module.css @@ -0,0 +1,67 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: auto; + + &:global(.card-controls) { + opacity: 0; + } +} + +.container.hidden { + opacity: 0; +} + +.image-container { + position: relative; + display: flex; + align-items: center; + aspect-ratio: 1/1; + overflow: hidden; + background: var(--theme-card-default-bg); + border-radius: var(--theme-card-poster-radius); + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + user-select: none; + content: ''; + background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); + opacity: 0; + transition: all 0.2s ease-in-out; + } + + &:hover { + &::before { + opacity: 0.5; + } + } + + &:hover:global(.card-controls) { + opacity: 1; + } +} + +.image { + width: 100%; + max-width: 100%; + height: 100% !important; + max-height: 100%; + border: 0; + + img { + height: 100%; + object-fit: var(--theme-image-fit); + } +} + +.detail-container { + margin-top: 0.5rem; +} diff --git a/src/renderer/components/card/poster-card.tsx b/src/renderer/components/card/poster-card.tsx index efdbd5f4..24e42241 100644 --- a/src/renderer/components/card/poster-card.tsx +++ b/src/renderer/components/card/poster-card.tsx @@ -1,12 +1,13 @@ -import { Center, Stack } from '@mantine/core'; -import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri'; +import { useState } from 'react'; import { generatePath, Link } from 'react-router-dom'; -import { SimpleImg } from 'react-simple-img'; -import styled, { css } from 'styled-components'; -import { CardRows } from '/@/renderer/components/card'; -import { Skeleton } from '/@/renderer/components/skeleton'; +import styles from './poster-card.module.css'; + +import { CardRows } from '/@/renderer/components/card/card-rows'; import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls'; +import { Image } from '/@/shared/components/image/image'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Stack } from '/@/shared/components/stack/stack'; import { Album, AlbumArtist, Artist, LibraryItem } from '/@/shared/types/domain-types'; import { CardRoute, CardRow, Play, PlayQueueAddOptions } from '/@/shared/types/types'; @@ -28,85 +29,14 @@ interface BaseGridCardProps { isLoading?: boolean; } -const PosterCardContainer = styled.div<{ $isHidden?: boolean }>` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - pointer-events: auto; - opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)}; - - .card-controls { - opacity: 0; - } -`; - -const ImageContainerStyles = css` - position: relative; - display: flex; - align-items: center; - aspect-ratio: 1/1; - overflow: hidden; - background: var(--card-default-bg); - border-radius: var(--card-poster-radius); - - &::before { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 100%; - height: 100%; - content: ''; - user-select: none; - background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); - opacity: 0; - transition: all 0.2s ease-in-out; - } - - &:hover { - &::before { - opacity: 0.5; - } - } - - &:hover .card-controls { - opacity: 1; - } -`; - -const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>` - ${ImageContainerStyles} -`; - -const ImageContainerSkeleton = styled.div` - ${ImageContainerStyles} -`; - -const Image = styled(SimpleImg)` - width: 100%; - max-width: 100%; - height: 100% !important; - max-height: 100%; - border: 0; - - img { - height: 100%; - object-fit: var(--image-fit); - } -`; - -const DetailContainer = styled.div` - margin-top: 0.5rem; -`; - export const PosterCard = ({ controls, data, isLoading, uniqueId, }: BaseGridCardProps & { uniqueId: string }) => { + const [isHovered, setIsHovered] = useState(false); + if (!isLoading) { const path = generatePath( controls.route.route as string, @@ -118,90 +48,57 @@ export const PosterCard = ({ }, {}), ); - let Placeholder = RiAlbumFill; - - switch (controls.itemType) { - case LibraryItem.ALBUM: - Placeholder = RiAlbumFill; - break; - case LibraryItem.ALBUM_ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.PLAYLIST: - Placeholder = RiPlayListFill; - break; - default: - Placeholder = RiAlbumFill; - break; - } - return ( - - setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + - {data?.imageUrl ? ( - - ) : ( -
- -
- )} + -
- + +
- - +
+ ); } return ( - - - - - - +
+
+ +
+
+ {(controls?.cardRows || []).map((row, index) => ( ))} - - +
+
); }; diff --git a/src/renderer/components/checkbox/index.tsx b/src/renderer/components/checkbox/index.tsx deleted file mode 100644 index bb453692..00000000 --- a/src/renderer/components/checkbox/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CheckboxProps, Checkbox as MantineCheckbox } from '@mantine/core'; -import { forwardRef } from 'react'; -import styled from 'styled-components'; - -const StyledCheckbox = styled(MantineCheckbox)` - & .mantine-Checkbox-input { - background-color: var(--input-bg); - - &:checked { - background-color: var(--primary-color); - border-color: var(--primary-color); - } - - &:hover:not(:checked) { - background-color: var(--primary-color); - opacity: 0.5; - } - - transition: none; - } -`; - -export const Checkbox = forwardRef( - ({ ...props }: CheckboxProps, ref) => { - return ( - - ); - }, -); diff --git a/src/renderer/components/context-menu/context-menu.module.css b/src/renderer/components/context-menu/context-menu.module.css new file mode 100644 index 00000000..cc8d7ea7 --- /dev/null +++ b/src/renderer/components/context-menu/context-menu.module.css @@ -0,0 +1,35 @@ +.container { + position: absolute; + z-index: 1000; + padding: var(--theme-spacing-xs); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + border-radius: var(--theme-radius-md); + box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); +} + +.context-menu-button { + display: flex; + padding: var(--theme-spacing-sm); + font-family: var(--theme-content-font-family); + font-size: var(--theme-font-size-sm); + font-weight: 500; + color: var(--theme-colors-surface-foreground); + text-align: left; + cursor: default; + background: var(--theme-colors-surface); + border: none; + + &:hover { + background: lighten(var(--theme-colors-surface), 10%); + } + + &:disabled { + background: transparent; + opacity: 0.6; + } +} + +.left { + margin-right: 3rem; +} diff --git a/src/renderer/components/context-menu/context-menu.tsx b/src/renderer/components/context-menu/context-menu.tsx new file mode 100644 index 00000000..9658c464 --- /dev/null +++ b/src/renderer/components/context-menu/context-menu.tsx @@ -0,0 +1,91 @@ +import { motion, Variants } from 'motion/react'; +import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react'; + +import styles from './context-menu.module.css'; + +import { Group } from '/@/shared/components/group/group'; + +interface ContextMenuProps { + children: ReactNode; + maxWidth?: number; + minWidth?: number; + xPos: number; + yPos: number; +} + +export const ContextMenuButton = forwardRef( + ( + { + children, + leftIcon, + rightIcon, + ...props + }: ComponentPropsWithoutRef<'button'> & { + leftIcon?: ReactNode; + rightIcon?: ReactNode; + }, + ref: any, + ) => { + return ( + + ); + }, +); + +const variants: Variants = { + closed: { + opacity: 0, + transition: { + duration: 0.1, + }, + }, + open: { + opacity: 1, + transition: { + duration: 0.1, + }, + }, +}; + +export const ContextMenu = forwardRef( + ({ children, maxWidth, minWidth, xPos, yPos }: ContextMenuProps, ref: Ref) => { + return ( + + {children} + + ); + }, +); diff --git a/src/renderer/components/context-menu/index.tsx b/src/renderer/components/context-menu/index.tsx deleted file mode 100644 index aff352a2..00000000 --- a/src/renderer/components/context-menu/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Box, Flex, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core'; -import { motion, Variants } from 'framer-motion'; -import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react'; -import styled from 'styled-components'; - -interface ContextMenuProps { - children: ReactNode; - maxWidth?: number; - minWidth?: number; - xPos: number; - yPos: number; -} - -const ContextMenuContainer = styled(motion.div)>` - position: absolute; - top: ${({ yPos }) => yPos}px !important; - left: ${({ xPos }) => xPos}px !important; - z-index: 1000; - min-width: ${({ minWidth }) => minWidth}px; - max-width: ${({ maxWidth }) => maxWidth}px; - background: var(--dropdown-menu-bg); - border-radius: var(--dropdown-menu-border-radius); - box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); - - button:first-child { - border-top-left-radius: var(--dropdown-menu-border-radius); - border-top-right-radius: var(--dropdown-menu-border-radius); - } - - button:last-child { - border-bottom-right-radius: var(--dropdown-menu-border-radius); - border-bottom-left-radius: var(--dropdown-menu-border-radius); - } -`; - -export const StyledContextMenuButton = styled(UnstyledButton)` - padding: var(--dropdown-menu-item-padding); - font-family: var(--content-font-family); - font-weight: 500; - color: var(--dropdown-menu-fg); - text-align: left; - cursor: default; - background: var(--dropdown-menu-bg); - border: none; - - & .mantine-Button-inner { - justify-content: flex-start; - } - - &:hover { - background: var(--dropdown-menu-bg-hover); - } - - &:disabled { - background: transparent; - opacity: 0.6; - } -`; - -export const ContextMenuButton = forwardRef( - ( - { - children, - leftIcon, - rightIcon, - ...props - }: ComponentPropsWithoutRef<'button'> & - UnstyledButtonProps & { - leftIcon?: ReactNode; - rightIcon?: ReactNode; - }, - ref: any, - ) => { - return ( - - - - {leftIcon} - {children} - - {rightIcon} - - - ); - }, -); - -const variants: Variants = { - closed: { - opacity: 0, - transition: { - duration: 0.1, - }, - }, - open: { - opacity: 1, - transition: { - duration: 0.1, - }, - }, -}; - -export const ContextMenu = forwardRef( - ({ children, maxWidth, minWidth, xPos, yPos }: ContextMenuProps, ref: Ref) => { - return ( - - {children} - - ); - }, -); diff --git a/src/renderer/components/date-picker/index.tsx b/src/renderer/components/date-picker/index.tsx deleted file mode 100644 index 5a1e9ab6..00000000 --- a/src/renderer/components/date-picker/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { DatePickerProps as MantineDatePickerProps } from '@mantine/dates'; - -import { DatePicker as MantineDatePicker } from '@mantine/dates'; -import styled from 'styled-components'; - -interface DatePickerProps extends MantineDatePickerProps { - maxWidth?: number | string; - width?: number | string; -} - -const StyledDatePicker = styled(MantineDatePicker)` - & .mantine-DatePicker-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-DatePicker-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-DatePicker-required { - color: var(--secondary-color); - } - - & .mantine-DatePicker-label { - font-family: var(--label-font-family); - } - - & .mantine-DateRangePicker-disabled { - opacity: 0.6; - } -`; - -export const DatePicker = ({ maxWidth, width, ...props }: DatePickerProps) => { - return ( - - ); -}; diff --git a/src/renderer/components/dialog/index.tsx b/src/renderer/components/dialog/index.tsx deleted file mode 100644 index 138de285..00000000 --- a/src/renderer/components/dialog/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { DialogProps as MantineDialogProps } from '@mantine/core'; - -import { Dialog as MantineDialog } from '@mantine/core'; -import styled from 'styled-components'; - -const StyledDialog = styled(MantineDialog)` - &.mantine-Dialog-root { - background-color: var(--modal-bg); - box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); - } -`; - -export const Dialog = ({ ...props }: MantineDialogProps) => { - return ; -}; diff --git a/src/renderer/components/dropdown-menu/index.tsx b/src/renderer/components/dropdown-menu/index.tsx deleted file mode 100644 index 0df315a6..00000000 --- a/src/renderer/components/dropdown-menu/index.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { - MenuDividerProps as MantineMenuDividerProps, - MenuDropdownProps as MantineMenuDropdownProps, - MenuItemProps as MantineMenuItemProps, - MenuLabelProps as MantineMenuLabelProps, - MenuProps as MantineMenuProps, -} from '@mantine/core'; - -import { createPolymorphicComponent, Menu as MantineMenu } from '@mantine/core'; -import { ReactNode } from 'react'; -import { RiArrowLeftSFill } from 'react-icons/ri'; -import styled from 'styled-components'; - -type MenuDividerProps = MantineMenuDividerProps; -type MenuDropdownProps = MantineMenuDropdownProps; -interface MenuItemProps extends MantineMenuItemProps { - $danger?: boolean; - $isActive?: boolean; - children: ReactNode; -} -type MenuLabelProps = MantineMenuLabelProps; -type MenuProps = MantineMenuProps; - -const StyledMenu = styled(MantineMenu)``; - -const StyledMenuLabel = styled(MantineMenu.Label)` - padding: 0.5rem; - font-family: var(--content-font-family); -`; - -const StyledMenuItem = styled(MantineMenu.Item)` - position: relative; - padding: var(--dropdown-menu-item-padding); - font-family: var(--content-font-family); - font-size: var(--dropdown-menu-item-font-size); - - cursor: default; - - &:disabled { - opacity: 0.6; - } - - &:hover { - background-color: var(--dropdown-menu-bg-hover); - } - - & .mantine-Menu-itemLabel { - margin-right: 2rem; - margin-left: 1rem; - color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')}; - } - - & .mantine-Menu-itemRightSection { - display: flex; - } -`; - -const StyledMenuDropdown = styled(MantineMenu.Dropdown)` - padding: 0; - margin: 0; - background: var(--dropdown-menu-bg); - filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%)); - border: var(--dropdown-menu-border); - border-radius: var(--dropdown-menu-border-radius); - - /* *:first-child { - border-top-left-radius: var(--dropdown-menu-border-radius); - border-top-right-radius: var(--dropdown-menu-border-radius); - } - - *:last-child { - border-bottom-right-radius: var(--dropdown-menu-border-radius); - border-bottom-left-radius: var(--dropdown-menu-border-radius); - } */ -`; - -const StyledMenuDivider = styled(MantineMenu.Divider)` - padding: 0; - margin: 0; -`; - -export const DropdownMenu = ({ children, ...props }: MenuProps) => { - return ( - - {children} - - ); -}; - -const MenuLabel = ({ children, ...props }: MenuLabelProps) => { - return {children}; -}; - -const pMenuItem = ({ $danger, $isActive, children, ...props }: MenuItemProps) => { - return ( - } - {...props} - > - {children} - - ); -}; - -const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => { - return {children}; -}; - -const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem); - -const MenuDivider = ({ ...props }: MenuDividerProps) => { - return ; -}; - -DropdownMenu.Label = MenuLabel; -DropdownMenu.Item = MenuItem; -DropdownMenu.Target = MantineMenu.Target; -DropdownMenu.Dropdown = MenuDropdown; -DropdownMenu.Divider = MenuDivider; diff --git a/src/renderer/components/feature-carousel/feature-carousel.module.css b/src/renderer/components/feature-carousel/feature-carousel.module.css new file mode 100644 index 00000000..75c67339 --- /dev/null +++ b/src/renderer/components/feature-carousel/feature-carousel.module.css @@ -0,0 +1,73 @@ +.carousel { + position: relative; + height: 35vh; + min-height: 250px; + max-height: 300px; + padding: var(--theme-spacing-md); + overflow: hidden; +} + +.grid { + display: grid; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-template-columns: 200px minmax(0, 1fr); + grid-auto-columns: 1fr; + width: 100%; + max-width: 100%; + height: 100%; +} + +.image-column { + z-index: 15; + display: flex; + grid-area: image; + align-items: flex-end; +} + +.info-column { + z-index: 15; + display: flex; + grid-area: info; + align-items: flex-end; + width: 100%; + max-width: 100%; + padding-left: 1rem; +} + +.background-image { + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 150%; + height: 150%; + user-select: none; + object-fit: var(--theme-image-fit); + object-position: 0 30%; + filter: blur(24px); +} + +.background-image-overlay { + position: absolute; + top: 0; + left: 0; + z-index: 10; + width: 100%; + height: 100%; + background: linear-gradient(180deg, rgb(25 26 28 / 30%), var(--theme-colors-background)); +} + +.wrapper { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.title-wrapper { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/src/renderer/components/feature-carousel/index.tsx b/src/renderer/components/feature-carousel/feature-carousel.tsx similarity index 55% rename from src/renderer/components/feature-carousel/index.tsx rename to src/renderer/components/feature-carousel/feature-carousel.tsx index 250dec61..521ec559 100644 --- a/src/renderer/components/feature-carousel/index.tsx +++ b/src/renderer/components/feature-carousel/feature-carousel.tsx @@ -1,99 +1,28 @@ -import type { Variants } from 'framer-motion'; +import type { Variants } from 'motion/react'; import type { MouseEvent } from 'react'; -import { Group, Image, Stack } from '@mantine/core'; -import { AnimatePresence, motion } from 'framer-motion'; +import { AnimatePresence, motion } from 'motion/react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'; import { generatePath, Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Badge } from '/@/renderer/components/badge'; -import { Button } from '/@/renderer/components/button'; -import { TextTitle } from '/@/renderer/components/text-title'; +import styles from './feature-carousel.module.css'; + import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; +import { PlayButton } from '/@/renderer/features/shared'; import { AppRoute } from '/@/renderer/router/routes'; import { usePlayButtonBehavior } from '/@/renderer/store'; +import { Badge } from '/@/shared/components/badge/badge'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Image } from '/@/shared/components/image/image'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; +import { Text } from '/@/shared/components/text/text'; import { Album, LibraryItem } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -const Carousel = styled(motion.div)` - position: relative; - height: 35vh; - min-height: 250px; - padding: 2rem; - overflow: hidden; - background: linear-gradient(180deg, var(--main-bg), rgb(25 26 28 / 60%)); - border-radius: 1rem; -`; - -const Grid = styled.div` - display: grid; - grid-template-areas: 'image info'; - grid-template-rows: 1fr; - grid-template-columns: 200px minmax(0, 1fr); - grid-auto-columns: 1fr; - width: 100%; - max-width: 100%; - height: 100%; -`; - -const ImageColumn = styled.div` - z-index: 15; - display: flex; - grid-area: image; - align-items: flex-end; -`; - -const InfoColumn = styled.div` - z-index: 15; - display: flex; - grid-area: info; - align-items: flex-end; - width: 100%; - max-width: 100%; - padding-left: 1rem; -`; - -const BackgroundImage = styled.img` - position: absolute; - top: 0; - left: 0; - z-index: 0; - width: 150%; - height: 150%; - user-select: none; - object-fit: var(--image-fit); - object-position: 0 30%; - filter: blur(24px); -`; - -const BackgroundImageOverlay = styled.div` - position: absolute; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; - background: linear-gradient(180deg, rgb(25 26 28 / 30%), var(--main-bg)); -`; - -const Wrapper = styled(Link)` - position: relative; - width: 100%; - height: 100%; - overflow: hidden; -`; - -const TitleWrapper = styled.div` - /* stylelint-disable-next-line value-no-vendor-prefix */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -`; - const variants: Variants = { animate: { opacity: 1, @@ -144,7 +73,8 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => { }; return ( - { mode="popLayout" > {data && ( - - - +
+
- - +
+
- +
{currentItem?.name} - - +
+
{currentItem?.albumArtists.slice(0, 1).map((artist) => ( - {artist.name} - + ))} - +
{currentItem?.genres?.slice(0, 1).map((genre) => ( {genre.name} ))} - {currentItem?.releaseYear} - {currentItem?.songCount !== null && - currentItem?.songCount !== undefined && ( - - {t('entity.trackWithCount', { - count: currentItem?.songCount || 0, - })} - - )} + {currentItem?.releaseYear} - - - + +
- - - +
+ - - +
+ )} - + ); }; diff --git a/src/renderer/components/grid-carousel/index.tsx b/src/renderer/components/grid-carousel/grid-carousel.tsx similarity index 92% rename from src/renderer/components/grid-carousel/index.tsx rename to src/renderer/components/grid-carousel/grid-carousel.tsx index 7bbc09b3..0bbf067d 100644 --- a/src/renderer/components/grid-carousel/index.tsx +++ b/src/renderer/components/grid-carousel/grid-carousel.tsx @@ -1,4 +1,3 @@ -import { Group, Stack } from '@mantine/core'; import throttle from 'lodash/throttle'; import { isValidElement, @@ -11,19 +10,20 @@ import { useRef, useState, } from 'react'; -import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'; -import styled from 'styled-components'; import { SwiperOptions, Virtual } from 'swiper'; import 'swiper/css'; import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper as SwiperCore } from 'swiper/types'; -import { Button } from '/@/renderer/components/button'; import { PosterCard } from '/@/renderer/components/card/poster-card'; -import { TextTitle } from '/@/renderer/components/text-title'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; import { usePlayButtonBehavior } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; import { Album, AlbumArtist, @@ -44,10 +44,6 @@ const getSlidesPerView = (windowWidth: number) => { return 10; }; -const CarouselContainer = styled(Stack)` - container-type: inline-size; -`; - interface TitleProps { handleNext?: () => void; handlePrev?: () => void; @@ -60,36 +56,34 @@ interface TitleProps { const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => { return ( - + {isValidElement(label) ? ( label ) : ( {label} )} - + @@ -286,10 +280,10 @@ export const SwiperGridCarousel = ({ }, []); return ( - {title ? ( - </CarouselContainer> + </Stack> ); }; diff --git a/src/renderer/components/hover-card/index.tsx b/src/renderer/components/hover-card/index.tsx deleted file mode 100644 index 713800b8..00000000 --- a/src/renderer/components/hover-card/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { HoverCardProps, HoverCard as MantineHoverCard } from '@mantine/core'; - -export const HoverCard = ({ children, ...props }: HoverCardProps) => { - return ( - <MantineHoverCard - styles={{ - dropdown: { - background: 'var(--dropdown-menu-bg)', - border: 'none', - borderRadius: 'var(--dropdown-menu-border-radius)', - boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)', - margin: 0, - padding: 0, - }, - }} - {...props} - > - {children} - </MantineHoverCard> - ); -}; - -HoverCard.Target = MantineHoverCard.Target; -HoverCard.Dropdown = MantineHoverCard.Dropdown; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 7d3b252f..4caa73b6 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -1,36 +1,2 @@ -export * from './accordion'; export * from './audio-player'; -export * from './badge'; -export * from './button'; -export * from './card'; -export * from './checkbox'; -export * from './context-menu'; -export * from './date-picker'; -export * from './dialog'; -export * from './dropdown-menu'; -export * from './feature-carousel'; -export * from './hover-card'; -export * from './input'; -export * from './modal'; export * from './motion'; -export * from './option'; -export * from './page-header'; -export * from './pagination'; -export * from './paper'; -export * from './popover'; -export * from './query-builder'; -export * from './rating'; -export * from './scroll-area'; -export * from './search-input'; -export * from './segmented-control'; -export * from './select'; -export * from './skeleton'; -export * from './slider'; -export * from './spinner'; -export * from './spoiler'; -export * from './switch'; -export * from './tabs'; -export * from './text'; -export * from './text-title'; -export * from './toast'; -export * from './tooltip'; diff --git a/src/renderer/components/input/index.tsx b/src/renderer/components/input/index.tsx deleted file mode 100644 index ba2dd85c..00000000 --- a/src/renderer/components/input/index.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import type { - FileInputProps as MantineFileInputProps, - JsonInputProps as MantineJsonInputProps, - NumberInputProps as MantineNumberInputProps, - PasswordInputProps as MantinePasswordInputProps, - TextareaProps as MantineTextareaProps, - TextInputProps as MantineTextInputProps, -} from '@mantine/core'; - -import { - FileInput as MantineFileInput, - JsonInput as MantineJsonInput, - NumberInput as MantineNumberInput, - PasswordInput as MantinePasswordInput, - Textarea as MantineTextarea, - TextInput as MantineTextInput, -} from '@mantine/core'; -import React, { forwardRef } from 'react'; -import styled from 'styled-components'; - -interface FileInputProps extends MantineFileInputProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -interface JsonInputProps extends MantineJsonInputProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -interface NumberInputProps extends MantineNumberInputProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -interface PasswordInputProps extends MantinePasswordInputProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -interface TextareaProps extends MantineTextareaProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -interface TextInputProps extends MantineTextInputProps { - children?: React.ReactNode; - maxWidth?: number | string; - width?: number | string; -} - -const StyledTextInput = styled(MantineTextInput)<TextInputProps>` - & .mantine-TextInput-wrapper { - border-color: var(--primary-color); - } - - & .mantine-TextInput-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-Input-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-TextInput-required { - color: var(--secondary-color); - } - - & .mantine-TextInput-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-TextInput-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>` - & .mantine-NumberInput-wrapper { - border-color: var(--primary-color); - } - - & .mantine-NumberInput-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-NumberInput-controlUp { - svg { - color: var(--btn-default-fg); - fill: var(--btn-default-fg); - } - } - - & .mantine-NumberInput-controlDown { - svg { - color: var(--btn-default-fg); - fill: var(--btn-default-fg); - } - } - - & .mantine-Input-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-NumberInput-required { - color: var(--secondary-color); - } - - & .mantine-NumberInput-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-NumberInput-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>` - & .mantine-PasswordInput-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-PasswordInput-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-PasswordInput-required { - color: var(--secondary-color); - } - - & .mantine-PasswordInput-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-PasswordInput-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -const StyledFileInput = styled(MantineFileInput)<FileInputProps>` - & .mantine-FileInput-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-FileInput-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-FileInput-required { - color: var(--secondary-color); - } - - & .mantine-FileInput-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-FileInput-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>` - & .mantine-JsonInput-input { - color: var(--input-fg); - background: var(--input-bg); - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - & .mantine-JsonInput-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-JsonInput-required { - color: var(--secondary-color); - } - - & .mantine-JsonInput-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-JsonInput-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -const StyledTextarea = styled(MantineTextarea)<TextareaProps>` - & .mantine-Textarea-input { - color: var(--input-fg); - background: var(--input-bg); - } - - & .mantine-Textarea-icon { - color: var(--input-placeholder-fg); - } - - & .mantine-Textarea-required { - color: var(--secondary-color); - } - - & .mantine-Textarea-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-Textarea-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - transition: width 0.3s ease-in-out; -`; - -export const TextInput = forwardRef<HTMLInputElement, TextInputProps>( - ({ children, maxWidth, width, ...props }: TextInputProps, ref) => { - return ( - <StyledTextInput - ref={ref} - spellCheck={false} - {...props} - sx={{ maxWidth, width }} - > - {children} - </StyledTextInput> - ); - }, -); - -export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>( - ({ children, maxWidth, width, ...props }: NumberInputProps, ref) => { - return ( - <StyledNumberInput - hideControls - ref={ref} - spellCheck={false} - {...props} - sx={{ maxWidth, width }} - > - {children} - </StyledNumberInput> - ); - }, -); - -export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>( - ({ children, maxWidth, width, ...props }: PasswordInputProps, ref) => { - return ( - <StyledPasswordInput - ref={ref} - {...props} - sx={{ maxWidth, width }} - > - {children} - </StyledPasswordInput> - ); - }, -); - -export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>( - ({ children, maxWidth, width, ...props }: FileInputProps, ref) => { - return ( - <StyledFileInput - ref={ref} - {...props} - styles={{ - placeholder: { - color: 'var(--input-placeholder-fg)', - }, - }} - sx={{ maxWidth, width }} - > - {children} - </StyledFileInput> - ); - }, -); - -export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>( - ({ children, maxWidth, width, ...props }: JsonInputProps, ref) => { - return ( - <StyledJsonInput - ref={ref} - {...props} - sx={{ maxWidth, width }} - > - {children} - </StyledJsonInput> - ); - }, -); - -export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>( - ({ children, maxWidth, width, ...props }: TextareaProps, ref) => { - return ( - <StyledTextarea - ref={ref} - {...props} - sx={{ maxWidth, width }} - > - {children} - </StyledTextarea> - ); - }, -); diff --git a/src/renderer/components/motion/index.tsx b/src/renderer/components/motion/index.tsx index d5186434..fdb93fcb 100644 --- a/src/renderer/components/motion/index.tsx +++ b/src/renderer/components/motion/index.tsx @@ -1,10 +1,13 @@ -import { Flex, Group, Stack } from '@mantine/core'; -import { motion } from 'framer-motion'; +import { motion } from 'motion/react'; -export const MotionFlex = motion(Flex); +import { Flex, FlexProps } from '/@/shared/components/flex/flex'; +import { Group, GroupProps } from '/@/shared/components/group/group'; +import { Stack, StackProps } from '/@/shared/components/stack/stack'; -export const MotionGroup = motion(Group); +export const MotionFlex = motion.create<FlexProps>(Flex, { forwardMotionProps: true }); -export const MotionStack = motion(Stack); +export const MotionGroup = motion.create<GroupProps>(Group, { forwardMotionProps: true }); + +export const MotionStack = motion.create<StackProps>(Stack, { forwardMotionProps: true }); export const MotionDiv = motion.div; diff --git a/src/renderer/components/native-scroll-area/native-scroll-area.module.css b/src/renderer/components/native-scroll-area/native-scroll-area.module.css new file mode 100644 index 00000000..a7fd9377 --- /dev/null +++ b/src/renderer/components/native-scroll-area/native-scroll-area.module.css @@ -0,0 +1,21 @@ +.scroll-area { + height: calc(100vh - 90px); +} + +.drag-container { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: calc(100% - 130px); + height: 65px; + -webkit-app-region: drag; + + button { + -webkit-app-region: no-drag; + } + + input { + -webkit-app-region: no-drag; + } +} diff --git a/src/renderer/components/scroll-area/index.tsx b/src/renderer/components/native-scroll-area/native-scroll-area.tsx similarity index 61% rename from src/renderer/components/scroll-area/index.tsx rename to src/renderer/components/native-scroll-area/native-scroll-area.tsx index 7eee18bb..8b49954d 100644 --- a/src/renderer/components/scroll-area/index.tsx +++ b/src/renderer/components/native-scroll-area/native-scroll-area.tsx @@ -1,73 +1,14 @@ -import type { ScrollAreaProps as MantineScrollAreaProps } from '@mantine/core'; - -import { ScrollArea as MantineScrollArea } from '@mantine/core'; import { useMergedRef } from '@mantine/hooks'; -import { useInView } from 'framer-motion'; +import { useInView } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { CSSProperties, forwardRef, ReactNode, Ref, useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; -import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header'; +import styles from './native-scroll-area.module.css'; + +import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header/page-header'; import { useWindowSettings } from '/@/renderer/store/settings.store'; import { Platform } from '/@/shared/types/types'; -const DragContainer = styled.div` - position: absolute; - top: 0; - left: 0; - z-index: -1; - width: calc(100% - 130px); - height: 65px; - -webkit-app-region: drag; - - button { - -webkit-app-region: no-drag; - } - - input { - -webkit-app-region: no-drag; - } -`; - -interface ScrollAreaProps extends MantineScrollAreaProps { - children: ReactNode; -} - -const StyledScrollArea = styled(MantineScrollArea)` - & .mantine-ScrollArea-thumb { - background: var(--scrollbar-thumb-bg); - border-radius: 0; - } - - & .mantine-ScrollArea-scrollbar { - padding: 0; - background: var(--scrollbar-track-bg); - } - - & .mantine-ScrollArea-viewport > div { - display: block !important; - } -`; - -const StyledNativeScrollArea = styled.div<{ - $scrollBarOffset?: string; - $windowBarStyle?: Platform; -}>` - height: calc(100vh - 90px); -`; - -export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => { - return ( - <StyledScrollArea - ref={ref} - scrollbarSize={12} - {...props} - > - {children} - </StyledScrollArea> - ); -}); - interface NativeScrollAreaProps { children: ReactNode; debugScrollPosition?: boolean; @@ -80,14 +21,7 @@ interface NativeScrollAreaProps { export const NativeScrollArea = forwardRef( ( - { - children, - noHeader, - pageHeaderProps, - scrollBarOffset, - scrollHideDelay, - ...props - }: NativeScrollAreaProps, + { children, noHeader, pageHeaderProps, scrollHideDelay, ...props }: NativeScrollAreaProps, ref: Ref<HTMLDivElement>, ) => { const { windowBarStyle } = useWindowSettings(); @@ -130,7 +64,7 @@ export const NativeScrollArea = forwardRef( autoHide: 'leave', autoHideDelay: scrollHideDelay || 500, pointers: ['mouse', 'pen', 'touch'], - theme: 'feishin', + theme: 'feishin-os-scrollbar', visibility: 'visible', }, }, @@ -148,7 +82,7 @@ export const NativeScrollArea = forwardRef( return ( <> - {windowBarStyle === Platform.WEB && <DragContainer />} + {windowBarStyle === Platform.WEB && <div className={styles.dragContainer} />} {shouldShowHeader && ( <PageHeader animated @@ -157,14 +91,13 @@ export const NativeScrollArea = forwardRef( {...pageHeaderProps} /> )} - <StyledNativeScrollArea - $scrollBarOffset={scrollBarOffset} - $windowBarStyle={windowBarStyle} + <div + className={styles.scrollArea} ref={mergedRef} {...props} > {children} - </StyledNativeScrollArea> + </div> </> ); }, diff --git a/src/renderer/components/option/index.tsx b/src/renderer/components/option/index.tsx deleted file mode 100644 index 5e7e141b..00000000 --- a/src/renderer/components/option/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Flex, Group } from '@mantine/core'; -import { ReactNode } from 'react'; - -export const Option = ({ children }: any) => { - return ( - <Group - grow - p="0.5rem" - > - {children} - </Group> - ); -}; - -interface LabelProps { - children: ReactNode; -} - -const Label = ({ children }: LabelProps) => { - return <Flex align="flex-start">{children}</Flex>; -}; - -interface ControlProps { - children: ReactNode; -} - -const Control = ({ children }: ControlProps) => { - return <Flex justify="flex-end">{children}</Flex>; -}; - -Option.Label = Label; -Option.Control = Control; diff --git a/src/renderer/components/page-header/index.tsx b/src/renderer/components/page-header/index.tsx deleted file mode 100644 index 5e53206c..00000000 --- a/src/renderer/components/page-header/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Flex, FlexProps } from '@mantine/core'; -import { AnimatePresence, motion, Variants } from 'framer-motion'; -import { ReactNode, useRef } from 'react'; -import styled from 'styled-components'; - -import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks'; -import { useWindowSettings } from '/@/renderer/store/settings.store'; -import { Platform } from '/@/shared/types/types'; - -const Container = styled(motion(Flex))<{ - $height?: string; - $position?: string; -}>` - position: ${(props) => props.$position || 'relative'}; - z-index: 190; - width: 100%; - height: ${(props) => props.$height || '65px'}; - background: var(--titlebar-bg); -`; - -const Header = styled(motion.div)<{ - $isDraggable?: boolean; - $isHidden?: boolean; - $padRight?: boolean; -}>` - position: relative; - z-index: 15; - width: 100%; - height: 100%; - margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')}; - pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')}; - user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')}; - -webkit-app-region: ${(props) => props.$isDraggable && 'drag'}; - - button { - -webkit-app-region: no-drag; - } - - input { - -webkit-app-region: no-drag; - } -`; - -const BackgroundImage = styled.div<{ $background: string }>` - position: absolute; - top: 0; - z-index: 1; - width: 100%; - height: 100%; - background: ${(props) => props.$background || 'var(--titlebar-bg)'}; -`; - -const BackgroundImageOverlay = styled.div<{ theme: 'dark' | 'light' }>` - position: absolute; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; - background: ${(props) => - props.theme === 'light' - ? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))' - : 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'}; -`; - -export interface PageHeaderProps - extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> { - animated?: boolean; - backgroundColor?: string; - children?: ReactNode; - height?: string; - isHidden?: boolean; - position?: string; -} - -const TitleWrapper = styled(motion.div)` - position: absolute; - display: flex; - width: 100%; - height: 100%; -`; - -const variants: Variants = { - animate: { - opacity: 1, - transition: { - duration: 0.3, - ease: 'easeIn', - }, - }, - exit: { opacity: 0 }, - initial: { opacity: 0 }, -}; - -export const PageHeader = ({ - animated, - backgroundColor, - children, - height, - isHidden, - position, - ...props -}: PageHeaderProps) => { - const ref = useRef(null); - const padRight = useShouldPadTitlebar(); - const { windowBarStyle } = useWindowSettings(); - const theme = useTheme(); - - return ( - <> - <Container - $height={height} - $position={position} - ref={ref} - {...props} - > - <Header - $isDraggable={windowBarStyle === Platform.WEB} - $isHidden={isHidden} - $padRight={padRight} - > - <AnimatePresence initial={animated ?? false}> - <TitleWrapper - animate="animate" - exit="exit" - initial="initial" - variants={variants} - > - {children} - </TitleWrapper> - </AnimatePresence> - </Header> - {backgroundColor && ( - <> - <BackgroundImage $background={backgroundColor || 'var(--titlebar-bg)'} /> - <BackgroundImageOverlay theme={theme as 'dark' | 'light'} /> - </> - )} - </Container> - </> - ); -}; diff --git a/src/renderer/components/page-header/page-header.module.css b/src/renderer/components/page-header/page-header.module.css new file mode 100644 index 00000000..04603d4f --- /dev/null +++ b/src/renderer/components/page-header/page-header.module.css @@ -0,0 +1,75 @@ +.container { + position: relative; + z-index: 190; + width: 100%; + height: 65px; +} + +.header { + position: relative; + z-index: 15; + width: 100%; + height: 100%; + margin-right: 1rem; + pointer-events: auto; + user-select: auto; + -webkit-app-region: drag; + + button { + -webkit-app-region: no-drag; + } + + input { + -webkit-app-region: no-drag; + } +} + +.header.pad-right { + margin-right: 140px; +} + +.header.hidden { + pointer-events: none; + user-select: none; +} + +.header.is-draggable { + -webkit-app-region: drag; +} + +.background-image { + position: absolute; + top: 0; + z-index: 1; + width: 100%; + height: 100%; + background: var(--theme-colors-background); +} + +.background-image-overlay { + position: absolute; + top: 0; + left: 0; + z-index: 10; + width: 100%; + height: 100%; +} + +.background-image-overlay.light { + background: linear-gradient(rgb(255 255 255 / 25%), rgb(255 255 255 / 25%)); +} + +.background-image-overlay.dark { + background: linear-gradient(rgb(0 0 0 / 50%), rgb(0 0 0 / 50%)); +} + +.title-wrapper { + position: absolute; + display: flex; + width: 100%; + height: 100%; +} + +.title-wrapper.hidden { + display: none; +} diff --git a/src/renderer/components/page-header/page-header.tsx b/src/renderer/components/page-header/page-header.tsx new file mode 100644 index 00000000..3bc69f0a --- /dev/null +++ b/src/renderer/components/page-header/page-header.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx'; +import { AnimatePresence, motion, Variants } from 'motion/react'; +import { CSSProperties, ReactNode, useRef } from 'react'; + +import styles from './page-header.module.css'; + +import { useShouldPadTitlebar } from '/@/renderer/hooks'; +import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { useAppTheme } from '/@/renderer/themes/use-app-theme'; +import { Flex, FlexProps } from '/@/shared/components/flex/flex'; +import { Platform } from '/@/shared/types/types'; + +export interface PageHeaderProps + extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> { + animated?: boolean; + backgroundColor?: string; + children?: ReactNode; + height?: string; + isHidden?: boolean; + position?: string; +} + +const variants: Variants = { + animate: { + opacity: 1, + transition: { + duration: 0.3, + ease: 'easeIn', + }, + }, + exit: { opacity: 0 }, + initial: { opacity: 0 }, +}; + +export const PageHeader = ({ + animated, + backgroundColor = 'var(--theme-colors-background)', + children, + height, + isHidden, + position, + ...props +}: PageHeaderProps) => { + const ref = useRef(null); + const padRight = useShouldPadTitlebar(); + const { windowBarStyle } = useWindowSettings(); + const { mode } = useAppTheme(); + + return ( + <> + <Flex + className={styles.container} + ref={ref} + style={{ height, position: position as CSSProperties['position'] }} + {...props} + > + <div + className={clsx(styles.header, { + [styles.hidden]: isHidden, + [styles.isDraggable]: windowBarStyle === Platform.WEB, + [styles.padRight]: padRight, + })} + > + <AnimatePresence initial={animated ?? false}> + <motion.div + animate="animate" + className={styles.titleWrapper} + exit="exit" + initial="initial" + variants={variants} + > + {children} + </motion.div> + </AnimatePresence> + </div> + {backgroundColor && ( + <> + <div + className={styles.backgroundImage} + style={{ + background: backgroundColor, + }} + /> + <div + className={clsx(styles.backgroundImageOverlay, { + [styles.dark]: mode === 'dark', + [styles.light]: mode === 'light', + })} + /> + </> + )} + </Flex> + </> + ); +}; diff --git a/src/renderer/components/pagination/index.tsx b/src/renderer/components/pagination/index.tsx deleted file mode 100644 index 4c0cb43e..00000000 --- a/src/renderer/components/pagination/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - Pagination as MantinePagination, - PaginationProps as MantinePaginationProps, -} from '@mantine/core'; -import styled from 'styled-components'; - -const StyledPagination = styled(MantinePagination)<PaginationProps>` - & .mantine-Pagination-item { - color: var(--btn-default-fg); - background-color: var(--btn-default-bg); - border: none; - transition: - background 0.2s ease-in-out, - color 0.2s ease-in-out; - - &[data-active] { - color: var(--btn-primary-fg); - background-color: var(--btn-primary-bg); - } - - &[data-dots] { - display: ${({ $hideDividers }) => ($hideDividers ? 'none' : 'block')}; - background-color: transparent; - } - - &:hover { - color: var(--btn-default-fg-hover); - background-color: var(--btn-default-bg-hover); - - &[data-active] { - color: var(--btn-primary-fg-hover); - background-color: var(--btn-primary-bg-hover); - } - - &[data-dots] { - background-color: transparent; - } - } - } -`; - -interface PaginationProps extends MantinePaginationProps { - $hideDividers?: boolean; -} - -export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => { - return ( - <StyledPagination - $hideDividers={$hideDividers} - radius="xl" - {...props} - /> - ); -}; diff --git a/src/renderer/components/paper/index.tsx b/src/renderer/components/paper/index.tsx deleted file mode 100644 index 4162a76a..00000000 --- a/src/renderer/components/paper/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { PaperProps as MantinePaperProps } from '@mantine/core'; - -import { Paper as MantinePaper } from '@mantine/core'; -import { ReactNode } from 'react'; -import styled from 'styled-components'; - -export interface PaperProps extends MantinePaperProps { - children: ReactNode; -} - -const StyledPaper = styled(MantinePaper)<PaperProps>` - background: var(--paper-bg); -`; - -export const Paper = ({ children, ...props }: PaperProps) => { - return <StyledPaper {...props}>{children}</StyledPaper>; -}; diff --git a/src/renderer/components/popover/index.tsx b/src/renderer/components/popover/index.tsx deleted file mode 100644 index c72b098d..00000000 --- a/src/renderer/components/popover/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { - PopoverDropdownProps as MantinePopoverDropdownProps, - PopoverProps as MantinePopoverProps, -} from '@mantine/core'; - -import { Popover as MantinePopover } from '@mantine/core'; -import styled from 'styled-components'; - -type PopoverDropdownProps = MantinePopoverDropdownProps; -type PopoverProps = MantinePopoverProps; - -const StyledPopover = styled(MantinePopover)``; - -const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>` - padding: 0.5rem; - font-family: var(--content-font-family); - font-size: 0.9em; - background-color: var(--dropdown-menu-bg); - border: var(--dropdown-menu-border); -`; - -export const Popover = ({ children, ...props }: PopoverProps) => { - return ( - <StyledPopover - styles={{ - dropdown: { - filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))', - }, - }} - transitionProps={{ transition: 'fade' }} - withinPortal - {...props} - > - {children} - </StyledPopover> - ); -}; - -Popover.Target = MantinePopover.Target; -Popover.Dropdown = StyledDropdown; diff --git a/src/renderer/components/query-builder/index.tsx b/src/renderer/components/query-builder/index.tsx index 9e99933d..83fcf7db 100644 --- a/src/renderer/components/query-builder/index.tsx +++ b/src/renderer/components/query-builder/index.tsx @@ -1,12 +1,13 @@ -import { Group, Stack } from '@mantine/core'; -import { AnimatePresence, motion } from 'framer-motion'; -import { RiAddFill, RiAddLine, RiDeleteBinFill, RiMore2Line, RiRestartLine } from 'react-icons/ri'; +import { AnimatePresence, motion } from 'motion/react'; import i18n from '/@/i18n/i18n'; -import { Button } from '/@/renderer/components/button'; -import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option'; -import { Select } from '/@/renderer/components/select'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types'; const FILTER_GROUP_OPTIONS_DATA = [ @@ -99,10 +100,10 @@ export const QueryBuilder = ({ return ( <Stack + gap="sm" ml={`${level * 10}px`} - spacing="sm" > - <Group spacing="sm"> + <Group gap="sm"> <Select data={FILTER_GROUP_OPTIONS_DATA} maxWidth={175} @@ -111,28 +112,26 @@ export const QueryBuilder = ({ value={data.type} width="20%" /> - <Button + <ActionIcon + icon="add" onClick={handleAddRule} - px={5} size="sm" - tooltip={{ label: 'Add rule' }} - variant="default" - > - <RiAddLine size={20} /> - </Button> + variant="subtle" + /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - p={0} + <ActionIcon + icon="ellipsisVertical" size="sm" + style={{ + padding: 0, + }} variant="subtle" - > - <RiMore2Line size={20} /> - </Button> + /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiAddFill />} + leftSection={<Icon icon="add" />} onClick={handleAddRuleGroup} > Add rule group @@ -140,7 +139,7 @@ export const QueryBuilder = ({ {level > 0 && ( <DropdownMenu.Item - icon={<RiDeleteBinFill />} + leftSection={<Icon icon="delete" />} onClick={handleDeleteRuleGroup} > Remove rule group @@ -150,15 +149,25 @@ export const QueryBuilder = ({ <> <DropdownMenu.Divider /> <DropdownMenu.Item - $danger - icon={<RiRestartLine color="var(--danger-color)" />} + isDanger + leftSection={ + <Icon + color="error" + icon="refresh" + /> + } onClick={onResetFilters} > Reset to default </DropdownMenu.Item> <DropdownMenu.Item - $danger - icon={<RiDeleteBinFill color="var(--danger-color)" />} + isDanger + leftSection={ + <Icon + color="error" + icon="delete" + /> + } onClick={onClearFilters} > Clear filters diff --git a/src/renderer/components/query-builder/query-builder-option.tsx b/src/renderer/components/query-builder/query-builder-option.tsx index 34c37a13..b7503534 100644 --- a/src/renderer/components/query-builder/query-builder-option.tsx +++ b/src/renderer/components/query-builder/query-builder-option.tsx @@ -1,10 +1,10 @@ -import { Group } from '@mantine/core'; import { useState } from 'react'; -import { RiSubtractLine } from 'react-icons/ri'; -import { Button } from '/@/renderer/components/button'; -import { NumberInput, TextInput } from '/@/renderer/components/input'; -import { Select } from '/@/renderer/components/select'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { TextInput } from '/@/shared/components/text-input/text-input'; import { QueryBuilderRule } from '/@/shared/types/types'; type DeleteArgs = { @@ -33,7 +33,7 @@ interface QueryOptionProps { } const QueryValueInput = ({ data, onChange, type, ...props }: any) => { - const [numberRange, setNumberRange] = useState([0, 0]); + const [numberRange, setNumberRange] = useState<number[]>([0, 0]); switch (type) { case 'boolean': @@ -63,7 +63,7 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => { defaultValue={props.defaultValue && Number(props.defaultValue?.[0])} maxWidth={81} onChange={(e) => { - const newRange = [e || 0, numberRange[1]]; + const newRange = [Number(e) || 0, numberRange[1]]; setNumberRange(newRange); onChange(newRange); }} @@ -74,7 +74,7 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => { defaultValue={props.defaultValue && Number(props.defaultValue?.[1])} maxWidth={81} onChange={(e) => { - const newRange = [numberRange[0], e || 0]; + const newRange = [numberRange[0], Number(e) || 0]; setNumberRange(newRange); onChange(newRange); }} @@ -189,8 +189,8 @@ export const QueryBuilderOption = ({ return ( <Group + gap="sm" ml={ml} - spacing="sm" > <Select data={filters} @@ -231,16 +231,14 @@ export const QueryBuilderOption = ({ width="25%" /> )} - <Button + <ActionIcon disabled={noRemove} + icon="remove" onClick={handleDeleteRule} px={5} size="sm" - tooltip={{ label: 'Remove rule' }} - variant="default" - > - <RiSubtractLine size={20} /> - </Button> + variant="subtle" + /> </Group> ); }; diff --git a/src/renderer/components/search-input/index.tsx b/src/renderer/components/search-input/index.tsx deleted file mode 100644 index 9e2782a7..00000000 --- a/src/renderer/components/search-input/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { ActionIcon, TextInputProps } from '@mantine/core'; -import { useFocusWithin, useHotkeys, useMergedRef } from '@mantine/hooks'; -import { ChangeEvent, KeyboardEvent } from 'react'; -import { RiCloseFill, RiSearchLine } from 'react-icons/ri'; -import { shallow } from 'zustand/shallow'; - -import { TextInput } from '/@/renderer/components/input'; -import { useSettingsStore } from '/@/renderer/store'; - -interface SearchInputProps extends TextInputProps { - initialWidth?: number; - openedWidth?: number; - value?: string; -} - -export const SearchInput = ({ - initialWidth, - onChange, - openedWidth, - ...props -}: SearchInputProps) => { - const { focused, ref } = useFocusWithin(); - const mergedRef = useMergedRef<HTMLInputElement>(ref); - const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow); - - const isOpened = focused || ref.current?.value; - const showIcon = !isOpened || (openedWidth || 100) > 100; - - useHotkeys([[binding.hotkey, () => ref.current.select()]]); - - const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => { - if (e.code === 'Escape') { - onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>); - ref.current.value = ''; - ref.current.blur(); - } - }; - - return ( - <TextInput - ref={mergedRef} - {...props} - icon={showIcon && <RiSearchLine />} - onChange={onChange} - onKeyDown={handleEscape} - rightSection={ - isOpened ? ( - <ActionIcon - onClick={() => { - ref.current.value = ''; - ref.current.focus(); - onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>); - }} - > - <RiCloseFill /> - </ActionIcon> - ) : null - } - size="md" - styles={{ - icon: { svg: { fill: 'var(--titlebar-fg)' } }, - input: { - backgroundColor: isOpened ? 'inherit' : 'transparent !important', - border: 'none !important', - cursor: isOpened ? 'text' : 'pointer', - padding: isOpened ? '10px' : 0, - }, - }} - width={isOpened ? openedWidth || 150 : initialWidth || 35} - /> - ); -}; diff --git a/src/renderer/components/segmented-control/index.tsx b/src/renderer/components/segmented-control/index.tsx deleted file mode 100644 index 30feb66e..00000000 --- a/src/renderer/components/segmented-control/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core'; - -import { SegmentedControl as MantineSegmentedControl } from '@mantine/core'; -import { forwardRef } from 'react'; -import styled from 'styled-components'; - -type SegmentedControlProps = MantineSegmentedControlProps; - -const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedControlProps>` - & .mantine-SegmentedControl-label { - font-family: var(--content-font-family); - color: var(--input-fg); - } - - background-color: var(--input-bg); - - & .mantine-SegmentedControl-disabled { - opacity: 0.6; - } - - & [data-disabled='true'] { - opacity: 0.6; - } - - & .mantine-SegmentedControl-active { - color: var(--input-active-fg); - background-color: var(--input-active-bg); - } -`; - -export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>( - ({ ...props }: SegmentedControlProps, ref) => { - return ( - <StyledSegmentedControl - ref={ref} - styles={{}} - transitionDuration={250} - transitionTimingFunction="linear" - {...props} - /> - ); - }, -); diff --git a/src/renderer/components/select-with-invalid-data/index.tsx b/src/renderer/components/select-with-invalid-data/index.tsx index 89b02bf6..16000e65 100644 --- a/src/renderer/components/select-with-invalid-data/index.tsx +++ b/src/renderer/components/select-with-invalid-data/index.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { MultiSelect, MultiSelectProps, Select, SelectProps } from '/@/renderer/components/select'; +import { MultiSelect, MultiSelectProps } from '/@/shared/components/multi-select/multi-select'; +import { Select, SelectProps } from '/@/shared/components/select/select'; export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectProps) => { const { t } = useTranslation(); @@ -9,12 +10,14 @@ export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectPr const [fullData, hasError] = useMemo(() => { if (typeof defaultValue === 'string') { const missingField = - data.find((item) => - typeof item === 'string' ? item === defaultValue : item.value === defaultValue, + data?.find((item) => + typeof item === 'string' + ? item === defaultValue + : (item as any).value === defaultValue, ) === undefined; if (missingField) { - return [data.concat(defaultValue), true]; + return [data?.concat(defaultValue), true]; } } @@ -40,11 +43,11 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul const [fullData, missing] = useMemo(() => { if (defaultValue?.length) { const validValues = new Set<string>(); - for (const item of data) { + for (const item of data || []) { if (typeof item === 'string') { validValues.add(item); } else { - validValues.add(item.value); + validValues.add((item as any).value); } } @@ -57,7 +60,7 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul } if (missingFields.length > 0) { - return [data.concat(missingFields), missingFields]; + return [data?.concat(missingFields), missingFields]; } } diff --git a/src/renderer/components/select/index.tsx b/src/renderer/components/select/index.tsx deleted file mode 100644 index bcad7309..00000000 --- a/src/renderer/components/select/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { - MultiSelectProps as MantineMultiSelectProps, - SelectProps as MantineSelectProps, -} from '@mantine/core'; - -import { MultiSelect as MantineMultiSelect, Select as MantineSelect } from '@mantine/core'; -import styled from 'styled-components'; - -export interface MultiSelectProps extends MantineMultiSelectProps { - maxWidth?: number | string; - width?: number | string; -} - -export interface SelectProps extends MantineSelectProps { - maxWidth?: number | string; - width?: number | string; -} - -const StyledSelect = styled(MantineSelect)` - & [data-selected='true'] { - background: var(--input-bg); - } - - & [data-disabled='true'] { - background: var(--input-bg); - opacity: 0.6; - } - - & .mantine-Select-label { - margin-bottom: 0.5rem; - font-family: var(--label-font-family); - } - - & .mantine-Select-itemsWrapper { - & .mantine-Select-item { - padding: 40px; - } - } -`; - -export const Select = ({ maxWidth, width, ...props }: SelectProps) => { - return ( - <StyledSelect - styles={{ - dropdown: { - background: 'var(--dropdown-menu-bg)', - filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))', - }, - input: { - background: 'var(--input-bg)', - color: 'var(--input-fg)', - }, - item: { - '&:hover': { - background: 'var(--dropdown-menu-bg-hover)', - }, - '&[data-hovered]': { - background: 'var(--dropdown-menu-bg-hover)', - }, - '&[data-selected="true"]': { - '&:hover': { - background: 'var(--dropdown-menu-bg-hover)', - }, - background: 'none', - color: 'var(--primary-color)', - }, - color: 'var(--dropdown-menu-fg)', - padding: '.3rem', - }, - }} - sx={{ maxWidth, width }} - transitionProps={{ duration: 100, transition: 'fade' }} - withinPortal - {...props} - /> - ); -}; - -const StyledMultiSelect = styled(MantineMultiSelect)` - & [data-selected='true'] { - background: var(--input-select-bg); - } - - & [data-disabled='true'] { - background: var(--input-bg); - opacity: 0.6; - } - - & .mantine-MultiSelect-itemsWrapper { - & .mantine-Select-item { - padding: 40px; - } - } -`; - -export const MultiSelect = ({ maxWidth, width, ...props }: MultiSelectProps) => { - return ( - <StyledMultiSelect - styles={{ - dropdown: { - background: 'var(--dropdown-menu-bg)', - filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))', - }, - input: { - background: 'var(--input-bg)', - color: 'var(--input-fg)', - }, - item: { - '&:hover': { - background: 'var(--dropdown-menu-bg-hover)', - }, - '&[data-hovered]': { - background: 'var(--dropdown-menu-bg-hover)', - }, - '&[data-selected="true"]': { - '&:hover': { - background: 'var(--dropdown-menu-bg-hover)', - }, - background: 'none', - color: 'var(--primary-color)', - }, - color: 'var(--dropdown-menu-fg)', - padding: '.5rem .1rem', - }, - value: { - margin: '.2rem', - paddingBottom: '1rem', - paddingLeft: '1rem', - paddingTop: '1rem', - }, - }} - sx={{ maxWidth, width }} - transitionProps={{ duration: 100, transition: 'fade' }} - withinPortal - {...props} - /> - ); -}; diff --git a/src/renderer/components/separator/index.tsx b/src/renderer/components/separator/index.tsx deleted file mode 100644 index 2ae05234..00000000 --- a/src/renderer/components/separator/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Text } from '/@/renderer/components/text'; -import { SEPARATOR_STRING } from '/@/shared/api/utils'; - -export const Separator = () => { - return ( - <Text - $noSelect - $secondary - size="md" - style={{ display: 'inline-block', padding: '0px 3px' }} - > - {SEPARATOR_STRING} - </Text> - ); -}; diff --git a/src/renderer/components/skeleton/index.tsx b/src/renderer/components/skeleton/index.tsx deleted file mode 100644 index b1e4b3ab..00000000 --- a/src/renderer/components/skeleton/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { SkeletonProps as MantineSkeletonProps } from '@mantine/core'; - -import { Skeleton as MantineSkeleton } from '@mantine/core'; -import styled from 'styled-components'; - -const StyledSkeleton = styled(MantineSkeleton)` - &::after { - background: var(--placeholder-bg); - } -`; - -export const Skeleton = ({ ...props }: MantineSkeletonProps) => { - return ( - <StyledSkeleton - animate={false} - {...props} - /> - ); -}; diff --git a/src/renderer/components/slider/index.tsx b/src/renderer/components/slider/index.tsx deleted file mode 100644 index c82890f8..00000000 --- a/src/renderer/components/slider/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { SliderProps as MantineSliderProps } from '@mantine/core'; - -import { Slider as MantineSlider } from '@mantine/core'; -import styled from 'styled-components'; - -type SliderProps = MantineSliderProps; - -const StyledSlider = styled(MantineSlider)` - & .mantine-Slider-track { - height: 0.5rem; - background-color: var(--slider-track-bg); - } - - & .mantine-Slider-bar { - background-color: var(--primary-color); - } - - & .mantine-Slider-thumb { - width: 1rem; - height: 1rem; - background: var(--slider-thumb-bg); - border: none; - } - - & .mantine-Slider-label { - padding: 0 1rem; - font-size: 1em; - color: var(--tooltip-fg); - background: var(--tooltip-bg); - } -`; - -export const Slider = ({ ...props }: SliderProps) => { - return <StyledSlider {...props} />; -}; diff --git a/src/renderer/components/switch/index.tsx b/src/renderer/components/switch/index.tsx deleted file mode 100644 index c23d6307..00000000 --- a/src/renderer/components/switch/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { SwitchProps as MantineSwitchProps } from '@mantine/core'; - -import { Switch as MantineSwitch } from '@mantine/core'; -import styled from 'styled-components'; - -type SwitchProps = MantineSwitchProps; - -const StyledSwitch = styled(MantineSwitch)` - display: flex; - - & .mantine-Switch-track { - background-color: var(--switch-track-bg); - border: none; - } - - & .mantine-Switch-input { - &:checked + .mantine-Switch-track { - background-color: var(--switch-track-enabled-bg); - } - } - - & .mantine-Switch-thumb { - background-color: var(--switch-thumb-bg); - } -`; - -export const Switch = ({ ...props }: SwitchProps) => { - return <StyledSwitch {...props} />; -}; diff --git a/src/renderer/components/tabs/index.tsx b/src/renderer/components/tabs/index.tsx deleted file mode 100644 index 9efc96cc..00000000 --- a/src/renderer/components/tabs/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Tabs as MantineTabs, TabsProps as MantineTabsProps, TabsPanelProps } from '@mantine/core'; -import { Suspense } from 'react'; -import styled from 'styled-components'; - -type TabsProps = MantineTabsProps; - -const StyledTabs = styled(MantineTabs)` - height: 100%; - - & .mantine-Tabs-tabsList { - padding-right: 1rem; - } - - &.mantine-Tabs-tab { - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 600; - background-color: var(--main-bg); - } - - & .mantine-Tabs-panel { - padding: 1.5rem 0.5rem; - } - - & .mantine-Tabs-tab { - padding: 1rem; - color: var(--btn-subtle-fg); - border-radius: 0; - - &:hover { - color: var(--btn-subtle-fg-hover); - background: var(--btn-subtle-bg-hover); - } - - transition: - background 0.2s ease-in-out, - color 0.2s ease-in-out; - } - - button[data-active] { - color: var(--btn-subtle-fg); - background: none; - border-color: var(--primary-color); - - &:hover { - background: none; - border-color: var(--primary-color); - } - } -`; - -export const Tabs = ({ children, ...props }: TabsProps) => { - return <StyledTabs {...props}>{children}</StyledTabs>; -}; - -const Panel = ({ children, ...props }: TabsPanelProps) => { - return ( - <StyledTabs.Panel {...props}> - <Suspense fallback={<></>}>{children}</Suspense> - </StyledTabs.Panel> - ); -}; - -Tabs.List = StyledTabs.List; -Tabs.Panel = Panel; -Tabs.Tab = StyledTabs.Tab; diff --git a/src/renderer/components/text-title/index.tsx b/src/renderer/components/text-title/index.tsx deleted file mode 100644 index 01f77183..00000000 --- a/src/renderer/components/text-title/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { TitleProps as MantineTitleProps } from '@mantine/core'; -import type { ComponentPropsWithoutRef, ReactNode } from 'react'; - -import { createPolymorphicComponent, Title as MantineHeader } from '@mantine/core'; -import styled from 'styled-components'; - -import { textEllipsis } from '/@/renderer/styles'; - -type MantineTextTitleDivProps = ComponentPropsWithoutRef<'div'> & MantineTitleProps; - -interface TextTitleProps extends MantineTextTitleDivProps { - $link?: boolean; - $noSelect?: boolean; - $secondary?: boolean; - children?: ReactNode; - overflow?: 'hidden' | 'visible'; - to?: string; - weight?: number; -} - -const StyledTextTitle = styled(MantineHeader)<TextTitleProps>` - overflow: ${(props) => props.overflow}; - color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; - cursor: ${(props) => props.$link && 'cursor'}; - user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')}; - transition: color 0.2s ease-in-out; - ${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis} - - &:hover { - color: ${(props) => props.$link && 'var(--main-fg)'}; - text-decoration: ${(props) => (props.$link ? 'underline' : 'none')}; - } -`; - -const _TextTitle = ({ $noSelect, $secondary, children, overflow, ...rest }: TextTitleProps) => { - return ( - <StyledTextTitle - $noSelect={$noSelect} - $secondary={$secondary} - overflow={overflow} - {...rest} - > - {children} - </StyledTextTitle> - ); -}; - -export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle); diff --git a/src/renderer/components/text/index.tsx b/src/renderer/components/text/index.tsx deleted file mode 100644 index 368f0293..00000000 --- a/src/renderer/components/text/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Font } from '/@/renderer/styles'; -import type { TextProps as MantineTextProps } from '@mantine/core'; -import type { ComponentPropsWithoutRef, ReactNode } from 'react'; - -import { createPolymorphicComponent, Text as MantineText } from '@mantine/core'; -import styled from 'styled-components'; - -import { textEllipsis } from '/@/renderer/styles'; - -type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps; - -interface TextProps extends MantineTextDivProps { - $link?: boolean; - $noSelect?: boolean; - $secondary?: boolean; - children?: ReactNode; - font?: Font; - overflow?: 'hidden' | 'visible'; - to?: string; - weight?: number; -} - -const StyledText = styled(MantineText)<TextProps>` - overflow: ${(props) => props.overflow}; - font-family: ${(props) => props.font}; - color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')}; - cursor: ${(props) => props.$link && 'cursor'}; - user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')}; - ${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis} - - &:hover { - color: ${(props) => props.$link && 'var(--main-fg)'}; - text-decoration: ${(props) => (props.$link ? 'underline' : 'none')}; - } -`; - -export const _Text = ({ $noSelect, $secondary, children, font, overflow, ...rest }: TextProps) => { - return ( - <StyledText - $noSelect={$noSelect} - $secondary={$secondary} - font={font} - overflow={overflow} - {...rest} - > - {children} - </StyledText> - ); -}; - -export const Text = createPolymorphicComponent<'div', TextProps>(_Text); diff --git a/src/renderer/components/toast/index.tsx b/src/renderer/components/toast/index.tsx deleted file mode 100644 index c4a6f74f..00000000 --- a/src/renderer/components/toast/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications'; - -import { - cleanNotifications, - cleanNotificationsQueue, - hideNotification, - showNotification, - updateNotification, -} from '@mantine/notifications'; - -interface NotificationProps extends MantineNotificationProps { - type?: 'error' | 'info' | 'success' | 'warning'; -} - -const showToast = ({ type, ...props }: NotificationProps) => { - const color = - type === 'success' - ? 'var(--success-color)' - : type === 'warning' - ? 'var(--warning-color)' - : type === 'error' - ? 'var(--danger-color)' - : 'var(--primary-color)'; - - const defaultTitle = - type === 'success' - ? 'Success' - : type === 'warning' - ? 'Warning' - : type === 'error' - ? 'Error' - : 'Info'; - - const defaultDuration = type === 'error' ? 5000 : 2000; - - return showNotification({ - autoClose: defaultDuration, - styles: () => ({ - closeButton: { - '&:hover': { - background: 'transparent', - }, - }, - description: { - color: 'var(--toast-description-fg)', - fontSize: '1rem', - }, - loader: { - margin: '1rem', - }, - root: { - '&::before': { backgroundColor: color }, - background: 'var(--toast-bg)', - border: '2px solid var(--generic-border-color)', - bottom: '90px', - }, - title: { - color: 'var(--toast-title-fg)', - fontSize: '1.3rem', - }, - }), - title: defaultTitle, - ...props, - }); -}; - -export const toast = { - clean: cleanNotifications, - cleanQueue: cleanNotificationsQueue, - error: (props: NotificationProps) => showToast({ type: 'error', ...props }), - hide: hideNotification, - info: (props: NotificationProps) => showToast({ type: 'info', ...props }), - show: showToast, - success: (props: NotificationProps) => showToast({ type: 'success', ...props }), - update: updateNotification, - warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }), -}; diff --git a/src/renderer/components/tooltip/index.tsx b/src/renderer/components/tooltip/index.tsx deleted file mode 100644 index c0817816..00000000 --- a/src/renderer/components/tooltip/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { TooltipProps } from '@mantine/core'; - -import { Tooltip as MantineTooltip } from '@mantine/core'; -import styled from 'styled-components'; - -const StyledTooltip = styled(MantineTooltip)` - & .mantine-Tooltip-tooltip { - margin: 20px; - } -`; - -export const Tooltip = ({ children, ...rest }: TooltipProps) => { - return ( - <StyledTooltip - multiline - pl={10} - pr={10} - py={5} - radius="xs" - styles={{ - tooltip: { - background: 'var(--tooltip-bg)', - boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)', - color: 'var(--tooltip-fg)', - fontSize: '1.1rem', - fontWeight: 550, - maxWidth: '250px', - }, - }} - transitionProps={{ - duration: 250, - transition: 'fade', - }} - withinPortal - {...rest} - > - {children} - </StyledTooltip> - ); -}; diff --git a/src/renderer/components/virtual-grid/grid-card/default-card.module.css b/src/renderer/components/virtual-grid/grid-card/default-card.module.css new file mode 100644 index 00000000..c2d7d7ba --- /dev/null +++ b/src/renderer/components/virtual-grid/grid-card/default-card.module.css @@ -0,0 +1,98 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: calc(100% - 2rem); + overflow: hidden; + pointer-events: auto; + cursor: pointer; + border-radius: var(--theme-radius-md); + + &:hover { + background: var(--theme-colors-surface-hover); + } +} + +.container.is-hidden { + opacity: 0; +} + +.inner-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 1rem; + overflow: hidden; + background: lighten(var(--theme-colors-surface), 3%); + + .card-controls { + opacity: 0; + } + + &:hover .card-controls { + opacity: 1; + } + + &:hover * { + &::before { + opacity: 0.5; + } + } +} + +.image-container { + position: relative; + display: flex; + align-items: center; + height: 100%; + aspect-ratio: 1/1; + overflow: hidden; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + user-select: none; + content: ''; + background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); + opacity: 0; + transition: all 0.2s ease-in-out; + } +} + +.image-container.is-favorite { + &::after { + position: absolute; + top: -50px; + left: -50px; + width: 80px; + height: 80px; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-primary-filled); + box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%); + transform: rotate(-45deg); + } +} + +.image { + width: 100%; + max-width: 100%; + height: 100% !important; + max-height: 100%; + border: 0; + border-radius: var(--theme-radius-md); + + img { + height: 100%; + object-fit: var(--theme-image-fit); + } +} + +.detail-container { + margin-top: 0.5rem; +} diff --git a/src/renderer/components/virtual-grid/grid-card/default-card.tsx b/src/renderer/components/virtual-grid/grid-card/default-card.tsx index fee72a0c..b9b2ba63 100644 --- a/src/renderer/components/virtual-grid/grid-card/default-card.tsx +++ b/src/renderer/components/virtual-grid/grid-card/default-card.tsx @@ -1,13 +1,15 @@ -import { Center, Stack } from '@mantine/core'; -import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri'; +import clsx from 'clsx'; +import { useState } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; -import { SimpleImg } from 'react-simple-img'; import { ListChildComponentProps } from 'react-window'; -import styled from 'styled-components'; -import { CardRows } from '/@/renderer/components/card'; -import { Skeleton } from '/@/renderer/components/skeleton'; +import styles from './default-card.module.css'; + +import { CardRows } from '/@/renderer/components/card/card-rows'; import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls'; +import { Image } from '/@/shared/components/image/image'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Stack } from '/@/shared/components/stack/stack'; import { Album, AlbumArtist, @@ -39,105 +41,6 @@ interface BaseGridCardProps { listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>; } -const DefaultCardContainer = styled.div<{ $isHidden?: boolean; $itemGap: number }>` - display: flex; - flex-direction: column; - width: 100%; - height: calc(100% - 2rem); - margin: ${({ $itemGap }) => $itemGap}px; - overflow: hidden; - pointer-events: auto; - cursor: pointer; - background: var(--card-default-bg); - border-radius: var(--card-default-radius); - opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)}; - - &:hover { - background: var(--card-default-bg-hover); - } -`; - -const InnerCardContainer = styled.div` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - padding: 1rem; - overflow: hidden; - - .card-controls { - opacity: 0; - } - - &:hover .card-controls { - opacity: 1; - } - - &:hover * { - &::before { - opacity: 0.5; - } - } -`; - -const ImageContainer = styled.div<{ $isFavorite?: boolean }>` - position: relative; - display: flex; - align-items: center; - height: 100%; - aspect-ratio: 1/1; - overflow: hidden; - background: var(--placeholder-bg); - border-radius: var(--card-default-radius); - - &::before { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 100%; - height: 100%; - content: ''; - user-select: none; - background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); - opacity: 0; - transition: all 0.2s ease-in-out; - } - ${(props) => - props.$isFavorite && - ` - &::after { - position: absolute; - top: -50px; - left: -50px; - width: 80px; - height: 80px; - background-color: var(--primary-color); - box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%); - transform: rotate(-45deg); - content: ''; - pointer-events: none; - } - `} -`; - -const Image = styled(SimpleImg)` - width: 100%; - max-width: 100%; - height: 100% !important; - max-height: 100%; - border: 0; - - img { - height: 100%; - object-fit: var(--image-fit); - } -`; - -const DetailContainer = styled.div` - margin-top: 0.5rem; -`; - export const DefaultCard = ({ columnIndex, controls, @@ -147,6 +50,8 @@ export const DefaultCard = ({ }: BaseGridCardProps) => { const navigate = useNavigate(); + const [isHovered, setIsHovered] = useState(false); + if (data) { const path = generatePath( controls.route.route as string, @@ -158,101 +63,68 @@ export const DefaultCard = ({ }, {}), ); - let Placeholder = RiAlbumFill; - - switch (controls.itemType) { - case LibraryItem.ALBUM: - Placeholder = RiAlbumFill; - break; - case LibraryItem.ALBUM_ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.PLAYLIST: - Placeholder = RiPlayListFill; - break; - default: - Placeholder = RiAlbumFill; - break; - } - return ( - <DefaultCardContainer - $itemGap={controls.itemGap} + <div + className={clsx(styles.container, isHidden && styles.isHidden)} key={`card-${columnIndex}-${listChildProps.index}`} onClick={() => navigate(path)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + margin: controls.itemGap, + }} > - <InnerCardContainer> - <ImageContainer $isFavorite={data?.userFavorite}> - {data?.imageUrl ? ( - <Image - importance="auto" - placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'} - src={data?.imageUrl} - /> - ) : ( - <Center - sx={{ - background: 'var(--placeholder-bg)', - borderRadius: 'var(--card-default-radius)', - height: '100%', - width: '100%', - }} - > - <Placeholder - color="var(--placeholder-fg)" - size={35} - /> - </Center> + <div className={styles.innerContainer}> + <div + className={clsx( + styles.imageContainer, + data?.userFavorite && styles.isFavorite, )} - + > + <Image + className={styles.image} + src={data?.imageUrl} + /> <GridCardControls handleFavorite={controls.handleFavorite} handlePlayQueueAdd={controls.handlePlayQueueAdd} + isHovered={isHovered} itemData={data} itemType={controls.itemType} resetInfiniteLoaderCache={controls.resetInfiniteLoaderCache} /> - </ImageContainer> - <DetailContainer> + </div> + <div className={styles.detailContainer}> <CardRows data={data} rows={controls.cardRows} /> - </DetailContainer> - </InnerCardContainer> - </DefaultCardContainer> + </div> + </div> + </div> ); } return ( - <DefaultCardContainer - $isHidden={isHidden} - $itemGap={controls.itemGap} + <div + className={clsx(styles.container, isHidden && styles.isHidden)} key={`card-${columnIndex}-${listChildProps.index}`} + style={{ + margin: controls.itemGap, + }} > - <InnerCardContainer> - <ImageContainer> - <Skeleton - radius="sm" - visible - /> - </ImageContainer> - <DetailContainer> - <Stack spacing="sm"> + <div className={styles.innerContainer}> + <div className={styles.imageContainer}> + <Skeleton className={styles.image} /> + </div> + <div className={styles.detailContainer}> + <Stack gap="xs"> {(controls?.cardRows || []).map((row, index) => ( - <Skeleton - height={14} - key={`${index}-${columnIndex}-${row.arrayProperty}`} - radius="sm" - visible - /> + <Skeleton key={`${index}-${columnIndex}-${row.arrayProperty}`} /> ))} </Stack> - </DetailContainer> - </InnerCardContainer> - </DefaultCardContainer> + </div> + </div> + </div> ); }; diff --git a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.module.css b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.module.css new file mode 100644 index 00000000..5a7c3a46 --- /dev/null +++ b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.module.css @@ -0,0 +1,84 @@ +.play-button { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background-color: var(--theme-colors-white); + border: none; + border-radius: 50%; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + transition: scale 0.1s ease-in-out; + + &:hover { + background-color: var(--theme-colors-white); + opacity: 1; + } + + &:active { + opacity: 1; + } + + svg { + fill: var(--theme-colors-black); + stroke: var(--theme-colors-black); + } +} + +.secondary-button { + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + transition: scale 0.2s linear; + + &:hover { + opacity: 1; + } + + &:active { + opacity: 1; + } +} + +.grid-card-controls-container { + position: absolute; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.favorite-banner { + position: absolute; + top: -50px; + left: -50px; + width: 80px; + height: 80px; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-primary-filled); + box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%); + transform: rotate(-45deg); +} + +.favorite-wrapper { + svg { + fill: var(--theme-colors-primary-filled); + } +} + +.bottom-controls { + position: absolute; + bottom: 0; + display: flex; + gap: var(--theme-spacing-md); + align-items: flex-end; + justify-content: flex-end; + width: 100%; + height: calc(100% / 3); + padding: 1rem 0.5rem; +} diff --git a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx index 8b085d43..380ecacc 100644 --- a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx +++ b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx @@ -1,10 +1,8 @@ -import type { UnstyledButtonProps } from '@mantine/core'; +import clsx from 'clsx'; +import { MouseEvent, useState } from 'react'; -import React, { MouseEvent, useState } from 'react'; -import { RiHeartFill, RiHeartLine, RiMoreFill, RiPlayFill } from 'react-icons/ri'; -import styled from 'styled-components'; +import styles from './grid-card-controls.module.css'; -import { _Button } from '/@/renderer/components/button'; import { ALBUM_CONTEXT_MENU_ITEMS, ARTIST_CONTEXT_MENU_ITEMS, @@ -12,105 +10,16 @@ import { } from '/@/renderer/features/context-menu/context-menu-items'; import { useHandleGridContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Icon } from '/@/shared/components/icon/icon'; import { LibraryItem } from '/@/shared/types/domain-types'; import { Play, PlayQueueAddOptions } from '/@/shared/types/types'; -type PlayButtonType = React.ComponentPropsWithoutRef<'button'> & UnstyledButtonProps; - -const PlayButton = styled.button<PlayButtonType>` - position: absolute; - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - background-color: rgb(255 255 255); - border: none; - border-radius: 50%; - opacity: 0.8; - transition: opacity 0.2s ease-in-out; - transition: scale 0.1s ease-in-out; - - &:hover { - opacity: 1; - scale: 1.1; - } - - &:active { - opacity: 1; - scale: 1; - } - - svg { - fill: rgb(0 0 0); - stroke: rgb(0 0 0); - } -`; - -const SecondaryButton = styled(_Button)` - opacity: 0.8; - transition: opacity 0.2s ease-in-out; - transition: scale 0.2s linear; - - &:hover { - opacity: 1; - scale: 1.1; - } - - &:active { - opacity: 1; - scale: 1; - } -`; - -const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>` - position: absolute; - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; -`; - -const FavoriteBanner = styled.div` - position: absolute; - top: -50px; - left: -50px; - width: 80px; - height: 80px; - pointer-events: none; - content: ''; - background-color: var(--primary-color); - box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%); - transform: rotate(-45deg); -`; - -const ControlsRow = styled.div` - width: 100%; - height: calc(100% / 3); -`; - -const BottomControls = styled(ControlsRow)` - position: absolute; - bottom: 0; - display: flex; - gap: 0.5rem; - align-items: flex-end; - justify-content: flex-end; - padding: 1rem 0.5rem; -`; - -const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` - svg { - fill: ${(props) => props.isFavorite && 'var(--primary-color)'}; - } -`; - export const GridCardControls = ({ handleFavorite, handlePlayQueueAdd, + isHovered, itemData, itemType, resetInfiniteLoaderCache, @@ -122,6 +31,7 @@ export const GridCardControls = ({ serverId: string; }) => void; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; + isHovered?: boolean; itemData: any; itemType: LibraryItem; resetInfiniteLoaderCache?: () => void; @@ -168,50 +78,46 @@ export const GridCardControls = ({ return ( <> - {isFavorite ? <FavoriteBanner /> : null} - <GridCardControlsContainer - $isFavorite - className="card-controls" - > - <PlayButton onClick={handlePlay}> - <RiPlayFill size={25} /> - </PlayButton> - <BottomControls> - {itemType !== LibraryItem.PLAYLIST && ( - <SecondaryButton - onClick={(e) => handleFavorites(e, itemData?.serverId)} - p={5} - variant="subtle" - > - <FavoriteWrapper isFavorite={itemData?.isFavorite}> - {isFavorite ? ( - <RiHeartFill size={20} /> - ) : ( - <RiHeartLine - color="white" - size={20} - /> - )} - </FavoriteWrapper> - </SecondaryButton> - )} - - <SecondaryButton - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleContextMenu(e, [itemData]); - }} - p={5} - variant="subtle" + {isFavorite ? <div className={styles.favoriteBanner} /> : null} + {isHovered && ( + <div className={clsx(styles.gridCardControlsContainer)}> + <Button + classNames={{ root: styles.playButton }} + onClick={handlePlay} + variant="filled" > - <RiMoreFill - color="white" - size={20} + <Icon + icon="mediaPlay" + size="xl" /> - </SecondaryButton> - </BottomControls> - </GridCardControlsContainer> + </Button> + <div className={styles.bottomControls}> + {itemType !== LibraryItem.PLAYLIST && ( + <ActionIcon + classNames={{ root: styles.secondaryButton }} + icon={isFavorite ? 'favorite' : 'favorite'} + iconProps={{ + fill: isFavorite ? 'primary' : undefined, + }} + onClick={(e) => handleFavorites(e, itemData?.serverId)} + size="sm" + variant="transparent" + /> + )} + <ActionIcon + classNames={{ root: styles.secondaryButton }} + icon="ellipsisHorizontal" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, [itemData]); + }} + size="sm" + variant="transparent" + /> + </div> + </div> + )} </> ); }; diff --git a/src/renderer/components/virtual-grid/grid-card/poster-card.module.css b/src/renderer/components/virtual-grid/grid-card/poster-card.module.css new file mode 100644 index 00000000..1755bbe1 --- /dev/null +++ b/src/renderer/components/virtual-grid/grid-card/poster-card.module.css @@ -0,0 +1,90 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: auto; + + &:global(.card-controls) { + opacity: 0; + } +} + +.container.hidden { + opacity: 0; +} + +.link-container { + cursor: pointer; +} + +.image-container { + position: relative; + display: flex; + align-items: center; + aspect-ratio: 1/1; + overflow: hidden; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + user-select: none; + content: ''; + background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); + opacity: 0; + transition: all 0.2s ease-in-out; + } + + &:hover { + &::before { + opacity: 0.5; + } + } + + &:hover .card-controls { + opacity: 1; + } +} + +.image-container.is-favorite { + &::after { + position: absolute; + top: -50px; + left: -50px; + width: 80px; + height: 80px; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-primary-filled); + box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%); + transform: rotate(-45deg); + } +} + +.image { + width: 100%; + max-width: 100%; + height: 100% !important; + max-height: 100%; + border: 0; + border-radius: var(--theme-radius-md); + + img { + height: 100%; + object-fit: var(--theme-image-fit); + } +} + +.detail-container { + margin-top: 0.5rem; +} + +.placeholder-wrapper { + width: 100%; + height: 100%; +} diff --git a/src/renderer/components/virtual-grid/grid-card/poster-card.tsx b/src/renderer/components/virtual-grid/grid-card/poster-card.tsx index 96cc152c..3c168f7c 100644 --- a/src/renderer/components/virtual-grid/grid-card/poster-card.tsx +++ b/src/renderer/components/virtual-grid/grid-card/poster-card.tsx @@ -1,13 +1,15 @@ -import { Center, Stack } from '@mantine/core'; -import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri'; +import clsx from 'clsx'; +import { useState } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; -import { SimpleImg } from 'react-simple-img'; import { ListChildComponentProps } from 'react-window'; -import styled from 'styled-components'; -import { CardRows } from '/@/renderer/components/card'; -import { Skeleton } from '/@/renderer/components/skeleton'; +import styles from './poster-card.module.css'; + +import { CardRows } from '/@/renderer/components/card/card-rows'; import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls'; +import { Image } from '/@/shared/components/image/image'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Stack } from '/@/shared/components/stack/stack'; import { Album, AlbumArtist, @@ -39,93 +41,6 @@ interface BaseGridCardProps { listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>; } -const PosterCardContainer = styled.div<{ $isHidden?: boolean; $itemGap: number }>` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - margin: ${({ $itemGap }) => $itemGap}px; - overflow: hidden; - pointer-events: auto; - opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)}; - - .card-controls { - opacity: 0; - } -`; - -const LinkContainer = styled.div` - cursor: pointer; -`; - -const ImageContainer = styled.div<{ $isFavorite?: boolean }>` - position: relative; - display: flex; - align-items: center; - aspect-ratio: 1/1; - overflow: hidden; - background: var(--card-default-bg); - border-radius: var(--card-poster-radius); - - &::before { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 100%; - height: 100%; - content: ''; - user-select: none; - background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%); - opacity: 0; - transition: all 0.2s ease-in-out; - } - - ${(props) => - props.$isFavorite && - ` - &::after { - position: absolute; - top: -50px; - left: -50px; - width: 80px; - height: 80px; - background-color: var(--primary-color); - box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%); - transform: rotate(-45deg); - content: ''; - pointer-events: none; - } - `} - - &:hover { - &::before { - opacity: 0.5; - } - } - - &:hover .card-controls { - opacity: 1; - } -`; - -const Image = styled(SimpleImg)` - width: 100%; - max-width: 100%; - height: 100% !important; - max-height: 100%; - border: 0; - - img { - height: 100%; - object-fit: var(--image-fit); - } -`; - -const DetailContainer = styled.div` - margin-top: 0.5rem; -`; - export const PosterCard = ({ columnIndex, controls, @@ -135,6 +50,8 @@ export const PosterCard = ({ }: BaseGridCardProps) => { const navigate = useNavigate(); + const [isHovered, setIsHovered] = useState(false); + if (data) { const path = generatePath( controls.route.route as string, @@ -146,97 +63,68 @@ export const PosterCard = ({ }, {}), ); - let Placeholder = RiAlbumFill; - - switch (controls.itemType) { - case LibraryItem.ALBUM: - Placeholder = RiAlbumFill; - break; - case LibraryItem.ALBUM_ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.PLAYLIST: - Placeholder = RiPlayListFill; - break; - default: - Placeholder = RiAlbumFill; - break; - } - return ( - <PosterCardContainer - $itemGap={controls.itemGap} + <div + className={styles.container} key={`card-${columnIndex}-${listChildProps.index}`} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + margin: controls.itemGap, + }} > - <LinkContainer onClick={() => navigate(path)}> - <ImageContainer $isFavorite={data?.userFavorite}> - {data?.imageUrl ? ( - <Image - importance="auto" - placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'} - src={data?.imageUrl} - /> - ) : ( - <Center - sx={{ - background: 'var(--placeholder-bg)', - borderRadius: 'var(--card-default-radius)', - height: '100%', - width: '100%', - }} - > - <Placeholder - color="var(--placeholder-fg)" - size={35} - /> - </Center> - )} + <div + className={styles.linkContainer} + onClick={() => navigate(path)} + > + <div + className={`${styles.imageContainer} ${data?.userFavorite ? styles.isFavorite : ''}`} + > + <Image + className={styles.image} + src={data?.imageUrl} + /> <GridCardControls handleFavorite={controls.handleFavorite} handlePlayQueueAdd={controls.handlePlayQueueAdd} + isHovered={isHovered} itemData={data} itemType={controls.itemType} resetInfiniteLoaderCache={controls.resetInfiniteLoaderCache} /> - </ImageContainer> - </LinkContainer> - <DetailContainer> + </div> + </div> + <div className={styles.detailContainer}> <CardRows data={data} rows={controls.cardRows} /> - </DetailContainer> - </PosterCardContainer> + </div> + </div> ); } return ( - <PosterCardContainer - $isHidden={isHidden} - $itemGap={controls.itemGap} + <div + className={clsx(styles.container, isHidden && styles.hidden)} key={`card-${columnIndex}-${listChildProps.index}`} + style={{ + margin: controls.itemGap, + }} > - <Skeleton - radius="sm" - visible - > - <ImageContainer /> - </Skeleton> - <DetailContainer> - <Stack spacing="sm"> + <div className={styles.imageContainer}> + <Skeleton className={styles.image} /> + </div> + <div className={styles.detailContainer}> + <Stack gap="xs"> {(controls?.cardRows || []).map((row, index) => ( <Skeleton - height={14} + className={styles.row} key={`${index}-${columnIndex}-${row.arrayProperty}`} - radius="sm" - visible /> ))} </Stack> - </DetailContainer> - </PosterCardContainer> + </div> + </div> ); }; diff --git a/src/renderer/components/virtual-grid/virtual-grid-wrapper.module.css b/src/renderer/components/virtual-grid/virtual-grid-wrapper.module.css new file mode 100644 index 00000000..50563d0b --- /dev/null +++ b/src/renderer/components/virtual-grid/virtual-grid-wrapper.module.css @@ -0,0 +1,9 @@ +.virtual-grid-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.virtual-grid-auto-sizer-container { + flex: 1; +} diff --git a/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx b/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx index f6d39623..820b37e1 100644 --- a/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx +++ b/src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx @@ -10,7 +10,8 @@ import type { FixedSizeListProps } from 'react-window'; import debounce from 'lodash/debounce'; import memoize from 'memoize-one'; import { FixedSizeList } from 'react-window'; -import styled from 'styled-components'; + +import styles from './virtual-grid-wrapper.module.css'; import { GridCard } from '/@/renderer/components/virtual-grid/grid-card'; import { Album, AlbumArtist, Artist, LibraryItem } from '/@/shared/types/domain-types'; @@ -128,12 +129,14 @@ export const VirtualGridWrapper = ({ ); }; -export const VirtualGridContainer = styled.div` - display: flex; - flex-direction: column; - height: 100%; -`; +interface VirtualGridContainerProps { + children: React.ReactNode; +} -export const VirtualGridAutoSizerContainer = styled.div` - flex: 1; -`; +export const VirtualGridContainer = ({ children }: VirtualGridContainerProps) => { + return <div className={styles.virtualGridContainer}>{children}</div>; +}; + +export const VirtualGridAutoSizerContainer = ({ children }: VirtualGridContainerProps) => { + return <div className={styles.virtualGridAutoSizerContainer}>{children}</div>; +}; diff --git a/src/renderer/components/virtual-table/cells/actions-cell.tsx b/src/renderer/components/virtual-table/cells/actions-cell.tsx index 48e73d28..fabb3239 100644 --- a/src/renderer/components/virtual-table/cells/actions-cell.tsx +++ b/src/renderer/components/virtual-table/cells/actions-cell.tsx @@ -1,24 +1,21 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { RiMoreFill } from 'react-icons/ri'; - -import { Button } from '/@/renderer/components/button'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; export const ActionsCell = ({ api, context }: ICellRendererParams) => { return ( - <CellContainer $position="center"> - <Button - compact + <CellContainer position="center"> + <ActionIcon + icon="ellipsisHorizontal" onClick={(e) => { e.stopPropagation(); e.preventDefault(); context.onCellContextMenu(undefined, api, e); }} + size="sm" variant="subtle" - > - <RiMoreFill /> - </Button> + /> </CellContainer> ); }; diff --git a/src/renderer/components/virtual-table/cells/album-artist-cell.tsx b/src/renderer/components/virtual-table/cells/album-artist-cell.tsx index 702b97e9..006bd8ad 100644 --- a/src/renderer/components/virtual-table/cells/album-artist-cell.tsx +++ b/src/renderer/components/virtual-table/cells/album-artist-cell.tsx @@ -5,16 +5,16 @@ import React from 'react'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { Separator } from '/@/renderer/components/separator'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { AppRoute } from '/@/renderer/router/routes'; +import { Separator } from '/@/shared/components/separator/separator'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => { if (value === undefined) { return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Skeleton height="1rem" width="80%" @@ -24,9 +24,9 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => { } return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Text - $secondary + isMuted overflow="hidden" size="md" > @@ -35,9 +35,9 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => { {index > 0 && <Separator />} {item.id ? ( <Text - $link - $secondary component={Link} + isLink + isMuted overflow="hidden" size="md" to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { @@ -48,7 +48,7 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => { </Text> ) : ( <Text - $secondary + isMuted overflow="hidden" size="md" > diff --git a/src/renderer/components/virtual-table/cells/artist-cell.tsx b/src/renderer/components/virtual-table/cells/artist-cell.tsx index 7a0766c1..fa8cc53f 100644 --- a/src/renderer/components/virtual-table/cells/artist-cell.tsx +++ b/src/renderer/components/virtual-table/cells/artist-cell.tsx @@ -5,16 +5,16 @@ import React from 'react'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { Separator } from '/@/renderer/components/separator'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { AppRoute } from '/@/renderer/router/routes'; +import { Separator } from '/@/shared/components/separator/separator'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; export const ArtistCell = ({ data, value }: ICellRendererParams) => { if (value === undefined) { return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Skeleton height="1rem" width="80%" @@ -24,9 +24,9 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => { } return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Text - $secondary + isMuted overflow="hidden" size="md" > @@ -35,9 +35,9 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => { {index > 0 && <Separator />} {item.id ? ( <Text - $link - $secondary component={Link} + isLink + isMuted overflow="hidden" size="md" to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { @@ -48,7 +48,7 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => { </Text> ) : ( <Text - $secondary + isMuted overflow="hidden" size="md" > diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell-controls.module.css b/src/renderer/components/virtual-table/cells/combined-title-cell-controls.module.css new file mode 100644 index 00000000..8b5071ba --- /dev/null +++ b/src/renderer/components/virtual-table/cells/combined-title-cell-controls.module.css @@ -0,0 +1,35 @@ +.play-button { + position: absolute; + width: 30px; + height: 30px; + background: var(--theme-colors-white); + border: none; + border-radius: 50%; + opacity: 0.7; + transition: scale 0.1s ease-in-out; + + svg { + color: var(--theme-colors-black); + fill: var(--theme-colors-black); + } + + &:hover { + background-color: var(--theme-colors-white); + opacity: 1; + } + + &:active { + opacity: 1; + } +} + +.list-controls-container { + position: absolute; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell-controls.tsx b/src/renderer/components/virtual-table/cells/combined-title-cell-controls.tsx index 7d1d146b..573cd8a8 100644 --- a/src/renderer/components/virtual-table/cells/combined-title-cell-controls.tsx +++ b/src/renderer/components/virtual-table/cells/combined-title-cell-controls.tsx @@ -1,62 +1,21 @@ -import type { UnstyledButtonProps } from '@mantine/core'; +import clsx from 'clsx'; -import React, { MouseEvent } from 'react'; -import { RiPlayFill } from 'react-icons/ri'; -import styled from 'styled-components'; +import styles from './combined-title-cell-controls.module.css'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { LibraryItem } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -type PlayButtonType = React.ComponentPropsWithoutRef<'button'> & UnstyledButtonProps; - -const PlayButton = styled.button<PlayButtonType>` - position: absolute; - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - background-color: rgb(255 255 255); - border: none; - border-radius: 50%; - opacity: 0.8; - transition: scale 0.1s ease-in-out; - - &:hover { - opacity: 1; - scale: 1.1; - } - - &:active { - opacity: 1; - scale: 1; - } - - svg { - fill: rgb(0 0 0); - stroke: rgb(0 0 0); - } -`; - -const ListConverControlsContainer = styled.div` - position: absolute; - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; -`; - export const ListCoverControls = ({ + className, context, itemData, itemType, uniqueId, }: { + className?: string; context: Record<string, any>; itemData: any; itemType: LibraryItem; @@ -66,7 +25,7 @@ export const ListCoverControls = ({ const handlePlayQueueAdd = usePlayQueueAdd(); const isQueue = Boolean(context?.isQueue); - const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => { + const handlePlay = async (e: React.MouseEvent<HTMLButtonElement>, playType?: Play) => { e.preventDefault(); e.stopPropagation(); @@ -88,12 +47,12 @@ export const ListCoverControls = ({ }; return ( - <> - <ListConverControlsContainer className="card-controls"> - <PlayButton onClick={isQueue ? handlePlayFromQueue : handlePlay}> - <RiPlayFill size={20} /> - </PlayButton> - </ListConverControlsContainer> - </> + <div className={clsx(styles.listControlsContainer, className)}> + <ActionIcon + classNames={{ root: styles.playButton }} + icon="mediaPlay" + onClick={isQueue ? handlePlayFromQueue : handlePlay} + /> + </div> ); }; diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell.module.css b/src/renderer/components/virtual-table/cells/combined-title-cell.module.css new file mode 100644 index 00000000..08eba185 --- /dev/null +++ b/src/renderer/components/virtual-table/cells/combined-title-cell.module.css @@ -0,0 +1,50 @@ +.cell-container { + display: grid; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-auto-columns: 1fr; + gap: 0.5rem; + align-items: center; + justify-items: center; + width: 100%; + max-width: 100%; + height: 100%; + letter-spacing: 0.5px; + + &:hover { + .play-button { + opacity: 1; + } + } +} + +.play-button { + opacity: 0; +} + +.image-wrapper { + position: relative; + display: flex; + grid-area: image; + align-items: center; + justify-content: center; + height: 100%; +} + +.metadata-wrapper { + display: flex; + flex-direction: column; + grid-area: info; + justify-content: center; + width: 100%; +} + +.metadata-wrapper > div { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skeleton-metadata { + height: var(--theme-font-size-md); +} diff --git a/src/renderer/components/virtual-table/cells/combined-title-cell.tsx b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx index cff81551..bffe9862 100644 --- a/src/renderer/components/virtual-table/cells/combined-title-cell.tsx +++ b/src/renderer/components/virtual-table/cells/combined-title-cell.tsx @@ -1,67 +1,19 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { Center } from '@mantine/core'; -import { motion } from 'framer-motion'; import React, { useMemo } from 'react'; -import { RiAlbumFill } from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { SimpleImg } from 'react-simple-img'; -import styled from 'styled-components'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; +import styles from './combined-title-cell.module.css'; + import { ListCoverControls } from '/@/renderer/components/virtual-table/cells/combined-title-cell-controls'; import { AppRoute } from '/@/renderer/router/routes'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; +import { Image } from '/@/shared/components/image/image'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; import { AlbumArtist, Artist } from '/@/shared/types/domain-types'; -const CellContainer = styled(motion.div)<{ height: number }>` - display: grid; - grid-template-areas: 'image info'; - grid-template-rows: 1fr; - grid-template-columns: ${(props) => props.height}px minmax(0, 1fr); - grid-auto-columns: 1fr; - gap: 0.5rem; - width: 100%; - max-width: 100%; - height: 100%; - letter-spacing: 0.5px; - - .card-controls { - opacity: 0; - } - - &:hover { - .card-controls { - opacity: 1; - } - } -`; - -const ImageWrapper = styled.div` - position: relative; - display: flex; - grid-area: image; - align-items: center; - justify-content: center; - height: 100%; -`; - -const MetadataWrapper = styled.div` - display: flex; - flex-direction: column; - grid-area: info; - justify-content: center; - width: 100%; -`; - -const StyledImage = styled(SimpleImg)` - img { - object-fit: var(--image-fit); - } -`; - export const CombinedTitleCell = ({ context, data, @@ -76,60 +28,55 @@ export const CombinedTitleCell = ({ if (value === undefined) { return ( - <CellContainer height={node.rowHeight || 40}> - <Skeleton> - <ImageWrapper /> - </Skeleton> - <MetadataWrapper> - <Skeleton - height="1rem" - width="80%" - /> - <Skeleton - height="1rem" - mt="0.5rem" - width="60%" - /> - </MetadataWrapper> - </CellContainer> + <div + className={styles.cellContainer} + style={{ gridTemplateColumns: `${node.rowHeight || 40}px minmax(0, 1fr)` }} + > + <div + className={styles.imageWrapper} + style={{ + height: `${(node.rowHeight || 40) - 10}px`, + width: `${(node.rowHeight || 40) - 10}px`, + }} + > + <Skeleton className={styles.image} /> + </div> + <Skeleton + className={styles.skeletonMetadata} + height="1rem" + width="80%" + /> + </div> ); } return ( - <CellContainer height={node.rowHeight || 40}> - <ImageWrapper> - {value.imageUrl ? ( - <StyledImage - alt="cover" - height={(node.rowHeight || 40) - 10} - placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'} - src={value.imageUrl} - style={{}} - width={(node.rowHeight || 40) - 10} - /> - ) : ( - <Center - sx={{ - background: 'var(--placeholder-bg)', - borderRadius: 'var(--card-default-radius)', - height: `${(node.rowHeight || 40) - 10}px`, - width: `${(node.rowHeight || 40) - 10}px`, - }} - > - <RiAlbumFill - color="var(--placeholder-fg)" - size={35} - /> - </Center> - )} + <div + className={styles.cellContainer} + style={{ gridTemplateColumns: `${node.rowHeight || 40}px minmax(0, 1fr)` }} + > + <div + className={styles.imageWrapper} + style={{ + height: `${(node.rowHeight || 40) - 10}px`, + width: `${(node.rowHeight || 40) - 10}px`, + }} + > + <Image + alt="cover" + className={styles.image} + src={value.imageUrl} + /> + <ListCoverControls + className={styles.playButton} context={context} itemData={value} itemType={context.itemType} uniqueId={data?.uniqueId} /> - </ImageWrapper> - <MetadataWrapper> + </div> + <div className={styles.metadataWrapper}> <Text className="current-song-child" overflow="hidden" @@ -138,7 +85,7 @@ export const CombinedTitleCell = ({ {value.name} </Text> <Text - $secondary + isMuted overflow="hidden" size="md" > @@ -148,12 +95,12 @@ export const CombinedTitleCell = ({ {index > 0 ? SEPARATOR_STRING : null} {artist.id ? ( <Text - $link - $secondary component={Link} + isLink + isMuted overflow="hidden" size="md" - sx={{ width: 'fit-content' }} + style={{ width: 'fit-content' }} to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: artist.id, })} @@ -162,10 +109,10 @@ export const CombinedTitleCell = ({ </Text> ) : ( <Text - $secondary + isMuted overflow="hidden" size="md" - sx={{ width: 'fit-content' }} + style={{ width: 'fit-content' }} > {artist.name} </Text> @@ -173,10 +120,10 @@ export const CombinedTitleCell = ({ </React.Fragment> )) ) : ( - <Text $secondary>—</Text> + <Text isMuted>—</Text> )} </Text> - </MetadataWrapper> - </CellContainer> + </div> + </div> ); }; diff --git a/src/renderer/components/virtual-table/cells/favorite-cell.tsx b/src/renderer/components/virtual-table/cells/favorite-cell.tsx index 1039b96a..ff4db1af 100644 --- a/src/renderer/components/virtual-table/cells/favorite-cell.tsx +++ b/src/renderer/components/virtual-table/cells/favorite-cell.tsx @@ -1,10 +1,8 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { RiHeartFill, RiHeartLine } from 'react-icons/ri'; - -import { Button } from '/@/renderer/components/button'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; export const FavoriteCell = ({ data, node, value }: ICellRendererParams) => { const createMutation = useCreateFavorite({}); @@ -47,21 +45,16 @@ export const FavoriteCell = ({ data, node, value }: ICellRendererParams) => { }; return ( - <CellContainer $position="center"> - <Button - compact - onClick={handleToggleFavorite} - sx={{ - svg: { - fill: !value - ? 'var(--main-fg-secondary) !important' - : 'var(--primary-color) !important', - }, + <CellContainer position="center"> + <ActionIcon + icon="favorite" + iconProps={{ + fill: !value ? undefined : 'primary', }} + onClick={handleToggleFavorite} + size="sm" variant="subtle" - > - {!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />} - </Button> + /> </CellContainer> ); }; diff --git a/src/renderer/components/virtual-table/cells/full-width-disc-cell.module.css b/src/renderer/components/virtual-table/cells/full-width-disc-cell.module.css new file mode 100644 index 00000000..eeb8914b --- /dev/null +++ b/src/renderer/components/virtual-table/cells/full-width-disc-cell.module.css @@ -0,0 +1,6 @@ +.container { + display: flex; + height: 100%; + padding: 0.5rem 1rem; + border: 1px solid transparent; +} diff --git a/src/renderer/components/virtual-table/cells/full-width-disc-cell.tsx b/src/renderer/components/virtual-table/cells/full-width-disc-cell.tsx index 6c7ca970..2ce9a923 100644 --- a/src/renderer/components/virtual-table/cells/full-width-disc-cell.tsx +++ b/src/renderer/components/virtual-table/cells/full-width-disc-cell.tsx @@ -1,19 +1,12 @@ import { ICellRendererParams } from '@ag-grid-community/core'; -import { Group } from '@mantine/core'; import { useState } from 'react'; -import { RiCheckboxBlankLine, RiCheckboxLine } from 'react-icons/ri'; -import styled from 'styled-components'; -import { Button } from '/@/renderer/components/button'; -import { Paper } from '/@/renderer/components/paper'; +import styles from './full-width-disc-cell.module.css'; + import { getNodesByDiscNumber, setNodeSelection } from '/@/renderer/components/virtual-table/utils'; - -const Container = styled(Paper)` - display: flex; - height: 100%; - padding: 0.5rem 1rem; - border: 1px solid transparent; -`; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => { const [isSelected, setIsSelected] = useState(false); @@ -31,21 +24,20 @@ export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => { }; return ( - <Container> + <div className={styles.container}> <Group - position="apart" + justify="space-between" w="100%" > <Button - compact - leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />} + leftSection={isSelected ? <Icon icon="squareCheck" /> : <Icon icon="square" />} onClick={handleToggleDiscNodes} - size="md" + size="compact-md" variant="subtle" > {data.name} </Button> </Group> - </Container> + </div> ); }; diff --git a/src/renderer/components/virtual-table/cells/generic-cell.module.css b/src/renderer/components/virtual-table/cells/generic-cell.module.css new file mode 100644 index 00000000..3e983db8 --- /dev/null +++ b/src/renderer/components/virtual-table/cells/generic-cell.module.css @@ -0,0 +1,19 @@ +.cell-container { + display: flex; + align-items: center; + width: 100%; + height: 100%; + letter-spacing: 0.5px; +} + +.cell-container.right { + justify-content: flex-end; +} + +.cell-container.center { + justify-content: center; +} + +.cell-container.left { + justify-content: flex-start; +} diff --git a/src/renderer/components/virtual-table/cells/generic-cell.tsx b/src/renderer/components/virtual-table/cells/generic-cell.tsx index 82ac41a6..413c4575 100644 --- a/src/renderer/components/virtual-table/cells/generic-cell.tsx +++ b/src/renderer/components/virtual-table/cells/generic-cell.tsx @@ -1,24 +1,12 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; +import clsx from 'clsx'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; +import styles from './generic-cell.module.css'; -export const CellContainer = styled.div<{ $position?: 'center' | 'left' | 'right' }>` - display: flex; - align-items: center; - justify-content: ${(props) => - props.$position === 'right' - ? 'flex-end' - : props.$position === 'center' - ? 'center' - : 'flex-start'}; - width: 100%; - height: 100%; - letter-spacing: 0.5px; -`; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; type Options = { array?: boolean; @@ -36,7 +24,7 @@ export const GenericCell = ( if (value === undefined) { return ( - <CellContainer $position={position || 'left'}> + <CellContainer position={position || 'left'}> <Skeleton height="1rem" width="80%" @@ -46,12 +34,12 @@ export const GenericCell = ( } return ( - <CellContainer $position={position || 'left'}> + <CellContainer position={position || 'left'}> {isLink ? ( <Text - $link={isLink} - $secondary={!primary} component={Link} + isLink={isLink} + isMuted={!primary} overflow="hidden" size="md" to={displayedValue.link} @@ -60,8 +48,8 @@ export const GenericCell = ( </Text> ) : ( <Text - $noSelect={false} - $secondary={!primary} + isMuted={!primary} + isNoSelect={false} overflow="hidden" size="md" > @@ -71,3 +59,24 @@ export const GenericCell = ( </CellContainer> ); }; + +export const CellContainer = ({ + children, + position, +}: { + children: React.ReactNode; + position: 'center' | 'left' | 'right'; +}) => { + return ( + <div + className={clsx({ + [styles.cellContainer]: true, + [styles.center]: position === 'center', + [styles.left]: position === 'left' || !position, + [styles.right]: position === 'right', + })} + > + {children} + </div> + ); +}; diff --git a/src/renderer/components/virtual-table/cells/genre-cell.tsx b/src/renderer/components/virtual-table/cells/genre-cell.tsx index 93a48e48..5365948d 100644 --- a/src/renderer/components/virtual-table/cells/genre-cell.tsx +++ b/src/renderer/components/virtual-table/cells/genre-cell.tsx @@ -4,17 +4,17 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; import React from 'react'; import { generatePath, Link } from 'react-router-dom'; -import { Separator } from '/@/renderer/components/separator'; -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; +import { Separator } from '/@/shared/components/separator/separator'; +import { Text } from '/@/shared/components/text/text'; export const GenreCell = ({ data, value }: ICellRendererParams) => { const genrePath = useGenreRoute(); return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Text - $secondary + isMuted overflow="hidden" size="md" > @@ -22,9 +22,9 @@ export const GenreCell = ({ data, value }: ICellRendererParams) => { <React.Fragment key={`row-${item.id}-${data.uniqueId}`}> {index > 0 && <Separator />} <Text - $link - $secondary component={Link} + isLink + isMuted overflow="hidden" size="md" to={generatePath(genrePath, { genreId: item.id })} diff --git a/src/renderer/components/virtual-table/cells/note-cell.tsx b/src/renderer/components/virtual-table/cells/note-cell.tsx index 0eb07950..c4ef40da 100644 --- a/src/renderer/components/virtual-table/cells/note-cell.tsx +++ b/src/renderer/components/virtual-table/cells/note-cell.tsx @@ -2,10 +2,10 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; import { useMemo } from 'react'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; export const NoteCell = ({ value }: ICellRendererParams) => { const formattedValue = useMemo(() => { @@ -18,7 +18,7 @@ export const NoteCell = ({ value }: ICellRendererParams) => { if (value === undefined) { return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Skeleton height="1rem" width="80%" @@ -28,9 +28,9 @@ export const NoteCell = ({ value }: ICellRendererParams) => { } return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Text - $secondary + isMuted overflow="hidden" > {formattedValue} diff --git a/src/renderer/components/virtual-table/cells/rating-cell.tsx b/src/renderer/components/virtual-table/cells/rating-cell.tsx index f1d1648f..80111941 100644 --- a/src/renderer/components/virtual-table/cells/rating-cell.tsx +++ b/src/renderer/components/virtual-table/cells/rating-cell.tsx @@ -1,8 +1,8 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { Rating } from '/@/renderer/components/rating'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { useSetRating } from '/@/renderer/features/shared'; +import { Rating } from '/@/shared/components/rating/rating'; export const RatingCell = ({ node, value }: ICellRendererParams) => { const updateRatingMutation = useSetRating({}); @@ -25,7 +25,7 @@ export const RatingCell = ({ node, value }: ICellRendererParams) => { }; return ( - <CellContainer $position="center"> + <CellContainer position="center"> <Rating onChange={handleUpdateRating} size="xs" diff --git a/src/renderer/components/virtual-table/cells/row-index-cell.tsx b/src/renderer/components/virtual-table/cells/row-index-cell.tsx index f7702bf3..43ca9451 100644 --- a/src/renderer/components/virtual-table/cells/row-index-cell.tsx +++ b/src/renderer/components/virtual-table/cells/row-index-cell.tsx @@ -1,9 +1,8 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { RiPlayFill } from 'react-icons/ri'; - -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Text } from '/@/shared/components/text/text'; // const AnimatedSvg = () => { // return ( @@ -14,7 +13,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi // > // <g> // <rect -// fill="var(--primary-color)" +// fill="var(--theme-colors-primary-filled)" // height="80" // id="bar-1" // width="12" @@ -33,7 +32,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi // /> // </rect> // <rect -// fill="var(--primary-color)" +// fill="var(--theme-colors-primary-filled)" // height="80" // id="bar-2" // width="12" @@ -52,7 +51,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi // /> // </rect> // <rect -// fill="var(--primary-color)" +// fill="var(--theme-colors-primary-filled)" // height="80" // id="bar-3" // width="12" @@ -71,7 +70,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi // /> // </rect> // <rect -// fill="var(--primary-color)" +// fill="var(--theme-colors-primary-filled)" // height="80" // id="bar-4" // width="12" @@ -143,23 +142,28 @@ export const RowIndexCell = ({ eGridCell, value }: ICellRendererParams) => { classList.contains('current-song-cell') || classList.contains('current-playlist-song-cell'); return ( - <CellContainer $position="right"> - {isPlaying && - (isCurrentSong ? ( - <RiPlayFill - color="var(--primary-color)" - size="1.2rem" - /> - ) : null)} - <Text - $secondary - align="right" - className="current-song-child current-song-index" - overflow="hidden" - size="md" - > - {value} - </Text> + <CellContainer position="right"> + {isPlaying && isCurrentSong ? ( + <Icon + fill="primary" + icon="mediaPlay" + /> + ) : isCurrentSong ? ( + <Icon + fill="primary" + icon="mediaPause" + /> + ) : ( + <Text + className="current-song-child current-song-index" + isMuted + overflow="hidden" + size="md" + style={{ textAlign: 'right' }} + > + {value} + </Text> + )} </CellContainer> ); }; diff --git a/src/renderer/components/virtual-table/cells/title-cell.tsx b/src/renderer/components/virtual-table/cells/title-cell.tsx index f48a89b8..8afad019 100644 --- a/src/renderer/components/virtual-table/cells/title-cell.tsx +++ b/src/renderer/components/virtual-table/cells/title-cell.tsx @@ -1,13 +1,13 @@ import type { ICellRendererParams } from '@ag-grid-community/core'; -import { Skeleton } from '/@/renderer/components/skeleton'; -import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; export const TitleCell = ({ value }: ICellRendererParams) => { if (value === undefined) { return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Skeleton height="1rem" width="80%" @@ -17,7 +17,7 @@ export const TitleCell = ({ value }: ICellRendererParams) => { } return ( - <CellContainer $position="left"> + <CellContainer position="left"> <Text className="current-song-child" overflow="hidden" diff --git a/src/renderer/components/virtual-table/headers/duration-header.tsx b/src/renderer/components/virtual-table/headers/duration-header.tsx index bfc36d60..c9fa81c9 100644 --- a/src/renderer/components/virtual-table/headers/duration-header.tsx +++ b/src/renderer/components/virtual-table/headers/duration-header.tsx @@ -1,11 +1,16 @@ import type { IHeaderParams } from '@ag-grid-community/core'; -import { FiClock } from 'react-icons/fi'; +import { Icon } from '/@/shared/components/icon/icon'; export interface ICustomHeaderParams extends IHeaderParams { menuIcon: string; } export const DurationHeader = () => { - return <FiClock size={15} />; + return ( + <Icon + icon="duration" + size="sm" + /> + ); }; diff --git a/src/renderer/components/virtual-table/headers/generic-table-header.module.css b/src/renderer/components/virtual-table/headers/generic-table-header.module.css new file mode 100644 index 00000000..b5f012ca --- /dev/null +++ b/src/renderer/components/virtual-table/headers/generic-table-header.module.css @@ -0,0 +1,38 @@ +.header-wrapper { + display: flex; + width: 100%; + text-transform: uppercase; +} + +.header-wrapper.right { + justify-content: flex-end; +} + +.header-wrapper.center { + justify-content: center; +} + +.header-wrapper.left { + justify-content: flex-start; +} + +.header-text { + width: 100%; + height: 100%; + font-weight: 500; + line-height: inherit; + color: var(--theme-colors-foreground); + text-transform: uppercase; +} + +.header-text.right { + text-align: flex-end; +} + +.header-text.center { + text-align: center; +} + +.header-text.left { + text-align: flex-start; +} diff --git a/src/renderer/components/virtual-table/headers/generic-table-header.tsx b/src/renderer/components/virtual-table/headers/generic-table-header.tsx index 34fde0ee..312d935a 100644 --- a/src/renderer/components/virtual-table/headers/generic-table-header.tsx +++ b/src/renderer/components/virtual-table/headers/generic-table-header.tsx @@ -1,12 +1,11 @@ import type { IHeaderParams } from '@ag-grid-community/core'; import type { ReactNode } from 'react'; -import { AiOutlineNumber } from 'react-icons/ai'; -import { FiClock } from 'react-icons/fi'; -import { RiHeartLine, RiMoreFill, RiStarLine } from 'react-icons/ri'; -import styled from 'styled-components'; +import clsx from 'clsx'; -import { _Text } from '/@/renderer/components/text'; +import styles from './generic-table-header.module.css'; + +import { Icon } from '/@/shared/components/icon/icon'; type Options = { children?: ReactNode; @@ -16,63 +15,35 @@ type Options = { type Presets = 'actions' | 'duration' | 'rowIndex' | 'userFavorite' | 'userRating'; -export const HeaderWrapper = styled.div<{ $position: Options['position'] }>` - display: flex; - justify-content: ${(props) => - props.$position === 'right' - ? 'flex-end' - : props.$position === 'center' - ? 'center' - : 'flex-start'}; - width: 100%; - font-family: var(--content-font-family); - text-transform: uppercase; -`; - -const HeaderText = styled(_Text)<{ $position: Options['position'] }>` - width: 100%; - height: 100%; - font-weight: 500; - line-height: inherit; - color: var(--ag-header-foreground-color); - text-align: ${(props) => - props.$position === 'right' - ? 'flex-end' - : props.$position === 'center' - ? 'center' - : 'flex-start'}; - text-transform: uppercase; -`; - const headerPresets = { actions: ( - <RiMoreFill - color="var(--ag-header-foreground-color)" - size="1em" + <Icon + icon="ellipsisHorizontal" + size="sm" /> ), duration: ( - <FiClock - color="var(--ag-header-foreground-color)" - size="1em" + <Icon + icon="duration" + size="sm" /> ), rowIndex: ( - <AiOutlineNumber - color="var(--ag-header-foreground-color)" - size="1em" + <Icon + icon="hash" + size="sm" /> ), userFavorite: ( - <RiHeartLine - color="var(--ag-header-foreground-color)" - size="1em" + <Icon + icon="favorite" + size="sm" /> ), userRating: ( - <RiStarLine - color="var(--ag-header-foreground-color)" - size="1em" + <Icon + icon="star" + size="sm" /> ), }; @@ -82,18 +53,18 @@ export const GenericTableHeader = ( { children, position, preset }: Options, ) => { if (preset) { - return <HeaderWrapper $position={position}>{headerPresets[preset]}</HeaderWrapper>; + return ( + <div className={clsx(styles.headerWrapper, styles[position ?? 'left'])}> + {headerPresets[preset]} + </div> + ); } return ( - <HeaderWrapper $position={position}> - <HeaderText - $position={position} - overflow="hidden" - weight={500} - > + <div className={clsx(styles.headerWrapper, styles[position ?? 'left'])}> + <div className={clsx(styles.headerText, styles[position ?? 'left'])}> {children || displayName} - </HeaderText> - </HeaderWrapper> + </div> + </div> ); }; diff --git a/src/renderer/components/virtual-table/hooks/use-fixed-table-header.tsx b/src/renderer/components/virtual-table/hooks/use-fixed-table-header.tsx index 6c3529b2..91dac334 100644 --- a/src/renderer/components/virtual-table/hooks/use-fixed-table-header.tsx +++ b/src/renderer/components/virtual-table/hooks/use-fixed-table-header.tsx @@ -1,4 +1,4 @@ -import { useInView } from 'framer-motion'; +import { useInView } from 'motion/react'; import { useEffect, useRef } from 'react'; import { useWindowSettings } from '/@/renderer/store/settings.store'; diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx index da50f408..e650db89 100644 --- a/src/renderer/components/virtual-table/index.tsx +++ b/src/renderer/components/virtual-table/index.tsx @@ -15,11 +15,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { AgGridReact } from '@ag-grid-community/react'; import { useClickOutside, useMergedRef } from '@mantine/hooks'; +import clsx from 'clsx'; import formatDuration from 'format-duration'; -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence } from 'motion/react'; import { forwardRef, Ref, useCallback, useEffect, useMemo, useRef } from 'react'; import { generatePath } from 'react-router'; -import styled from 'styled-components'; + +import styles from './virtual-table.module.css'; import i18n from '/@/i18n/i18n'; import { ActionsCell } from '/@/renderer/components/virtual-table/cells/actions-cell'; @@ -57,18 +59,18 @@ export * from './table-config-dropdown'; export * from './table-pagination'; export * from './utils'; -const TableWrapper = styled.div` - position: relative; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; -`; +// const TableWrapper = styled.div` +// position: relative; +// display: flex; +// flex-direction: column; +// width: 100%; +// height: 100%; +// `; -const DummyHeader = styled.div<{ height?: number }>` - position: absolute; - height: ${({ height }) => height || 36}px; -`; +// const DummyHeader = styled.div<{ height?: number }>` +// position: absolute; +// height: ${({ height }) => height || 36}px; +// `; const tableColumns: { [key: string]: ColDef } = { actions: { @@ -593,15 +595,20 @@ export const VirtualTable = forwardRef( const mergedWrapperRef = useMergedRef(deselectRef, tableContainerRef); return ( - <TableWrapper - className={ + <div + className={clsx( + styles.tableWrapper, transparentHeader ? 'ag-theme-alpine-dark ag-header-transparent' - : 'ag-theme-alpine-dark' - } + : 'ag-theme-alpine-dark', + )} ref={mergedWrapperRef} > - <DummyHeader ref={tableHeaderRef} /> + <div + className={styles.dummyHeader} + ref={tableHeaderRef} + style={{ height: rest.headerHeight ?? 36 }} + /> <AgGridReact animateRows blockLoadDebounceMillis={200} @@ -639,7 +646,7 @@ export const VirtualTable = forwardRef( /> </AnimatePresence> )} - </TableWrapper> + </div> ); }, ); diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index 259700ba..94ff51df 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -3,11 +3,11 @@ import type { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import i18n from '/@/i18n/i18n'; -import { Option } from '/@/renderer/components/option'; -import { MultiSelect } from '/@/renderer/components/select'; -import { Slider } from '/@/renderer/components/slider'; -import { Switch } from '/@/renderer/components/switch'; import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { MultiSelect } from '/@/shared/components/multi-select/multi-select'; +import { Option } from '/@/shared/components/option/option'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Switch } from '/@/shared/components/switch/switch'; import { TableColumn, TableType } from '/@/shared/types/types'; export const SONG_TABLE_COLUMNS = [ @@ -292,7 +292,7 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => { const { setSettings } = useSettingsStoreActions(); const tableConfig = useSettingsStore((state) => state.tables); - const handleAddOrRemoveColumns = (values: TableColumn[]) => { + const handleAddOrRemoveColumns = (values: string[]) => { const existingColumns = tableConfig[type].columns; if (values.length === 0) { @@ -421,8 +421,8 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => { clearable data={SONG_TABLE_COLUMNS} defaultValue={tableConfig[type]?.columns.map((column) => column.column)} - dropdownPosition="bottom" onChange={handleAddOrRemoveColumns} + variant="filled" width={300} /> </Option.Control> diff --git a/src/renderer/components/virtual-table/table-pagination.tsx b/src/renderer/components/virtual-table/table-pagination.tsx index 00b03004..63cc9ea2 100644 --- a/src/renderer/components/virtual-table/table-pagination.tsx +++ b/src/renderer/components/virtual-table/table-pagination.tsx @@ -1,20 +1,19 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Group } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { MutableRefObject } from 'react'; -import { useTranslation } from 'react-i18next'; -import { RiHashtag } from 'react-icons/ri'; -import { Button } from '/@/renderer/components/button'; -import { NumberInput } from '/@/renderer/components/input'; -import { MotionFlex } from '/@/renderer/components/motion'; -import { Pagination } from '/@/renderer/components/pagination'; -import { Popover } from '/@/renderer/components/popover'; -import { Text } from '/@/renderer/components/text'; import { useContainerQuery } from '/@/renderer/hooks'; import { ListKey } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Pagination } from '/@/shared/components/pagination/pagination'; +import { Popover } from '/@/shared/components/popover/popover'; +import { Text } from '/@/shared/components/text/text'; import { TablePagination as TablePaginationType } from '/@/shared/types/types'; interface TablePaginationProps { @@ -32,7 +31,6 @@ export const TablePagination = ({ setPagination, tableRef, }: TablePaginationProps) => { - const { t } = useTranslation(); const [isGoToPageOpen, handlers] = useDisclosure(false); const containerQuery = useContainerQuery(); @@ -71,19 +69,15 @@ export const TablePagination = ({ currentPageMaxIndex > pagination.totalItems ? pagination.totalItems : currentPageMaxIndex; return ( - <MotionFlex + <Flex align="center" - animate={{ y: 0 }} - exit={{ y: 50 }} - initial={{ y: 50 }} justify="space-between" - layout p="1rem" ref={containerQuery.ref} - sx={{ borderTop: '1px solid var(--generic-border-color)' }} + style={{ borderTop: '1px solid var(--theme-generic-border-color)' }} > <Text - $secondary + isMuted size="md" > {containerQuery.isMd ? ( @@ -104,9 +98,9 @@ export const TablePagination = ({ )} </Text> <Group - noWrap + gap="sm" ref={containerQuery.ref} - spacing="sm" + wrap="nowrap" > <Popover onClose={() => handlers.close()} @@ -115,18 +109,13 @@ export const TablePagination = ({ trapFocus > <Popover.Target> - <Button + <ActionIcon + icon="hash" onClick={() => handlers.toggle()} radius="sm" size="sm" - sx={{ height: '26px', padding: '0', width: '26px' }} - tooltip={{ - label: t('action.goToPage', { postProcess: 'sentenceCase' }), - }} - variant="default" - > - <RiHashtag size={15} /> - </Button> + style={{ height: '26px', padding: '0', width: '26px' }} + /> </Popover.Target> <Popover.Dropdown> <form onSubmit={handleGoSubmit}> @@ -149,9 +138,7 @@ export const TablePagination = ({ </Popover.Dropdown> </Popover> <Pagination - $hideDividers={!containerQuery.isSm} boundaries={1} - noWrap onChange={handlePagination} radius="sm" siblings={containerQuery.isMd ? 2 : containerQuery.isSm ? 1 : 0} @@ -159,6 +146,6 @@ export const TablePagination = ({ value={pagination.currentPage + 1} /> </Group> - </MotionFlex> + </Flex> ); }; diff --git a/src/renderer/components/virtual-table/virtual-table.module.css b/src/renderer/components/virtual-table/virtual-table.module.css new file mode 100644 index 00000000..260ce048 --- /dev/null +++ b/src/renderer/components/virtual-table/virtual-table.module.css @@ -0,0 +1,11 @@ +.table-wrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.dummy-header { + position: absolute; +} diff --git a/src/renderer/features/action-required/components/action-required-container.tsx b/src/renderer/features/action-required/components/action-required-container.tsx index 5513cad9..6b8fcab3 100644 --- a/src/renderer/features/action-required/components/action-required-container.tsx +++ b/src/renderer/features/action-required/components/action-required-container.tsx @@ -1,8 +1,9 @@ -import { Group, Stack } from '@mantine/core'; import { ReactNode } from 'react'; -import { RiAlertFill } from 'react-icons/ri'; -import { Text } from '/@/renderer/components'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; interface ActionRequiredContainerProps { children: ReactNode; @@ -10,15 +11,16 @@ interface ActionRequiredContainerProps { } export const ActionRequiredContainer = ({ children, title }: ActionRequiredContainerProps) => ( - <Stack sx={{ cursor: 'default', maxWidth: '700px' }}> + <Stack style={{ cursor: 'default', maxWidth: '700px' }}> <Group> - <RiAlertFill - color="var(--warning-color)" - size={30} + <Icon + fill="warn" + icon="warn" + size="lg" /> <Text size="xl" - sx={{ textTransform: 'uppercase' }} + style={{ textTransform: 'uppercase' }} > {title} </Text> diff --git a/src/renderer/features/action-required/components/error-fallback.module.css b/src/renderer/features/action-required/components/error-fallback.module.css new file mode 100644 index 00000000..2f1e52c7 --- /dev/null +++ b/src/renderer/features/action-required/components/error-fallback.module.css @@ -0,0 +1,3 @@ +.container { + background: var(--theme-colors-background); +} diff --git a/src/renderer/features/action-required/components/error-fallback.tsx b/src/renderer/features/action-required/components/error-fallback.tsx index 632f9001..02b70dcb 100644 --- a/src/renderer/features/action-required/components/error-fallback.tsx +++ b/src/renderer/features/action-required/components/error-fallback.tsx @@ -1,39 +1,42 @@ import type { FallbackProps } from 'react-error-boundary'; -import { Box, Center, Group, Stack } from '@mantine/core'; -import { RiErrorWarningLine } from 'react-icons/ri'; +import { useTranslation } from 'react-i18next'; import { useRouteError } from 'react-router'; -import styled from 'styled-components'; -import { Button, Text } from '/@/renderer/components'; +import styles from './error-fallback.module.css'; -const Container = styled(Box)` - background: var(--main-bg); -`; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => { const error = useRouteError() as any; + const { t } = useTranslation(); return ( - <Container> - <Center sx={{ height: '100vh' }}> - <Stack sx={{ maxWidth: '50%' }}> - <Group spacing="xs"> - <RiErrorWarningLine - color="var(--danger-color)" - size={30} + <div className={styles.container}> + <Center style={{ height: '100vh' }}> + <Stack style={{ maxWidth: '50%' }}> + <Group gap="xs"> + <Icon + fill="error" + icon="error" + size="lg" /> - <Text size="lg">Something went wrong</Text> + <Text size="lg">{t('error.genericError')}</Text> </Group> <Text>{error?.message}</Text> <Button onClick={resetErrorBoundary} variant="filled" > - Reload + {t('common.reload')} </Button> </Stack> </Center> - </Container> + </div> ); }; diff --git a/src/renderer/features/action-required/components/mpv-required.tsx b/src/renderer/features/action-required/components/mpv-required.tsx index edd3beda..0eb716c2 100644 --- a/src/renderer/features/action-required/components/mpv-required.tsx +++ b/src/renderer/features/action-required/components/mpv-required.tsx @@ -2,8 +2,11 @@ import isElectron from 'is-electron'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Checkbox, FileInput, Text } from '/@/renderer/components'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { FileInput } from '/@/shared/components/file-input/file-input'; +import { Text } from '/@/shared/components/text/text'; import { PlaybackType } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -15,7 +18,8 @@ export const MpvRequired = () => { const [disabled, setDisabled] = useState(false); const { t } = useTranslation(); - const handleSetMpvPath = (e: File) => { + const handleSetMpvPath = (e: File | null) => { + if (!e) return; localSettings?.set('mpv_path', e.path); }; diff --git a/src/renderer/features/action-required/components/route-error-boundary.tsx b/src/renderer/features/action-required/components/route-error-boundary.tsx index dc6990cd..d277e6a4 100644 --- a/src/renderer/features/action-required/components/route-error-boundary.tsx +++ b/src/renderer/features/action-required/components/route-error-boundary.tsx @@ -1,12 +1,20 @@ -import { Box, Center, Divider, Group, Stack } from '@mantine/core'; -import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line, RiMenuFill } from 'react-icons/ri'; +import { useTranslation } from 'react-i18next'; import { useNavigate, useRouteError } from 'react-router'; -import { Button, DropdownMenu, Text } from '/@/renderer/components'; import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; import { AppRoute } from '/@/renderer/router/routes'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; const RouteErrorBoundary = () => { + const { t } = useTranslation(); const navigate = useNavigate(); const error = useRouteError() as any; console.log('error', error); @@ -24,47 +32,47 @@ const RouteErrorBoundary = () => { }; return ( - <Box bg="var(--main-bg)"> - <Center sx={{ height: '100vh' }}> - <Stack sx={{ maxWidth: '50%' }}> + <div style={{ backgroundColor: 'var(--theme-colors-background)' }}> + <Center style={{ height: '100vh' }}> + <Stack style={{ maxWidth: '50%' }}> <Group> - <Button + <ActionIcon + icon="arrowLeftS" onClick={handleReturn} px={10} variant="subtle" - > - <RiArrowLeftSLine size={20} /> - </Button> - <RiErrorWarningLine - color="var(--danger-color)" - size={30} /> - <Text size="lg">Something went wrong</Text> + <Icon + fill="error" + icon="error" + size="lg" + /> + <Text size="lg">{t('error.genericError')}</Text> </Group> <Divider my={5} /> <Text size="sm">{error?.message}</Text> <Group + gap="sm" grow - spacing="sm" > <Button - leftIcon={<RiHome4Line />} + leftSection={<Icon icon="home" />} onClick={handleHome} size="md" - sx={{ flex: 0.5 }} + style={{ flex: 0.5 }} variant="default" > - Go home + {t('page.home.title')} </Button> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> <Button - leftIcon={<RiMenuFill />} + leftSection={<Icon icon="menu" />} size="md" - sx={{ flex: 0.5 }} + style={{ flex: 0.5 }} variant="default" > - Menu + {t('common.menu')} </Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> @@ -78,12 +86,12 @@ const RouteErrorBoundary = () => { size="md" variant="filled" > - Reload + {t('common.reload')} </Button> </Group> </Stack> </Center> - </Box> + </div> ); }; diff --git a/src/renderer/features/action-required/components/server-credential-required.tsx b/src/renderer/features/action-required/components/server-credential-required.tsx index 2b7101f5..3d29cff4 100644 --- a/src/renderer/features/action-required/components/server-credential-required.tsx +++ b/src/renderer/features/action-required/components/server-credential-required.tsx @@ -1,5 +1,5 @@ -import { Text } from '/@/renderer/components'; import { useCurrentServer } from '/@/renderer/store'; +import { Text } from '/@/shared/components/text/text'; export const ServerCredentialRequired = () => { const currentServer = useCurrentServer(); diff --git a/src/renderer/features/action-required/components/server-required.tsx b/src/renderer/features/action-required/components/server-required.tsx index dfbca5d7..aa6b9e2a 100644 --- a/src/renderer/features/action-required/components/server-required.tsx +++ b/src/renderer/features/action-required/components/server-required.tsx @@ -1,25 +1,161 @@ -import { RiMenuFill } from 'react-icons/ri'; +import { closeAllModals, openModal } from '@mantine/modals'; +import isElectron from 'is-electron'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; -import { Button, DropdownMenu, Text } from '/@/renderer/components'; -import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; +import { AddServerForm } from '/@/renderer/features/servers'; +import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; +import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; +import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; +import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store'; +import { Accordion } from '/@/shared/components/accordion/accordion'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { ServerListItem, ServerType } from '/@/shared/types/domain-types'; + +const localSettings = isElectron() ? window.api.localSettings : null; export const ServerRequired = () => { + const { t } = useTranslation(); + const serverList = useServerList(); + + const serverLock = + (localSettings + ? !!localSettings.env.SERVER_LOCK + : !!window.SERVER_LOCK && + window.SERVER_TYPE && + window.SERVER_NAME && + window.SERVER_URL) || false; + + if (Object.keys(serverList).length > 0) { + return ( + <ScrollArea> + <Stack miw="300px"> + <ServerSelector /> + {serverLock && ( + <> + <Divider my="lg" /> + <Accordion> + <Accordion.Item value="add-server"> + <Accordion.Control> + {t('form.addServer.title', { postProcess: 'titleCase' })} + </Accordion.Control> + <Accordion.Panel> + <AddServerForm onCancel={null} /> + </Accordion.Panel> + </Accordion.Item> + </Accordion> + </> + )} + </Stack> + </ScrollArea> + ); + } + + return <AddServerForm onCancel={null} />; +}; + +function ServerSelector() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const serverList = useServerList(); + const currentServer = useCurrentServer(); + const { setCurrentServer } = useAuthStoreActions(); + + const handleSetCurrentServer = (server: ServerListItem) => { + navigate(AppRoute.HOME); + setCurrentServer(server); + }; + + const handleCredentialsModal = async (server: ServerListItem) => { + let password: null | string = null; + + try { + if (localSettings && server.savePassword) { + password = await localSettings.passwordGet(server.id); + } + } catch (error) { + console.error(error); + } + openModal({ + children: server && ( + <EditServerForm + isUpdate + onCancel={closeAllModals} + password={password} + server={server} + /> + ), + size: 'sm', + title: t('form.updateServer.title', { postProcess: 'titleCase' }), + }); + }; + return ( <> - <Text>No server selected.</Text> - <DropdownMenu> - <DropdownMenu.Target> + {Object.keys(serverList).map((serverId) => { + const server = serverList[serverId]; + const isNavidromeExpired = + server.type === ServerType.NAVIDROME && !server.ndCredential; + const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; + const isSessionExpired = isNavidromeExpired || isJellyfinExpired; + + const logo = + server.type === ServerType.NAVIDROME + ? NavidromeLogo + : server.type === ServerType.JELLYFIN + ? JellyfinLogo + : OpenSubsonicLogo; + + return ( <Button - leftIcon={<RiMenuFill />} - variant="filled" + key={`server-${server.id}`} + onClick={() => { + if (!isSessionExpired) return handleSetCurrentServer(server); + return handleCredentialsModal(server); + }} + size="lg" + styles={{ + label: { + width: '100%', + }, + root: { + padding: 'var(--theme-spacing-sm)', + }, + }} + variant={server.id === currentServer?.id ? 'filled' : 'default'} > - Open menu + <Group + justify="space-between" + w="100%" + > + <Group> + <img + src={logo} + style={{ + height: 'var(--theme-font-size-2xl)', + width: 'var(--theme-font-size-2xl)', + }} + /> + <Text + fw={600} + size="lg" + > + {server.name} + </Text> + </Group> + {isSessionExpired ? <Icon icon="lock" /> : <Icon icon="arrowRight" />} + </Group> </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <AppMenu /> - </DropdownMenu.Dropdown> - </DropdownMenu> + ); + })} </> ); -}; +} diff --git a/src/renderer/features/action-required/routes/action-required-route.tsx b/src/renderer/features/action-required/routes/action-required-route.tsx index c8037fe4..ab9daf5e 100644 --- a/src/renderer/features/action-required/routes/action-required-route.tsx +++ b/src/renderer/features/action-required/routes/action-required-route.tsx @@ -1,10 +1,8 @@ -import { Center, Group, Stack } from '@mantine/core'; import { openModal } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; -import { RiCheckFill, RiEdit2Line, RiHome4Line } from 'react-icons/ri'; -import { Link } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; -import { Button, PageHeader, Text } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container'; import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required'; import { ServerRequired } from '/@/renderer/features/action-required/components/server-required'; @@ -12,6 +10,11 @@ import { ServerList } from '/@/renderer/features/servers'; import { AnimatedPage } from '/@/renderer/features/shared'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; const ActionRequiredRoute = () => { const { t } = useTranslation(); @@ -45,12 +48,12 @@ const ActionRequiredRoute = () => { return ( <AnimatedPage> <PageHeader /> - <Center sx={{ height: '100%', width: '100vw' }}> + <Center style={{ height: '100%', width: '100vw' }}> <Stack - spacing="xl" - sx={{ maxWidth: '50%' }} + gap="xl" + style={{ maxWidth: '50%' }} > - <Group noWrap> + <Group wrap="nowrap"> {displayedCheck && ( <ActionRequiredContainer title={displayedCheck.title}> {displayedCheck?.component} @@ -58,37 +61,15 @@ const ActionRequiredRoute = () => { )} </Group> <Stack mt="2rem"> - {canReturnHome && ( - <> - <Group - noWrap - position="center" - > - <RiCheckFill - color="var(--success-color)" - size={30} - /> - <Text size="xl">No issues found</Text> - </Group> - <Button - component={Link} - disabled={!canReturnHome} - leftIcon={<RiHome4Line />} - to={AppRoute.HOME} - variant="filled" - > - Go back - </Button> - </> - )} + {canReturnHome && <Navigate to={AppRoute.HOME} />} {!displayedCheck && ( <Group - noWrap - position="center" + justify="center" + wrap="nowrap" > <Button fullWidth - leftIcon={<RiEdit2Line />} + leftSection={<Icon icon="edit" />} onClick={handleManageServersModal} variant="filled" > diff --git a/src/renderer/features/action-required/routes/invalid-route.tsx b/src/renderer/features/action-required/routes/invalid-route.tsx index 66e1f5de..8ac4ea4b 100644 --- a/src/renderer/features/action-required/routes/invalid-route.tsx +++ b/src/renderer/features/action-required/routes/invalid-route.tsx @@ -1,35 +1,41 @@ -import { Center, Group, Stack } from '@mantine/core'; -import { RiQuestionLine } from 'react-icons/ri'; +import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; -import { Button, Text } from '/@/renderer/components'; import { AnimatedPage } from '/@/renderer/features/shared'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; const InvalidRoute = () => { + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); return ( <AnimatedPage> - <Center sx={{ height: '100%', width: '100%' }}> + <Center style={{ height: '100%', width: '100%' }}> <Stack> <Group - noWrap - position="center" + justify="center" + wrap="nowrap" > - <RiQuestionLine - color="var(--warning-color)" - size={30} + <Icon + color="warn" + icon="error" /> - <Text size="xl">Page not found</Text> + <Text size="xl"> + {t('error.apiRouteError', { postProcess: 'sentenceCase' })} + </Text> </Group> <Text>{location.pathname}</Text> - <Button + <ActionIcon + icon="arrowLeftS" onClick={() => navigate(-1)} variant="filled" - > - Go back - </Button> + /> </Stack> </Center> </AnimatedPage> diff --git a/src/renderer/features/albums/components/album-detail-content.module.css b/src/renderer/features/albums/components/album-detail-content.module.css new file mode 100644 index 00000000..42db7c56 --- /dev/null +++ b/src/renderer/features/albums/components/album-detail-content.module.css @@ -0,0 +1,12 @@ +.content-container { + position: relative; + z-index: 0; +} + +.detail-container { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-lg); + padding: 1rem 2rem 5rem; + overflow: hidden; +} diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 5e9b784f..939d4bf3 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -1,20 +1,16 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core'; -import { Box, Group, Stack } from '@mantine/core'; import { useSetState } from '@mantine/hooks'; import { MutableRefObject, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { FaLastfmSquare } from 'react-icons/fa'; -import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri'; -import { SiMusicbrainz } from 'react-icons/si'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; + +import styles from './album-detail-content.module.css'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, Popover, Spoiler } from '/@/renderer/components'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; +import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; import { getColumnDefs, TableConfigDropdown, @@ -47,6 +43,12 @@ import { useTableSettings, } from '/@/renderer/store/settings.store'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Popover } from '/@/shared/components/popover/popover'; +import { Spoiler } from '/@/shared/components/spoiler/spoiler'; +import { Stack } from '/@/shared/components/stack/stack'; import { AlbumListQuery, AlbumListSort, @@ -60,19 +62,6 @@ const isFullWidthRow = (node: RowNode) => { return node.id?.startsWith('disc-'); }; -const ContentContainer = styled.div` - position: relative; - z-index: 0; -`; - -const DetailContainer = styled.div` - display: flex; - flex-direction: column; - gap: 2rem; - padding: 1rem 2rem 5rem; - overflow: hidden; -`; - interface AlbumDetailContentProps { background?: string; tableRef: MutableRefObject<AgGridReactType | null>; @@ -330,72 +319,73 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP const mbzId = detailQuery?.data?.mbzId; return ( - <ContentContainer> - <LibraryBackgroundOverlay $backgroundColor={background} /> - <DetailContainer> - <Box component="section"> + <div + className={styles.contentContainer} + ref={cq.ref} + > + <LibraryBackgroundOverlay backgroundColor={background} /> + <div className={styles.detailContainer}> + <section> <Group - position="apart" - spacing="sm" + gap="sm" + justify="space-between" > <Group> <PlayButton onClick={() => handlePlay(playButtonBehavior)} /> - <Button - compact - loading={ - createFavoriteMutation.isLoading || - deleteFavoriteMutation.isLoading - } - onClick={handleFavorite} - variant="subtle" - > - {detailQuery?.data?.userFavorite ? ( - <RiHeartFill - color="red" - size={20} - /> - ) : ( - <RiHeartLine size={20} /> - )} - </Button> - <Button - compact - onClick={(e) => { - if (!detailQuery?.data) return; - handleGeneralContextMenu(e, [detailQuery.data!]); - }} - variant="subtle" - > - <RiMoreFill size={20} /> - </Button> + <Group gap="xs"> + <ActionIcon + icon="favorite" + iconProps={{ + fill: detailQuery?.data?.userFavorite + ? 'primary' + : undefined, + }} + loading={ + createFavoriteMutation.isLoading || + deleteFavoriteMutation.isLoading + } + onClick={handleFavorite} + size="lg" + variant="transparent" + /> + <ActionIcon + icon="ellipsisHorizontal" + onClick={(e) => { + if (!detailQuery?.data) return; + handleGeneralContextMenu(e, [detailQuery.data!]); + }} + size="lg" + variant="transparent" + /> + </Group> </Group> - <Popover position="bottom-end"> <Popover.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings2Fill size={20} /> - </Button> + <ActionIcon + icon="settings" + onClick={(e) => { + if (!detailQuery?.data) return; + handleGeneralContextMenu(e, [detailQuery.data!]); + }} + size="lg" + variant="transparent" + /> </Popover.Target> <Popover.Dropdown> <TableConfigDropdown type="albumDetail" /> </Popover.Dropdown> </Popover> </Group> - </Box> + </section> {showGenres && ( - <Box component="section"> - <Group spacing="sm"> + <section> + <Group gap="sm"> {detailQuery?.data?.genres?.map((genre) => ( <Button - compact component={Link} key={`genre-${genre.id}`} - radius={0} - size="md" + radius="md" + size="compact-md" to={generatePath(genreRoute, { genreId: genre.id, })} @@ -405,35 +395,38 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP </Button> ))} </Group> - </Box> + </section> )} {externalLinks && (lastFM || musicBrainz) ? ( - <Box component="section"> - <Group spacing="sm"> - {lastFM && ( - <Button - compact - component="a" - href={`https://www.last.fm/music/${encodeURIComponent( - detailQuery?.data?.albumArtist || '', - )}/${encodeURIComponent(detailQuery.data?.name || '')}`} - radius="md" - rel="noopener noreferrer" - size="md" - target="_blank" - tooltip={{ - label: t('action.openIn.lastfm'), - }} - variant="subtle" - > - <FaLastfmSquare size={25} /> - </Button> - )} - {musicBrainz && mbzId ? ( - <Button - compact + <section> + <Group gap="sm"> + <ActionIcon + component="a" + href={`https://www.last.fm/music/${encodeURIComponent( + detailQuery?.data?.albumArtist || '', + )}/${encodeURIComponent(detailQuery.data?.name || '')}`} + icon="brandLastfm" + iconProps={{ + fill: 'default', + size: 'xl', + }} + radius="md" + rel="noopener noreferrer" + target="_blank" + tooltip={{ + label: t('action.openIn.lastfm'), + }} + variant="subtle" + /> + {mbzId ? ( + <ActionIcon component="a" href={`https://musicbrainz.org/release/${mbzId}`} + icon="brandMusicBrainz" + iconProps={{ + fill: 'default', + size: 'xl', + }} radius="md" rel="noopener noreferrer" size="md" @@ -442,19 +435,17 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP label: t('action.openIn.musicbrainz'), }} variant="subtle" - > - <SiMusicbrainz size={25} /> - </Button> + /> ) : null} </Group> - </Box> + </section> ) : null} {comment && ( - <Box component="section"> + <section> <Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler> - </Box> + </section> )} - <Box style={{ minHeight: '300px' }}> + <div style={{ minHeight: '300px' }}> <VirtualTable autoFitColumns={tableConfig.autoFit} autoHeight @@ -491,11 +482,11 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP suppressLoadingOverlay suppressRowDrag /> - </Box> + </div> <Stack + gap="lg" mt="3rem" ref={cq.ref} - spacing="lg" > {cq.height || cq.width ? ( <> @@ -547,7 +538,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP </> ) : null} </Stack> - </DetailContainer> - </ContentContainer> + </div> + </div> ); }; diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index 854d214a..fae91f6a 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -1,11 +1,9 @@ -import { Group, Stack } from '@mantine/core'; import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Rating, Text } from '/@/renderer/components'; import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; import { LibraryHeader, useSetRating } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; @@ -14,6 +12,10 @@ import { queryClient } from '/@/renderer/lib/react-query'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; +import { Group } from '/@/shared/components/group/group'; +import { Rating } from '/@/shared/components/rating/rating'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; import { AlbumDetailResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types'; interface AlbumDetailHeaderProps { @@ -129,17 +131,17 @@ export const AlbumDetailHeader = forwardRef( title={detailQuery?.data?.name || ''} {...background} > - <Stack spacing="sm"> - <Group spacing="sm"> + <Stack gap="sm"> + <Group gap="sm"> {metadataItems.map((item, index) => ( <Fragment key={`item-${item.id}-${index}`}> - {index > 0 && <Text $noSelect>•</Text>} + {index > 0 && <Text isNoSelect>•</Text>} <Text>{item.value}</Text> </Fragment> ))} {showRating && ( <> - <Text $noSelect>•</Text> + <Text isNoSelect>•</Text> <Rating onChange={handleUpdateRating} readOnly={ @@ -152,9 +154,9 @@ export const AlbumDetailHeader = forwardRef( )} </Group> <Group + gap="md" mah="4rem" - spacing="md" - sx={{ + style={{ overflow: 'hidden', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, @@ -162,9 +164,9 @@ export const AlbumDetailHeader = forwardRef( > {detailQuery?.data?.albumArtists.map((artist) => ( <Text - $link component={Link} fw={600} + isLink key={`artist-${artist.id}`} to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: artist.id, diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index ddc80972..2fe52c72 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const AlbumListGridView = lazy(() => @@ -32,7 +32,7 @@ export const AlbumListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont return ( <Suspense fallback={<Spinner container />}> - {display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? ( + {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( <AlbumListGridView gridRef={gridRef} itemCount={itemCount} diff --git a/src/renderer/features/albums/components/album-list-grid-view.tsx b/src/renderer/features/albums/components/album-list-grid-view.tsx index 785a1e5f..d7b53a43 100644 --- a/src/renderer/features/albums/components/album-list-grid-view.tsx +++ b/src/renderer/features/albums/components/album-list-grid-view.tsx @@ -6,7 +6,7 @@ import { ListOnScrollProps } from 'react-window'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { ALBUM_CARD_ROWS } from '/@/renderer/components'; +import { ALBUM_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 9756e8ff..0883b6d2 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -1,24 +1,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { openModal } from '@mantine/modals'; import { useQueryClient } from '@tanstack/react-query'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; +import debounce from 'lodash/debounce'; +import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiFilterFill, - RiFolder2Fill, - RiMoreFill, - RiPlayFill, - RiRefreshLine, - RiSettings3Fill, -} from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; @@ -26,6 +15,11 @@ import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jel import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { FilterButton } from '/@/renderer/features/shared/components/filter-button'; +import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { @@ -34,6 +28,12 @@ import { useListStoreActions, useListStoreByKey, } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { AlbumListQuery, AlbumListSort, @@ -228,7 +228,7 @@ export const AlbumListHeaderFilters = ({ ?.name) || 'Unknown'; - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const onFilterChange = useCallback( (filter: AlbumListFilter) => { @@ -355,19 +355,20 @@ export const AlbumListHeaderFilters = ({ } }; + const debouncedHandleItemSize = debounce(handleItemSize, 20); + const handleItemGap = (e: number) => { setGrid({ data: { itemGap: e }, key: pageKey }); }; const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + (displayType: ListDisplayType) => { + setDisplayType({ data: displayType, key: pageKey }); }, [pageKey, setDisplayType], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -379,7 +380,7 @@ export const AlbumListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { column: values[values.length - 1] as TableColumn, width: 100 }; setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } else { @@ -393,10 +394,10 @@ export const AlbumListHeaderFilters = ({ return tableRef.current?.api.sizeColumnsToFit(); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -439,25 +440,18 @@ export const AlbumListHeaderFilters = ({ return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw={600} - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -477,26 +471,12 @@ export const AlbumListHeaderFilters = ({ <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw={600} - size="md" - sx={{ - svg: { - fill: isFolderFilterApplied - ? 'var(--primary-color) !important' - : undefined, - }, - }} - variant="subtle" - > - <RiFolder2Fill size="1.3rem" /> - </Button> + <FolderButton isActive={!!isFolderFilterApplied} /> </DropdownMenu.Target> <DropdownMenu.Dropdown> {musicFoldersQuery.data?.items.map((folder) => ( <DropdownMenu.Item - $isActive={filter.musicFolderId === folder.id} + isSelected={filter.musicFolderId === folder.id} key={`musicFolder-${folder.id}`} onClick={handleSetMusicFolder} value={folder.id} @@ -508,66 +488,37 @@ export const AlbumListHeaderFilters = ({ </DropdownMenu> </> )} - <Divider orientation="vertical" /> - <Button - compact + <FilterButton + isActive={!!isFilterApplied} onClick={handleOpenFiltersModal} - size="md" - sx={{ - svg: { - fill: isFilterApplied ? 'var(--primary-color) !important' : undefined, - }, - }} - tooltip={{ - label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }), - }} - variant="subtle" - > - <RiFilterFill size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiMoreFill size={15} /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiPlayFill />} + leftSection={<Icon icon="mediaPlay" />} onClick={() => handlePlay?.({ playType: Play.NOW })} > {t('player.play', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddBoxFill />} + leftSection={<Icon icon="mediaPlayLast" />} onClick={() => handlePlay?.({ playType: Play.LAST })} > {t('player.addLast', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddCircleFill />} + leftSection={<Icon icon="mediaPlayNext" />} onClick={() => handlePlay?.({ playType: Play.NEXT })} > {t('player.addNext', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Divider /> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { postProcess: 'sentenceCase' })} @@ -576,118 +527,23 @@ export const AlbumListHeaderFilters = ({ </DropdownMenu> </Group> <Group - noWrap - spacing="sm" + gap="sm" + wrap="nowrap" > - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - tooltip={{ - label: t('common.configure', { postProcess: 'sentenceCase' }), - }} - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - {/* <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE_PAGINATED} - value={ListDisplayType.TABLE_PAGINATED} - onClick={handleSetViewType} - > - Table (paginated) - </DropdownMenu.Item> */} - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.itemSize', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight} - max={isGrid ? 300 : 100} - min={isGrid ? 100 : 25} - onChangeEnd={handleItemSize} - /> - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.itemGap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - {(display === ListDisplayType.TABLE || - display === ListDisplayType.TABLE_PAGINATED) && ( - <> - <DropdownMenu.Label>Table Columns</DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={ALBUM_TABLE_COLUMNS} - defaultValue={table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text>Auto Fit Columns</Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + <ListConfigMenu + autoFitColumns={table.autoFit} + disabledViewTypes={[ListDisplayType.LIST]} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={ALBUM_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 37f50796..23f50858 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -1,18 +1,21 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { type ChangeEvent, type MutableRefObject, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store'; import { titleCase } from '/@/renderer/utils'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface AlbumListHeaderProps { @@ -59,10 +62,10 @@ export const AlbumListHeader = ({ return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader backgroundColor="var(--theme-colors-background)"> <Flex justify="space-between" w="100%" @@ -85,7 +88,6 @@ export const AlbumListHeader = ({ <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index b57d834f..a86fbc81 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -1,14 +1,19 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useGenreList } from '/@/renderer/features/genres'; import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; import { AlbumListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { AlbumArtistListSort, AlbumListQuery, @@ -186,8 +191,8 @@ export const JellyfinAlbumFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`nd-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index b3fcc0e9..11581535 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -1,14 +1,19 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useGenreList } from '/@/renderer/features/genres'; import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { AlbumArtistListSort, AlbumListQuery, @@ -233,8 +238,8 @@ export const NavidromeAlbumFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`nd-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 4da79903..73796830 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -1,11 +1,16 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { AlbumListQuery, GenreListSort, @@ -100,8 +105,8 @@ export const SubsonicAlbumFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`nd-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/albums/routes/album-detail-route.tsx b/src/renderer/features/albums/routes/album-detail-route.tsx index 2a6c59f9..cf55f165 100644 --- a/src/renderer/features/albums/routes/album-detail-route.tsx +++ b/src/renderer/features/albums/routes/album-detail-route.tsx @@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { useRef } from 'react'; import { useParams } from 'react-router'; -import { NativeScrollArea } from '/@/renderer/components'; +import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content'; import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header'; import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.module.css b/src/renderer/features/albums/routes/dummy-album-detail-route.module.css new file mode 100644 index 00000000..7f74ad16 --- /dev/null +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.module.css @@ -0,0 +1,7 @@ +.detail-container { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 1rem 2rem 5rem; + overflow: hidden; +} diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx index f49956f0..d5f0b9aa 100644 --- a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx @@ -1,15 +1,13 @@ -import { Box, Center, Group, Stack } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { styled } from 'styled-components'; + +import styles from './dummy-album-detail-route.module.css'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, Spoiler, Text } from '/@/renderer/components'; import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu'; import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items'; import { usePlayQueueAdd } from '/@/renderer/features/player'; @@ -27,16 +25,16 @@ import { useCurrentServer } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDurationString } from '/@/renderer/utils'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Spoiler } from '/@/shared/components/spoiler/spoiler'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; import { LibraryItem, SongDetailResponse } from '/@/shared/types/domain-types'; -const DetailContainer = styled.div` - display: flex; - flex-direction: column; - gap: 2rem; - padding: 1rem 2rem 5rem; - overflow: hidden; -`; - const DummyAlbumDetailRoute = () => { const cq = useContainerQuery(); const { t } = useTranslation(); @@ -137,19 +135,19 @@ const DummyAlbumDetailRoute = () => { loading={!background || colorId !== albumId} title={detailQuery?.data?.name || ''} > - <Stack spacing="sm"> - <Group spacing="sm"> + <Stack gap="sm"> + <Group gap="sm"> {metadataItems.map((item, index) => ( <Fragment key={`item-${item.id}-${index}`}> - {index > 0 && <Text $noSelect>•</Text>} - <Text $secondary={item.secondary}>{item.value}</Text> + {index > 0 && <Text isNoSelect>•</Text>} + <Text isMuted={item.secondary}>{item.value}</Text> </Fragment> ))} </Group> <Group + gap="md" mah="4rem" - spacing="md" - sx={{ + style={{ overflow: 'hidden', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, @@ -157,9 +155,9 @@ const DummyAlbumDetailRoute = () => { > {detailQuery?.data?.albumArtists.map((artist) => ( <Text - $link component={Link} fw={600} + isLink key={`artist-${artist.id}`} size="md" to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { @@ -174,55 +172,46 @@ const DummyAlbumDetailRoute = () => { </Stack> </LibraryHeader> </Stack> - <DetailContainer> - <Box component="section"> + <div className={styles.detailContainer}> + <section> <Group - position="apart" - spacing="sm" + gap="sm" + justify="space-between" > <Group> <PlayButton onClick={() => handlePlay()} /> - <Button - compact + <ActionIcon + icon="favorite" + iconProps={{ + fill: detailQuery?.data?.userFavorite ? 'primary' : undefined, + }} loading={ createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading } onClick={handleFavorite} variant="subtle" - > - {detailQuery?.data?.userFavorite ? ( - <RiHeartFill - color="red" - size={20} - /> - ) : ( - <RiHeartLine size={20} /> - )} - </Button> - <Button - compact + /> + <ActionIcon + icon="ellipsisHorizontal" onClick={(e) => { if (!detailQuery?.data) return; handleGeneralContextMenu(e, [detailQuery.data!]); }} variant="subtle" - > - <RiMoreFill size={20} /> - </Button> + /> </Group> </Group> - </Box> + </section> {showGenres && ( - <Box component="section"> - <Group spacing="sm"> + <section> + <Group gap="sm"> {detailQuery?.data?.genres?.map((genre) => ( <Button - compact component={Link} key={`genre-${genre.id}`} radius={0} - size="md" + size="compact-md" to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, { genreId: genre.id, })} @@ -232,25 +221,26 @@ const DummyAlbumDetailRoute = () => { </Button> ))} </Group> - </Box> + </section> )} {comment && ( - <Box component="section"> + <section> <Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler> - </Box> + </section> )} - <Box component="section"> + <section> <Center> <Group mr={5}> - <RiErrorWarningLine - color="var(--danger-color)" + <Icon + fill="error" + icon="error" size={30} /> </Group> <h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2> </Center> - </Box> - </DetailContainer> + </section> + </div> </AnimatedPage> ); }; diff --git a/src/renderer/features/artists/components/album-artist-detail-content.module.css b/src/renderer/features/artists/components/album-artist-detail-content.module.css new file mode 100644 index 00000000..36a71b8f --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-detail-content.module.css @@ -0,0 +1,16 @@ +.content-container { + position: relative; + z-index: 0; +} + +.detail-container { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-lg); + padding: 1rem 2rem 5rem; + overflow: hidden; + + :global(.ag-theme-alpine-dark) { + --ag-header-background-color: rgb(0 0 0 / 0%) !important; + } +} diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index d5f1329c..1e0c5e9f 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -1,16 +1,12 @@ import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core'; -import { Box, Grid, Group, Stack } from '@mantine/core'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { FaLastfmSquare } from 'react-icons/fa'; -import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri'; -import { SiMusicbrainz } from 'react-icons/si'; import { generatePath, useParams } from 'react-router'; import { createSearchParams, Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Button, Spoiler, TextTitle } from '/@/renderer/components'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; +import styles from './album-artist-detail-content.module.css'; + +import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query'; @@ -32,6 +28,13 @@ import { AppRoute } from '/@/renderer/router/routes'; import { ArtistItem, useCurrentServer } from '/@/renderer/store'; import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { sanitize } from '/@/renderer/utils/sanitize'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Grid } from '/@/shared/components/grid/grid'; +import { Group } from '/@/shared/components/group/group'; +import { Spoiler } from '/@/shared/components/spoiler/spoiler'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; import { Album, AlbumArtist, @@ -43,23 +46,6 @@ import { } from '/@/shared/types/domain-types'; import { CardRow, Play, TableColumn } from '/@/shared/types/types'; -const ContentContainer = styled.div` - position: relative; - z-index: 0; -`; - -const DetailContainer = styled.div` - display: flex; - flex-direction: column; - gap: 2rem; - padding: 1rem 2rem 5rem; - overflow: hidden; - - .ag-theme-alpine-dark { - --ag-header-background-color: rgb(0 0 0 / 0%) !important; - } -`; - interface AlbumArtistDetailContentProps { background?: string; } @@ -216,21 +202,20 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten title: ( <Group align="flex-end"> <TextTitle + fw={700} order={2} - weight={700} > {t('page.albumArtistDetail.recentReleases', { postProcess: 'sentenceCase', })} </TextTitle> <Button - compact component={Link} + size="compact-md" to={artistDiscographyLink} - uppercase variant="subtle" > - {t('page.albumArtistDetail.viewDiscography')} + {String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()} </Button> </Group> ), @@ -247,8 +232,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten order: itemOrder.compilations, title: ( <TextTitle + fw={700} order={2} - weight={700} > {t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })} </TextTitle> @@ -262,8 +247,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten order: itemOrder.similarArtists, title: ( <TextTitle + fw={700} order={2} - weight={700} > {t('page.albumArtistDetail.relatedArtists', { postProcess: 'sentenceCase', @@ -369,77 +354,77 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten detailQuery?.isLoading || (server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading); - if (isLoading) return <ContentContainer ref={cq.ref} />; + if (isLoading) + return ( + <div + className={styles.contentContainer} + ref={cq.ref} + /> + ); return ( - <ContentContainer ref={cq.ref}> - <LibraryBackgroundOverlay $backgroundColor={background} /> - <DetailContainer> - <Group spacing="md"> + <div + className={styles.contentContainer} + ref={cq.ref} + > + <LibraryBackgroundOverlay backgroundColor={background} /> + <div className={styles.detailContainer}> + <Group gap="md"> <PlayButton disabled={albumCount === 0} onClick={() => handlePlay(playButtonBehavior)} /> - <Group spacing="xs"> - <Button - compact + <Group gap="xs"> + <ActionIcon + icon="favorite" + iconProps={{ + fill: detailQuery?.data?.userFavorite ? 'primary' : undefined, + }} loading={ createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading } onClick={handleFavorite} - variant="subtle" - > - {detailQuery?.data?.userFavorite ? ( - <RiHeartFill - color="red" - size={20} - /> - ) : ( - <RiHeartLine size={20} /> - )} - </Button> - <Button - compact + size="lg" + variant="transparent" + /> + <ActionIcon + icon="ellipsisHorizontal" onClick={(e) => { if (!detailQuery?.data) return; handleGeneralContextMenu(e, [detailQuery.data!]); }} - variant="subtle" - > - <RiMoreFill size={20} /> - </Button> + size="lg" + variant="transparent" + /> </Group> </Group> - <Group spacing="md"> + <Group gap="md"> <Button - compact component={Link} + size="compact-md" to={artistDiscographyLink} - uppercase variant="subtle" > - {t('page.albumArtistDetail.viewDiscography')} + {String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()} </Button> <Button - compact component={Link} + size="compact-md" to={artistSongsLink} - uppercase variant="subtle" > - {t('page.albumArtistDetail.viewAllTracks')} + {String(t('page.albumArtistDetail.viewAllTracks')).toUpperCase()} </Button> </Group> {showGenres ? ( - <Box component="section"> - <Group spacing="sm"> + <section> + <Group gap="sm"> {detailQuery?.data?.genres?.map((genre) => ( <Button - compact component={Link} key={`genre-${genre.id}`} radius="md" - size="md" + size="compact-md" to={generatePath(genrePath, { genreId: genre.id, })} @@ -449,70 +434,65 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten </Button> ))} </Group> - </Box> + </section> ) : null} {externalLinks && (lastFM || musicBrainz) ? ( - <Box component="section"> - <Group spacing="sm"> - {lastFM && ( - <Button - compact - component="a" - href={`https://www.last.fm/music/${encodeURIComponent( - detailQuery?.data?.name || '', - )}`} - radius="md" - rel="noopener noreferrer" - size="md" - target="_blank" - tooltip={{ - label: t('action.openIn.lastfm'), - }} - variant="subtle" - > - <FaLastfmSquare size={25} /> - </Button> - )} - {musicBrainz && mbzId ? ( - <Button - compact + <section> + <Group gap="sm"> + <ActionIcon + component="a" + href={`https://www.last.fm/music/${encodeURIComponent( + detailQuery?.data?.name || '', + )}`} + icon="brandLastfm" + iconProps={{ + fill: 'default', + size: 'xl', + }} + rel="noopener noreferrer" + target="_blank" + tooltip={{ + label: t('action.openIn.lastfm'), + }} + variant="subtle" + /> + {mbzId ? ( + <ActionIcon component="a" href={`https://musicbrainz.org/artist/${mbzId}`} - radius="md" + icon="brandMusicBrainz" + iconProps={{ + fill: 'default', + size: 'xl', + }} rel="noopener noreferrer" - size="md" target="_blank" tooltip={{ label: t('action.openIn.musicbrainz'), }} variant="subtle" - > - <SiMusicbrainz size={25} /> - </Button> + /> ) : null} </Group> - </Box> + </section> ) : null} - <Grid> + <Grid gutter="xl"> {biography ? ( <Grid.Col order={itemOrder.biography} span={12} > - <Box - component="section" - maw="1280px" - > + <section style={{ maxWidth: '1280px' }}> <TextTitle + fw={700} order={2} - weight={700} > {t('page.albumArtistDetail.about', { artist: detailQuery?.data?.name, })} </TextTitle> <Spoiler dangerouslySetInnerHTML={{ __html: biography }} /> - </Box> + </section> </Grid.Col> ) : null} {showTopSongs ? ( @@ -520,26 +500,26 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten order={itemOrder.topSongs} span={12} > - <Box component="section"> + <section> <Group - noWrap - position="apart" + justify="space-between" + wrap="nowrap" > <Group align="flex-end" - noWrap + wrap="nowrap" > <TextTitle + fw={700} order={2} - weight={700} > {t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })} </TextTitle> <Button - compact component={Link} + size="compact-md" to={generatePath( AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, { @@ -577,7 +557,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten suppressLoadingOverlay suppressRowDrag /> - </Box> + </section> </Grid.Col> ) : null} @@ -589,8 +569,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten order={carousel.order} span={12} > - <Box component="section"> - <Stack spacing="xl"> + <section> + <Stack gap="xl"> <MemoizedSwiperGridCarousel cardRows={ cardRows[carousel.itemType as keyof typeof cardRows] @@ -614,11 +594,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten uniqueId={carousel.uniqueId} /> </Stack> - </Box> + </section> </Grid.Col> ))} </Grid> - </DetailContainer> - </ContentContainer> + </div> + </div> ); }; diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index ba512247..f1574859 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -1,14 +1,16 @@ -import { Group, Rating, Stack } from '@mantine/core'; import { forwardRef, Fragment, Ref } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; -import { Text } from '/@/renderer/components'; import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query'; import { LibraryHeader, useSetRating } from '/@/renderer/features/shared'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; import { formatDurationString } from '/@/renderer/utils'; +import { Group } from '/@/shared/components/group/group'; +import { Rating } from '/@/shared/components/rating/rating'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; interface AlbumArtistDetailHeaderProps { @@ -87,13 +89,13 @@ export const AlbumArtistDetailHeader = forwardRef( .filter((i) => i.enabled) .map((item, index) => ( <Fragment key={`item-${item.id}-${index}`}> - {index > 0 && <Text $noSelect>•</Text>} - <Text $secondary={item.secondary}>{item.value}</Text> + {index > 0 && <Text isNoSelect>•</Text>} + <Text isMuted={item.secondary}>{item.value}</Text> </Fragment> ))} {showRating && ( <> - <Text $noSelect>•</Text> + <Text isNoSelect>•</Text> <Rating onChange={handleUpdateRating} readOnly={ diff --git a/src/renderer/features/artists/components/album-artist-detail-top-songs-list-header.tsx b/src/renderer/features/artists/components/album-artist-detail-top-songs-list-header.tsx index 0227728c..e0287391 100644 --- a/src/renderer/features/artists/components/album-artist-detail-top-songs-list-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-top-songs-list-header.tsx @@ -1,10 +1,11 @@ import { useTranslation } from 'react-i18next'; -import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri'; -import { Button, DropdownMenu, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { LibraryHeaderBar } from '/@/renderer/features/shared'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { Badge } from '/@/shared/components/badge/badge'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { QueueSong } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; @@ -35,47 +36,14 @@ export const AlbumArtistDetailTopSongsListHeader = ({ <LibraryHeaderBar> <LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} /> <LibraryHeaderBar.Title> - {t('page.albumArtistDetail.topSongsFrom', { title })} + {t('page.albumArtistDetail.topSongsFrom', { + postProcess: 'titleCase', + title, + })} </LibraryHeaderBar.Title> - <Paper - fw="600" - px="1rem" - py="0.3rem" - radius="sm" - > + <Badge> {itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount} - </Paper> - <DropdownMenu position="bottom-start"> - <DropdownMenu.Target> - <Button - compact - fw="600" - variant="subtle" - > - <RiMoreFill size={15} /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Item - icon={<RiPlayFill />} - onClick={() => handlePlay(Play.NOW)} - > - {t('player.play', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - icon={<RiAddBoxFill />} - onClick={() => handlePlay(Play.LAST)} - > - {t('player.addLast', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - icon={<RiAddCircleFill />} - onClick={() => handlePlay(Play.NEXT)} - > - {t('player.addNext', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - </DropdownMenu.Dropdown> - </DropdownMenu> + </Badge> </LibraryHeaderBar> </PageHeader> ); diff --git a/src/renderer/features/artists/components/album-artist-list-content.tsx b/src/renderer/features/artists/components/album-artist-list-content.tsx index c41a802d..a8275b8f 100644 --- a/src/renderer/features/artists/components/album-artist-list-content.tsx +++ b/src/renderer/features/artists/components/album-artist-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const AlbumArtistListGridView = lazy(() => @@ -37,7 +37,7 @@ export const AlbumArtistListContent = ({ }: AlbumArtistListContentProps) => { const { pageKey } = useListContext(); const { display } = useListStoreByKey({ key: pageKey }); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; return ( <Suspense fallback={<Spinner container />}> diff --git a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx index e9aec7a9..ff00468c 100644 --- a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx +++ b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx @@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components'; +import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx index e3d68b6c..c61e2191 100644 --- a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx @@ -1,28 +1,36 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { IDatasource } from '@ag-grid-community/core'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { AlbumArtistListFilter, + PersistedTableColumn, useCurrentServer, useListStoreActions, useListStoreByKey, } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { AlbumArtistListQuery, AlbumArtistListSort, @@ -30,7 +38,7 @@ import { ServerType, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -137,7 +145,7 @@ export const AlbumArtistListHeaderFilters = ({ useListStoreActions(); const cq = useContainerQuery(); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id }); const sortByLabel = @@ -308,15 +316,13 @@ export const AlbumArtistListHeaderFilters = ({ }, [filter.sortOrder, handleFilterChange, pageKey, setFilter]); const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - - setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + (displayType: ListDisplayType) => { + setDisplayType({ data: displayType, key: pageKey }); }, [pageKey, setDisplayType], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -330,7 +336,10 @@ export const AlbumArtistListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } else { @@ -344,10 +353,10 @@ export const AlbumArtistListHeaderFilters = ({ return tableRef.current?.api.sizeColumnsToFit(); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -357,28 +366,25 @@ export const AlbumArtistListHeaderFilters = ({ handleFilterChange(filter); }, [filter, handleFilterChange, queryClient, server?.id]); + const isFolderFilterApplied = useMemo(() => { + return filter.musicFolderId !== undefined; + }, [filter.musicFolderId]); + return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -395,22 +401,14 @@ export const AlbumArtistListHeaderFilters = ({ /> {server?.type === ServerType.JELLYFIN && ( <> - <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - {cq.isMd ? 'Folder' : <RiFolder2Line size={15} />} - </Button> + <FolderButton isActive={!!isFolderFilterApplied} /> </DropdownMenu.Target> <DropdownMenu.Dropdown> {musicFoldersQuery.data?.items.map((folder) => ( <DropdownMenu.Item - $isActive={filter.musicFolderId === folder.id} + isSelected={filter.musicFolderId === folder.id} key={`musicFolder-${folder.id}`} onClick={handleSetMusicFolder} value={folder.id} @@ -422,30 +420,14 @@ export const AlbumArtistListHeaderFilters = ({ </DropdownMenu> </> )} - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiMoreFill size={15} /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { @@ -455,136 +437,24 @@ export const AlbumArtistListHeaderFilters = ({ </DropdownMenu.Dropdown> </DropdownMenu> </Group> - <Group> - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - {/* <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE_PAGINATED} - value={ListDisplayType.TABLE_PAGINATED} - onClick={handleSetViewType} - > - Table (paginated) - </DropdownMenu.Item> */} - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.itemSize', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - {display === ListDisplayType.CARD || - display === ListDisplayType.POSTER ? ( - <Slider - defaultValue={grid?.itemSize} - max={300} - min={150} - onChange={debouncedHandleItemSize} - /> - ) : ( - <Slider - defaultValue={table.rowHeight} - max={100} - min={30} - onChange={debouncedHandleItemSize} - /> - )} - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.itemGap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - {!isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.tableColumns', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={ALBUMARTIST_TABLE_COLUMNS} - defaultValue={table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text> - {t('table.config.general.autoFitColumns', { - postProcess: 'sentenceCase', - })} - </Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + <Group + gap="sm" + wrap="nowrap" + > + <ListConfigMenu + autoFitColumns={table.autoFit} + disabledViewTypes={[ListDisplayType.LIST]} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={ALBUMARTIST_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/artists/components/album-artist-list-header.tsx b/src/renderer/features/artists/components/album-artist-list-header.tsx index b7802bbf..293ebf5d 100644 --- a/src/renderer/features/artists/components/album-artist-list-header.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header.tsx @@ -1,17 +1,20 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { ChangeEvent, MutableRefObject } from 'react'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; -import { PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { AlbumArtistListFilter, useCurrentServer } from '/@/renderer/store'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { AlbumArtistListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface AlbumArtistListHeaderProps { @@ -44,10 +47,10 @@ export const AlbumArtistListHeader = ({ return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <Flex justify="space-between" w="100%" @@ -66,7 +69,6 @@ export const AlbumArtistListHeader = ({ <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> diff --git a/src/renderer/features/artists/components/artist-list-content.tsx b/src/renderer/features/artists/components/artist-list-content.tsx index 51b82ac7..e1332f72 100644 --- a/src/renderer/features/artists/components/artist-list-content.tsx +++ b/src/renderer/features/artists/components/artist-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const ArtistListGridView = lazy(() => @@ -29,7 +29,7 @@ interface ArtistListContentProps { export const ArtistListContent = ({ gridRef, itemCount, tableRef }: ArtistListContentProps) => { const { pageKey } = useListContext(); const { display } = useListStoreByKey({ key: pageKey }); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; return ( <Suspense fallback={<Spinner container />}> diff --git a/src/renderer/features/artists/components/artist-list-grid-view.tsx b/src/renderer/features/artists/components/artist-list-grid-view.tsx index b65088d3..3a81c803 100644 --- a/src/renderer/features/artists/components/artist-list-grid-view.tsx +++ b/src/renderer/features/artists/components/artist-list-grid-view.tsx @@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components'; +import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/artists/components/artist-list-header-filters.tsx b/src/renderer/features/artists/components/artist-list-header-filters.tsx index 08960fa0..b1a4c116 100644 --- a/src/renderer/features/artists/components/artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/artist-list-header-filters.tsx @@ -1,37 +1,38 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { IDatasource } from '@ag-grid-community/core'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import { MouseEvent, MutableRefObject, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { - Button, - DropdownMenu, - MultiSelect, - Select, - Slider, - Switch, - Text, -} from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { useRoles } from '/@/renderer/features/artists/queries/roles-query'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { ArtistListFilter, + PersistedTableColumn, useCurrentServer, useListStoreActions, useListStoreByKey, } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Select } from '/@/shared/components/select/select'; import { ArtistListQuery, ArtistListSort, @@ -39,7 +40,7 @@ import { ServerType, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -150,7 +151,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF serverId: server?.id, }); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id }); const sortByLabel = @@ -321,15 +322,13 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF }, [filter.sortOrder, handleFilterChange, pageKey, setFilter]); const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - - setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + (displayType: ListDisplayType) => { + setDisplayType({ data: displayType, key: pageKey }); }, [pageKey, setDisplayType], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -343,7 +342,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } else { @@ -357,10 +359,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF return tableRef.current?.api.sizeColumnsToFit(); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -387,25 +389,18 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -425,19 +420,15 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" + <ActionIcon + icon="folder" variant="subtle" - > - {cq.isMd ? 'Folder' : <RiFolder2Line size={15} />} - </Button> + /> </DropdownMenu.Target> <DropdownMenu.Dropdown> {musicFoldersQuery.data?.items.map((folder) => ( <DropdownMenu.Item - $isActive={filter.musicFolderId === folder.id} + isSelected={filter.musicFolderId === folder.id} key={`musicFolder-${folder.id}`} onClick={handleSetMusicFolder} value={folder.id} @@ -451,7 +442,6 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF )} {roles.data?.length && ( <> - <Divider orientation="vertical" /> <Select data={roles.data} onChange={handleSetRole} @@ -459,30 +449,14 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF /> </> )} - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiMoreFill size={15} /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { @@ -492,129 +466,23 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF </DropdownMenu.Dropdown> </DropdownMenu> </Group> - <Group> - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Item> - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.itemSize', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - {display === ListDisplayType.CARD || - display === ListDisplayType.POSTER ? ( - <Slider - defaultValue={grid?.itemSize} - max={300} - min={150} - onChange={debouncedHandleItemSize} - /> - ) : ( - <Slider - defaultValue={table.rowHeight} - max={100} - min={30} - onChange={debouncedHandleItemSize} - /> - )} - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.itemGap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - {!isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.tableColumns', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={ALBUMARTIST_TABLE_COLUMNS} - defaultValue={table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text> - {t('table.config.general.autoFitColumns', { - postProcess: 'sentenceCase', - })} - </Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + <Group + gap="xs" + wrap="nowrap" + > + <ListConfigMenu + autoFitColumns={table.autoFit} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={ALBUMARTIST_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/artists/components/artist-list-header.tsx b/src/renderer/features/artists/components/artist-list-header.tsx index 1f4abd88..51c73174 100644 --- a/src/renderer/features/artists/components/artist-list-header.tsx +++ b/src/renderer/features/artists/components/artist-list-header.tsx @@ -1,17 +1,20 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { ChangeEvent, MutableRefObject } from 'react'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; -import { PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { ArtistListFilter, useCurrentServer } from '/@/renderer/store'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { ArtistListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface ArtistListHeaderProps { @@ -40,10 +43,10 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <Flex justify="space-between" w="100%" @@ -62,7 +65,6 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx index 31389e9f..92d82d41 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { useParams } from 'react-router'; -import { NativeScrollArea } from '/@/renderer/components'; +import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content'; import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header'; import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query'; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index ff0b7f5b..9cd18082 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -1,5 +1,4 @@ import { RowNode } from '@ag-grid-community/core'; -import { Divider, Group, Portal, Stack } from '@mantine/core'; import { useClickOutside, useMergedRef, @@ -8,42 +7,14 @@ import { useViewportSize, } from '@mantine/hooks'; import { closeAllModals, openContextModal, openModal } from '@mantine/modals'; -import { AnimatePresence } from 'framer-motion'; import isElectron from 'is-electron'; +import { AnimatePresence } from 'motion/react'; import { createContext, Fragment, ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiArrowDownLine, - RiArrowGoForwardLine, - RiArrowRightSFill, - RiArrowUpLine, - RiCloseCircleLine, - RiDeleteBinFill, - RiDislikeFill, - RiDownload2Line, - RiHeartFill, - RiInformationFill, - RiPlayFill, - RiPlayListAddFill, - RiRadio2Fill, - RiShareForwardFill, - RiShuffleFill, - RiStarFill, -} from 'react-icons/ri'; import { api } from '/@/renderer/api'; import { controller } from '/@/renderer/api/controller'; -import { - ConfirmModal, - ContextMenu, - ContextMenuButton, - HoverCard, - Rating, - Text, - toast, -} from '/@/renderer/components'; +import { ContextMenu, ContextMenuButton } from '/@/renderer/components/context-menu/context-menu'; import { ContextMenuItemType, OpenContextMenuProps, @@ -66,6 +37,16 @@ import { import { usePlaybackType } from '/@/renderer/store/settings.store'; import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; import { hasFeature } from '/@/shared/api/utils'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { HoverCard } from '/@/shared/components/hover-card/hover-card'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { Portal } from '/@/shared/components/portal/portal'; +import { Rating } from '/@/shared/components/rating/rating'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; import { AnyLibraryItem, AnyLibraryItems, @@ -113,6 +94,7 @@ function RatingIcon({ rating }: { rating: number }) { readOnly style={{ pointerEvents: 'none', + size: 'var(--theme-font-size-md)', }} value={rating} /> @@ -292,7 +274,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { {ctx.data.map((item) => ( <li key={item.id}> <Group> - —<Text $secondary>{item.name}</Text> + —<Text isMuted>{item.name}</Text> </Group> </li> ))} @@ -750,13 +732,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { addToFavorites: { id: 'addToFavorites', label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }), - leftIcon: <RiHeartFill size="1.1rem" />, + leftIcon: <Icon icon="favorite" />, onClick: handleAddToFavorites, }, addToPlaylist: { id: 'addToPlaylist', label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), - leftIcon: <RiPlayListAddFill size="1.1rem" />, + leftIcon: <Icon icon="playlistAdd" />, onClick: handleAddToPlaylist, }, createPlaylist: { @@ -767,86 +749,86 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { deletePlaylist: { id: 'deletePlaylist', label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }), - leftIcon: <RiDeleteBinFill size="1.1rem" />, + leftIcon: <Icon icon="playlistDelete" />, onClick: openDeletePlaylistModal, }, deselectAll: { id: 'deselectAll', label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }), - leftIcon: <RiCloseCircleLine size="1.1rem" />, + leftIcon: <Icon icon="remove" />, onClick: handleDeselectAll, }, download: { disabled: ctx.data?.length !== 1, id: 'download', label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }), - leftIcon: <RiDownload2Line size="1.1rem" />, + leftIcon: <Icon icon="download" />, onClick: handleDownload, }, moveToBottomOfQueue: { id: 'moveToBottomOfQueue', label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }), - leftIcon: <RiArrowDownLine size="1.1rem" />, + leftIcon: <Icon icon="arrowDownToLine" />, onClick: handleMoveToBottom, }, moveToNextOfQueue: { id: 'moveToNext', label: t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' }), - leftIcon: <RiArrowGoForwardLine size="1.1rem" />, + leftIcon: <Icon icon="mediaPlayNext" />, onClick: handleMoveToNext, }, moveToTopOfQueue: { id: 'moveToTopOfQueue', label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }), - leftIcon: <RiArrowUpLine size="1.1rem" />, + leftIcon: <Icon icon="arrowUpToLine" />, onClick: handleMoveToTop, }, play: { id: 'play', label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }), - leftIcon: <RiPlayFill size="1.1rem" />, + leftIcon: <Icon icon="mediaPlay" />, onClick: () => handlePlay(Play.NOW), }, playLast: { id: 'playLast', label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }), - leftIcon: <RiAddBoxFill size="1.1rem" />, + leftIcon: <Icon icon="mediaPlayLast" />, onClick: () => handlePlay(Play.LAST), }, playNext: { id: 'playNext', label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }), - leftIcon: <RiAddCircleFill size="1.1rem" />, + leftIcon: <Icon icon="mediaPlayNext" />, onClick: () => handlePlay(Play.NEXT), }, playShuffled: { id: 'playShuffled', label: t('page.contextMenu.playShuffled', { postProcess: 'sentenceCase' }), - leftIcon: <RiShuffleFill size="1.1rem" />, + leftIcon: <Icon icon="mediaShuffle" />, onClick: () => handlePlay(Play.SHUFFLE), }, playSimilarSongs: { id: 'playSimilarSongs', label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }), - leftIcon: <RiRadio2Fill size="1.1rem" />, + leftIcon: <Icon icon="radio" />, onClick: handleSimilar, }, removeFromFavorites: { id: 'removeFromFavorites', label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }), - leftIcon: <RiDislikeFill size="1.1rem" />, + leftIcon: <Icon icon="unfavorite" />, onClick: handleRemoveFromFavorites, }, removeFromPlaylist: { id: 'removeFromPlaylist', label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }), - leftIcon: <RiDeleteBinFill size="1.1rem" />, + leftIcon: <Icon icon="playlistDelete" />, onClick: handleRemoveFromPlaylist, }, removeFromQueue: { id: 'removeSongs', label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }), - leftIcon: <RiDeleteBinFill size="1.1rem" />, + leftIcon: <Icon icon="delete" />, onClick: handleRemoveSelected, }, setRating: { @@ -884,22 +866,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ], id: 'setRating', label: t('action.setRating', { postProcess: 'sentenceCase' }), - leftIcon: <RiStarFill size="1.1rem" />, + leftIcon: <Icon icon="star" />, onClick: () => {}, - rightIcon: <RiArrowRightSFill size="1.2rem" />, + rightIcon: <Icon icon="arrowRightS" />, }, shareItem: { disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG), id: 'shareItem', label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }), - leftIcon: <RiShareForwardFill size="1.1rem" />, + leftIcon: <Icon icon="share" />, onClick: handleShareItem, }, showDetails: { disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType, id: 'showDetails', label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), - leftIcon: <RiInformationFill />, + leftIcon: <Icon icon="info" />, onClick: handleOpenItemDetails, }, }; @@ -946,10 +928,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { xPos={ctx.xPos} yPos={ctx.yPos} > - <Stack spacing={0}> + <Stack gap={0}> <Stack + gap={0} onClick={closeContextMenu} - spacing={0} > {ctx.menuItems?.map((item) => { return ( @@ -957,7 +939,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { <Fragment key={`context-menu-${item.id}`}> {item.children ? ( <HoverCard - offset={5} + offset={0} position="right" > <HoverCard.Target> @@ -982,7 +964,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { </ContextMenuButton> </HoverCard.Target> <HoverCard.Dropdown> - <Stack spacing={0}> + <Stack gap={0}> {contextMenuItems[ item.id ].children?.map((child) => ( @@ -1020,9 +1002,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { {item.divider && ( <Divider - color="rgb(62, 62, 62)" key={`context-menu-divider-${item.id}`} - size="sm" /> )} </Fragment> @@ -1030,10 +1010,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ); })} </Stack> - <Divider - color="rgb(62, 62, 62)" - size="sm" - /> <ContextMenuButton disabled> {t('page.contextMenu.numberSelected', { count: ctx.data?.length || 0, diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index a891bc9d..28529c17 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -1,7 +1,7 @@ import { GridOptions, RowNode } from '@ag-grid-community/core'; -import { createUseExternalEvents } from '@mantine/utils'; import { LibraryItem } from '/@/shared/types/domain-types'; +import { createUseExternalEvents } from '/@/shared/utils/create-use-external-events'; export type ContextMenuEvents = { closeContextMenu: () => void; diff --git a/src/renderer/features/genres/components/genre-list-content.tsx b/src/renderer/features/genres/components/genre-list-content.tsx index f6f14387..85b3273e 100644 --- a/src/renderer/features/genres/components/genre-list-content.tsx +++ b/src/renderer/features/genres/components/genre-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const GenreListGridView = lazy(() => @@ -32,7 +32,7 @@ export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont return ( <Suspense fallback={<Spinner container />}> - {display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? ( + {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( <GenreListGridView gridRef={gridRef} itemCount={itemCount} diff --git a/src/renderer/features/genres/components/genre-list-grid-view.tsx b/src/renderer/features/genres/components/genre-list-grid-view.tsx index 855fdec1..c95a850f 100644 --- a/src/renderer/features/genres/components/genre-list-grid-view.tsx +++ b/src/renderer/features/genres/components/genre-list-grid-view.tsx @@ -6,7 +6,7 @@ import { ListOnScrollProps } from 'react-window'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { ALBUM_CARD_ROWS } from '/@/renderer/components'; +import { ALBUM_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index aacc9d52..a7488099 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -1,36 +1,38 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; +import debounce from 'lodash/debounce'; +import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAlbumLine, - RiFolder2Fill, - RiMoreFill, - RiMusic2Line, - RiRefreshLine, - RiSettings3Fill, -} from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { GenreListFilter, GenreTarget, + PersistedTableColumn, useCurrentServer, useGeneralSettings, useListStoreActions, useListStoreByKey, useSettingsStoreActions, } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { GenreListQuery, GenreListSort, @@ -38,7 +40,7 @@ import { ServerType, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -99,7 +101,7 @@ export const GenreListHeaderFilters = ({ ?.name) || 'Unknown'; - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const onFilterChange = useCallback( (filter: GenreListFilter) => { @@ -191,19 +193,20 @@ export const GenreListHeaderFilters = ({ } }; + const debouncedHandleItemSize = debounce(handleItemSize, 20); + const handleItemGap = (e: number) => { setGrid({ data: { itemGap: e }, key: pageKey }); }; const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + (displayType: ListDisplayType) => { + setDisplayType({ data: displayType, key: pageKey }); }, [pageKey, setDisplayType], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -215,7 +218,10 @@ export const GenreListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } else { @@ -229,10 +235,10 @@ export const GenreListHeaderFilters = ({ return tableRef.current?.api.sizeColumnsToFit(); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -249,25 +255,18 @@ export const GenreListHeaderFilters = ({ return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw={600} - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -287,26 +286,15 @@ export const GenreListHeaderFilters = ({ <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw={600} - size="md" - sx={{ - svg: { - fill: isFolderFilterApplied - ? 'var(--primary-color) !important' - : undefined, - }, - }} - variant="subtle" - > - <RiFolder2Fill size="1.3rem" /> - </Button> + <FolderButton + isActive={isFolderFilterApplied} + onClick={handleSetMusicFolder} + /> </DropdownMenu.Target> <DropdownMenu.Dropdown> {musicFoldersQuery.data?.items.map((folder) => ( <DropdownMenu.Item - $isActive={filter.musicFolderId === folder.id} + isSelected={filter.musicFolderId === folder.id} key={`musicFolder-${folder.id}`} onClick={handleSetMusicFolder} value={folder.id} @@ -318,168 +306,58 @@ export const GenreListHeaderFilters = ({ </DropdownMenu> </> )} - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiMoreFill size={15} /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { postProcess: 'titleCase' })} </DropdownMenu.Item> </DropdownMenu.Dropdown> - <Divider orientation="vertical" /> <Button - compact onClick={handleGenreToggle} - size="md" + size="compact-md" tooltip={{ label: t( genreTarget === GenreTarget.ALBUM - ? 'page.genreList.showAlbums' - : 'page.genreList.showTracks', + ? 'page.genreList.showTracks' + : 'page.genreList.showAlbums', { postProcess: 'sentenceCase' }, ), }} variant="subtle" > - {genreTarget === GenreTarget.ALBUM ? <RiAlbumLine /> : <RiMusic2Line />} + {genreTarget === GenreTarget.ALBUM ? ( + <Icon icon="itemAlbum" /> + ) : ( + <Icon icon="itemSong" /> + )} </Button> </DropdownMenu> </Group> <Group - noWrap - spacing="sm" + gap="sm" + wrap="nowrap" > - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - tooltip={{ - label: t('common.configure', { postProcess: 'titleCase' }), - }} - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.size', { postProcess: 'titleCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight} - max={isGrid ? 300 : 100} - min={isGrid ? 100 : 25} - onChangeEnd={handleItemSize} - /> - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.itemGap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - {(display === ListDisplayType.TABLE || - display === ListDisplayType.TABLE_PAGINATED) && ( - <> - <DropdownMenu.Label> - {t('table.config.general.tableColumns', { - postProcess: 'titleCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={GENRE_TABLE_COLUMNS} - defaultValue={table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text> - {t('table.config.general.autoFitColumns', { - postProcess: 'titleCase', - })} - </Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + <ListConfigMenu + autoFitColumns={table.autoFit} + disabledViewTypes={[ListDisplayType.LIST]} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={GENRE_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/genres/components/genre-list-header.tsx b/src/renderer/features/genres/components/genre-list-header.tsx index 35aff41a..dc3490ab 100644 --- a/src/renderer/features/genres/components/genre-list-header.tsx +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -1,17 +1,20 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; -import { PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { GenreListFilter, useCurrentServer } from '/@/renderer/store'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { GenreListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface GenreListHeaderProps { @@ -38,10 +41,10 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <Flex justify="space-between" w="100%" @@ -60,7 +63,6 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index 53ef25ae..cd8ff12d 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -1,12 +1,11 @@ -import { ActionIcon, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiRefreshLine } from 'react-icons/ri'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { FeatureCarousel, NativeScrollArea, Spinner, TextTitle } from '/@/renderer/components'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; +import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel'; +import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; +import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { useAlbumList } from '/@/renderer/features/albums'; import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query'; import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared'; @@ -18,6 +17,12 @@ import { useGeneralSettings, useWindowSettings, } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; import { AlbumListSort, LibraryItem, @@ -233,7 +238,6 @@ const HomeRoute = () => { <AnimatedPage> <NativeScrollArea pageHeaderProps={{ - backgroundColor: 'var(--titlebar-bg)', children: ( <LibraryHeaderBar> <LibraryHeaderBar.Title> @@ -246,10 +250,10 @@ const HomeRoute = () => { ref={scrollAreaRef} > <Stack + gap="lg" mb="5rem" pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'} px="2rem" - spacing="lg" > {homeFeature && <FeatureCarousel data={featureItemsWithImage} />} {sortedCarousel.map((carousel) => ( @@ -304,17 +308,12 @@ const HomeRoute = () => { title={{ label: ( <Group> - <TextTitle - order={2} - weight={700} - > - {carousel.title} - </TextTitle> - + <TextTitle order={3}>{carousel.title}</TextTitle> <ActionIcon onClick={() => invalidateCarouselQuery(carousel)} + variant="transparent" > - <RiRefreshLine /> + <Icon icon="refresh" /> </ActionIcon> </Group> ), diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index f54ccf7f..144ee3d2 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -1,12 +1,8 @@ -import { Group, Table } from '@mantine/core'; import { ReactNode } from 'react'; import { TFunction, useTranslation } from 'react-i18next'; -import { RiCheckFill, RiCloseFill } from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { Spoiler, Text } from '/@/renderer/components'; -import { Separator } from '/@/renderer/components/separator'; import { SongPath } from '/@/renderer/features/item-details/components/song-path'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; @@ -15,6 +11,11 @@ import { formatDateRelative, formatRating } from '/@/renderer/utils/format'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { sanitize } from '/@/renderer/utils/sanitize'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Separator } from '/@/shared/components/separator/separator'; +import { Spoiler } from '/@/shared/components/spoiler/spoiler'; +import { Table } from '/@/shared/components/table/table'; +import { Text } from '/@/shared/components/text/text'; import { Album, AlbumArtist, @@ -49,10 +50,12 @@ const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDe if (!value) return null; return ( - <tr key={rule.label}> - <td>{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}</td> - <td>{value}</td> - </tr> + <Table.Tr key={rule.label}> + <Table.Th> + {t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })} + </Table.Th> + <Table.Td>{value}</Table.Td> + </Table.Tr> ); }; @@ -62,8 +65,9 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) => {index > 0 && <Separator />} {artist.id ? ( <Text - $link component={Link} + fw={500} + isLink overflow="visible" size="md" to={ @@ -73,7 +77,6 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) => }) : '' } - weight={500} > {artist.name || '—'} </Text> @@ -102,12 +105,12 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => { <span key={genre.id}> {index > 0 && <Separator />} <Text - $link component={Link} + fw={500} + isLink overflow="visible" size="md" to={genre.id ? generatePath(genreRoute, { genreId: genre.id }) : ''} - weight={500} > {genre.name || '—'} </Text> @@ -116,7 +119,17 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => { }; const BoolField = (key: boolean) => - key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />; + key ? ( + <Icon + color="success" + icon="check" + /> + ) : ( + <Icon + color="error" + icon="x" + /> + ); const AlbumPropertyMapping: ItemDetailRow<Album>[] = [ { key: 'name', label: 'common.title' }, @@ -154,13 +167,13 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [ postprocess: [], render: (album) => album.mbzId ? ( - <a - href={`https://musicbrainz.org/release/${album.mbzId}`} + <Link rel="noopener noreferrer" target="_blank" + to={`https://musicbrainz.org/release/${album.mbzId}`} > {album.mbzId} - </a> + </Link> ) : null, }, { key: 'id', label: 'filter.id' }, @@ -189,13 +202,13 @@ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [ postprocess: [], render: (artist) => artist.mbz ? ( - <a - href={`https://musicbrainz.org/artist/${artist.mbz}`} + <Link rel="noopener noreferrer" target="_blank" + to={`https://musicbrainz.org/artist/${artist.mbz}`} > {artist.mbz} - </a> + </Link> ) : null, }, { @@ -246,8 +259,9 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [ song.albumId && song.album && ( <Text - $link component={Link} + fw={500} + isLink overflow="visible" size="md" to={ @@ -257,7 +271,6 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [ }) : '' } - weight={500} > {song.album} </Text> @@ -314,26 +327,24 @@ const handleTags = (item: Album | Song, t: TFunction) => { if (item.tags) { const tags = Object.entries(item.tags).map(([tag, fields]) => { return ( - <tr key={tag}> - <td> + <Table.Tr key={tag}> + <Table.Th> {tag.slice(0, 1).toLocaleUpperCase()} {tag.slice(1)} - </td> - <td>{fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)}</td> - </tr> + </Table.Th> + <Table.Td> + {fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)} + </Table.Td> + </Table.Tr> ); }); if (tags.length) { return [ - <tr key="tags"> - <td> - <h3>{t('common.tags', { postProcess: 'sentenceCase' })}</h3> - </td> - <td> - <h3>{tags.length}</h3> - </td> - </tr>, + <Table.Tr key="tags"> + <Table.Th>{t('common.tags', { postProcess: 'sentenceCase' })}</Table.Th> + <Table.Td>{tags.length}</Table.Td> + </Table.Tr>, ].concat(tags); } } @@ -345,30 +356,26 @@ const handleParticipants = (item: Album | Song, t: TFunction) => { if (item.participants) { const participants = Object.entries(item.participants).map(([role, participants]) => { return ( - <tr key={role}> - <td> + <Table.Tr key={role}> + <Table.Th> {role.slice(0, 1).toLocaleUpperCase()} {role.slice(1)} - </td> - <td>{formatArtists(participants)}</td> - </tr> + </Table.Th> + <Table.Td>{formatArtists(participants)}</Table.Td> + </Table.Tr> ); }); if (participants.length) { return [ - <tr key="participants"> - <td> - <h3> - {t('common.additionalParticipants', { - postProcess: 'sentenceCase', - })} - </h3> - </td> - <td> - <h3>{participants.length}</h3> - </td> - </tr>, + <Table.Tr key="participants"> + <Table.Th> + {t('common.additionalParticipants', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td>{participants.length}</Table.Td> + </Table.Tr>, ].concat(participants); } } @@ -402,15 +409,13 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { } return ( - <Group> - <Table - highlightOnHover - horizontalSpacing="sm" - sx={{ userSelect: 'text', whiteSpace: 'pre-line' }} - verticalSpacing="sm" - > - <tbody>{body}</tbody> - </Table> - </Group> + <Table + highlightOnHover + variant="vertical" + withRowBorders={false} + withTableBorder + > + <Table.Tbody>{body}</Table.Tbody> + </Table> ); }; diff --git a/src/renderer/features/item-details/components/song-path.tsx b/src/renderer/features/item-details/components/song-path.tsx index 9c435a9e..08bd8eeb 100644 --- a/src/renderer/features/item-details/components/song-path.tsx +++ b/src/renderer/features/item-details/components/song-path.tsx @@ -1,10 +1,13 @@ -import { ActionIcon, CopyButton, Group } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { RiCheckFill, RiClipboardFill, RiExternalLinkFill } from 'react-icons/ri'; -import styled from 'styled-components'; -import { toast, Tooltip } from '/@/renderer/components'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { CopyButton } from '/@/shared/components/copy-button/copy-button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; const util = isElectron() ? window.api.utils : null; @@ -12,10 +15,6 @@ export type SongPathProps = { path: null | string; }; -const PathText = styled.div` - user-select: all; -`; - export const SongPath = ({ path }: SongPathProps) => { const { t } = useTranslation(); @@ -37,8 +36,11 @@ export const SongPath = ({ path }: SongPathProps) => { )} withinPortal > - <ActionIcon onClick={copy}> - {copied ? <RiCheckFill /> : <RiClipboardFill />} + <ActionIcon + onClick={copy} + variant="transparent" + > + {copied ? <Icon icon="check" /> : <Icon icon="clipboardCopy" />} </ActionIcon> </Tooltip> )} @@ -48,23 +50,23 @@ export const SongPath = ({ path }: SongPathProps) => { label={t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })} withinPortal > - <ActionIcon> - <RiExternalLinkFill - onClick={() => { - util.openItem(path).catch((error) => { - toast.error({ - message: (error as Error).message, - title: t('error.openError', { - postProcess: 'sentenceCase', - }), - }); + <ActionIcon + icon="externalLink" + onClick={() => { + util.openItem(path).catch((error) => { + toast.error({ + message: (error as Error).message, + title: t('error.openError', { + postProcess: 'sentenceCase', + }), }); - }} - /> - </ActionIcon> + }); + }} + variant="transparent" + /> </Tooltip> )} - <PathText>{path}</PathText> + <Text style={{ userSelect: 'all' }}>{path}</Text> </Group> ); }; diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.module.css b/src/renderer/features/lyrics/components/lyrics-search-form.module.css new file mode 100644 index 00000000..fe60fc8f --- /dev/null +++ b/src/renderer/features/lyrics/components/lyrics-search-form.module.css @@ -0,0 +1,13 @@ +.search-item { + all: unset; + box-sizing: border-box !important; + padding: 0.5rem; + cursor: pointer; + border-radius: 5px; + + &:hover, + &:focus-visible { + color: var(--theme-btn-default-fg-hover); + background: var(--theme-btn-default-bg-hover); + } +} diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index 75fd6b0b..8cb4db16 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -1,35 +1,27 @@ -import { Divider, Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; import { openModal } from '@mantine/modals'; import orderBy from 'lodash/orderBy'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; + +import styles from './lyrics-search-form.module.css'; import i18n from '/@/i18n/i18n'; -import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components'; import { useLyricSearch } from '/@/renderer/features/lyrics/queries/lyric-search-query'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; import { InternetProviderLyricSearchResponse, LyricSource, LyricsOverride, } from '/@/shared/types/domain-types'; -const SearchItem = styled.button` - all: unset; - box-sizing: border-box !important; - padding: 0.5rem; - cursor: pointer; - border-radius: 5px; - - &:hover, - &:focus-visible { - color: var(--btn-default-fg-hover); - background: var(--btn-default-bg-hover); - } -`; - interface SearchResultProps { data: InternetProviderLyricSearchResponse; onClick?: () => void; @@ -46,28 +38,31 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => { source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id; return ( - <SearchItem onClick={onClick}> + <button + className={styles.searchItem} + onClick={onClick} + > <Group - noWrap - position="apart" + justify="space-between" + wrap="nowrap" > <Stack + gap={0} maw="65%" - spacing={0} > <Text + fw={600} size="md" - weight={600} > {name} </Text> - <Text $secondary>{artist}</Text> + <Text isMuted>{artist}</Text> <Group - noWrap - spacing="sm" + gap="sm" + wrap="nowrap" > <Text - $secondary + isMuted size="sm" > {[source, cleanId].join(' — ')} @@ -76,7 +71,7 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => { </Stack> <Text>{percentageScore}%</Text> </Group> - </SearchItem> + </button> ); }; @@ -141,13 +136,12 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch <Spinner container /> ) : ( <ScrollArea - h={400} - offsetScrollbars - pr="1rem" - type="auto" - w="100%" + style={{ + height: '400px', + paddingRight: '1rem', + }} > - <Stack spacing="md"> + <Stack gap="md"> {searchResults.map((result) => ( <SearchResult data={result} diff --git a/src/renderer/features/lyrics/lyric-line.module.css b/src/renderer/features/lyrics/lyric-line.module.css new file mode 100644 index 00000000..0afe69ef --- /dev/null +++ b/src/renderer/features/lyrics/lyric-line.module.css @@ -0,0 +1,21 @@ +.lyric-line { + padding: 0 1rem; + font-weight: 600; + color: var(--theme-colors-foreground); + opacity: 0.5; + transition: + opacity 0.3s ease-in-out, + transform 0.3s ease-in-out; + + &.active { + opacity: 1; + } + + &.unsynchronized { + opacity: 1; + } + + &.synchronized { + cursor: pointer; + } +} diff --git a/src/renderer/features/lyrics/lyric-line.module.scss b/src/renderer/features/lyrics/lyric-line.module.scss deleted file mode 100644 index 1183d4de..00000000 --- a/src/renderer/features/lyrics/lyric-line.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -.lyric-line { - color: var(--main-fg); - font-weight: 400; - - font-size: 2.5vmax; - transform: scale(0.95); - opacity: 0.5; - - .active { - font-weight: 800 !important; - transform: scale(1) !important; - opacity: 1; - } - - .active.unsynchronized { - opacity: 0.8; - } - - transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; -} - -.lyric-line.active { - font-weight: 800; - transform: scale(1); - opacity: 1; -} diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index 8fa76ac5..06e3471f 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -1,8 +1,9 @@ -import { TitleProps } from '@mantine/core'; +import clsx from 'clsx'; import { ComponentPropsWithoutRef } from 'react'; -import styled from 'styled-components'; -import { TextTitle } from '/@/renderer/components/text-title'; +import styles from './lyric-line.module.css'; + +import { TextTitle } from '/@/shared/components/text-title/text-title'; interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { alignment: 'center' | 'left' | 'right'; @@ -10,39 +11,17 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { text: string; } -const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSize: number }>` - padding: 0 1rem; - font-size: ${(props) => props.$fontSize}px; - font-weight: 600; - color: var(--main-fg); - text-align: ${(props) => props.$alignment}; - opacity: 0.5; - - transition: - opacity 0.3s ease-in-out, - transform 0.3s ease-in-out; - - &.active { - opacity: 1; - } - - &.unsynchronized { - opacity: 1; - } - - &.synchronized { - cursor: pointer; - } -`; - -export const LyricLine = ({ alignment, fontSize, text, ...props }: LyricLineProps) => { +export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { return ( - <StyledText - $alignment={alignment} - $fontSize={fontSize} + <TextTitle + className={clsx(styles.lyricLine, className)} + style={{ + fontSize, + textAlign: alignment, + }} {...props} > {text} - </StyledText> + </TextTitle> ); }; diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index f4a3bab8..47b684b2 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -1,9 +1,6 @@ -import { Box, Center, Group, Select, SelectItem } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { RiAddFill, RiSubtractFill } from 'react-icons/ri'; -import { Button, NumberInput, Tooltip } from '/@/renderer/components'; import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form'; import { useCurrentSong, @@ -11,11 +8,18 @@ import { useSettingsStore, useSettingsStoreActions, } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { LyricsOverride } from '/@/shared/types/domain-types'; interface LyricsActionsProps { index: number; - languages: SelectItem[]; + languages: { label: string; value: string }[]; onRemoveLyric: () => void; onResetLyric: () => void; @@ -38,7 +42,7 @@ export const LyricsActions = ({ const { setSettings } = useSettingsStoreActions(); const { delayMs, sources } = useLyricsSettings(); - const handleLyricOffset = (e: number) => { + const handleLyricOffset = (e: number | string) => { setSettings({ lyrics: { ...useSettingsStore.getState().lyrics, @@ -51,7 +55,7 @@ export const LyricsActions = ({ const isDesktop = isElectron(); return ( - <Box style={{ position: 'relative', width: '100%' }}> + <div style={{ position: 'relative', width: '100%' }}> {languages.length > 1 && ( <Center> <Select @@ -64,7 +68,7 @@ export const LyricsActions = ({ </Center> )} - <Group position="center"> + <Group justify="center"> {isDesktop && sources.length ? ( <Button disabled={isActionsDisabled} @@ -81,13 +85,12 @@ export const LyricsActions = ({ {t('common.search', { postProcess: 'titleCase' })} </Button> ) : null} - <Button + <ActionIcon aria-label="Decrease lyric offset" + icon="minus" onClick={() => handleLyricOffset(delayMs - 50)} variant="subtle" - > - <RiSubtractFill /> - </Button> + /> <Tooltip label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })} openDelay={500} @@ -100,13 +103,12 @@ export const LyricsActions = ({ width={55} /> </Tooltip> - <Button + <ActionIcon aria-label="Increase lyric offset" + icon="plus" onClick={() => handleLyricOffset(delayMs + 50)} variant="subtle" - > - <RiAddFill /> - </Button> + /> {isDesktop && sources.length ? ( <Button disabled={isActionsDisabled} @@ -119,7 +121,7 @@ export const LyricsActions = ({ ) : null} </Group> - <Box style={{ position: 'absolute', right: 0, top: 0 }}> + <div style={{ position: 'absolute', right: 0, top: 0 }}> {isDesktop && sources.length ? ( <Button disabled={isActionsDisabled} @@ -130,9 +132,9 @@ export const LyricsActions = ({ {t('common.clear', { postProcess: 'sentenceCase' })} </Button> ) : null} - </Box> + </div> - <Box style={{ position: 'absolute', right: 0, top: -50 }}> + <div style={{ position: 'absolute', right: 0, top: -50 }}> {isDesktop && sources.length ? ( <Button disabled={isActionsDisabled} @@ -143,7 +145,7 @@ export const LyricsActions = ({ {t('common.translation', { postProcess: 'sentenceCase' })} </Button> ) : null} - </Box> - </Box> + </div> + </div> ); }; diff --git a/src/renderer/features/lyrics/lyrics.module.css b/src/renderer/features/lyrics/lyrics.module.css new file mode 100644 index 00000000..a2d069f2 --- /dev/null +++ b/src/renderer/features/lyrics/lyrics.module.css @@ -0,0 +1,49 @@ +.actions-container { + position: absolute; + bottom: 0; + left: 0; + z-index: 50; + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: center; + width: 100%; + opacity: 0; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 1 !important; + } + + &:focus-within { + opacity: 1 !important; + } +} + +.lyrics-container { + position: relative; + display: flex; + width: 100%; + height: 100%; + + &:hover { + .actions-container { + opacity: 0.6; + } + } +} + +.scroll-container { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + text-align: center; + mask-image: linear-gradient( + 180deg, + transparent 5%, + rgb(0 0 0 / 100%) 20%, + rgb(0 0 0 / 100%) 85%, + transparent 95% + ); +} diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 48a8ea39..9354d6ab 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -1,13 +1,11 @@ -import { Center, Group } from '@mantine/core'; -import { AnimatePresence, motion } from 'framer-motion'; +import { AnimatePresence, motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; -import { RiInformationFill } from 'react-icons/ri'; -import styled from 'styled-components'; + +import styles from './lyrics.module.css'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Spinner, TextTitle } from '/@/renderer/components'; import { ErrorFallback } from '/@/renderer/features/action-required'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { @@ -25,72 +23,13 @@ import { } from '/@/renderer/features/lyrics/unsynchronized-lyrics'; import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentSong, useLyricsSettings, usePlayerStore } from '/@/renderer/store'; +import { Center } from '/@/shared/components/center/center'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Text } from '/@/shared/components/text/text'; import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/shared/types/domain-types'; -const ActionsContainer = styled.div` - position: absolute; - bottom: 0; - left: 0; - z-index: 50; - display: flex; - gap: 0.5rem; - align-items: center; - justify-content: center; - width: 100%; - opacity: 0; - transition: opacity 0.2s ease-in-out; - - &:hover { - opacity: 1 !important; - } - - &:focus-within { - opacity: 1 !important; - } -`; - -const LyricsContainer = styled.div` - position: relative; - display: flex; - width: 100%; - height: 100%; - - &:hover { - ${ActionsContainer} { - opacity: 0.6; - } - } -`; - -const ScrollContainer = styled(motion.div)` - position: relative; - z-index: 1; - width: 100%; - height: 100%; - text-align: center; - - mask-image: linear-gradient( - 180deg, - transparent 5%, - rgb(0 0 0 / 100%) 20%, - rgb(0 0 0 / 100%) 85%, - transparent 95% - ); - - &.mantine-ScrollArea-root { - width: 100%; - height: 100%; - } - - & .mantine-ScrollArea-viewport { - height: 100% !important; - } - - & .mantine-ScrollArea-viewport > div { - height: 100%; - } -`; - export const Lyrics = () => { const currentSong = useCurrentSong(); const lyricsSettings = useLyricsSettings(); @@ -210,7 +149,7 @@ export const Lyrics = () => { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> - <LyricsContainer> + <div className={styles.lyricsContainer}> {isLoadingLyrics ? ( <Spinner container @@ -221,20 +160,18 @@ export const Lyrics = () => { {hasNoLyrics ? ( <Center w="100%"> <Group> - <RiInformationFill size="2rem" /> - <TextTitle - order={3} - weight={700} - > + <Icon icon="info" /> + <Text> {t('page.fullscreenPlayer.noLyrics', { postProcess: 'sentenceCase', })} - </TextTitle> + </Text> </Group> </Center> ) : ( - <ScrollContainer + <motion.div animate={{ opacity: 1 }} + className={styles.scrollContainer} initial={{ opacity: 0 }} transition={{ duration: 0.5 }} > @@ -249,11 +186,11 @@ export const Lyrics = () => { translatedLyrics={showTranslation ? translatedLyrics : null} /> )} - </ScrollContainer> + </motion.div> )} </AnimatePresence> )} - <ActionsContainer> + <div className={styles.actionsContainer}> <LyricsActions index={index} languages={languages} @@ -263,8 +200,8 @@ export const Lyrics = () => { onTranslateLyric={handleOnTranslateLyric} setIndex={setIndex} /> - </ActionsContainer> - </LyricsContainer> + </div> + </div> </ErrorBoundary> ); }; diff --git a/src/renderer/features/lyrics/synchronized-lyrics.css b/src/renderer/features/lyrics/synchronized-lyrics.css new file mode 100644 index 00000000..abc0282e --- /dev/null +++ b/src/renderer/features/lyrics/synchronized-lyrics.css @@ -0,0 +1,3 @@ +.active { + opacity: 1; +} diff --git a/src/renderer/features/lyrics/synchronized-lyrics.module.css b/src/renderer/features/lyrics/synchronized-lyrics.module.css new file mode 100644 index 00000000..1809096e --- /dev/null +++ b/src/renderer/features/lyrics/synchronized-lyrics.module.css @@ -0,0 +1,21 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 10vh 0 50vh; + overflow: scroll; + word-break: break-all; + mask-image: linear-gradient( + 180deg, + transparent 5%, + rgb(0 0 0 / 100%) 20%, + rgb(0 0 0 / 100%) 85%, + transparent 95% + ); + transform: translateY(-2rem); + + @media screen and (orientation: portrait) { + padding: 5vh 0; + } +} diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index dc16d8a8..3e9fa8dd 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -1,6 +1,9 @@ +import clsx from 'clsx'; import isElectron from 'is-electron'; import { useCallback, useEffect, useRef } from 'react'; -import styled from 'styled-components'; + +import styles from './synchronized-lyrics.module.css'; +import './synchronized-lyrics.css'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; @@ -22,38 +25,6 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const utils = isElectron() ? window.api.utils : null; const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; -const SynchronizedLyricsContainer = styled.div<{ $gap: number }>` - display: flex; - flex-direction: column; - gap: ${(props) => props.$gap || 5}px; - width: 100%; - height: 100%; - padding: 10vh 0 50vh; - overflow: scroll; - word-break: break-word; - - -webkit-mask-image: linear-gradient( - 180deg, - transparent 5%, - rgb(0 0 0 / 100%) 20%, - rgb(0 0 0 / 100%) 85%, - transparent 95% - ); - - mask-image: linear-gradient( - 180deg, - transparent 5%, - rgb(0 0 0 / 100%) 20%, - rgb(0 0 0 / 100%) 85%, - transparent 95% - ); - transform: translateY(-2rem); - - @media screen and (orientation: portrait) { - padding: 5vh 0; - } -`; - export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { lyrics: SynchronizedLyricsArray; translatedLyrics?: null | string; @@ -344,12 +315,12 @@ export const SynchronizedLyrics = ({ }; return ( - <SynchronizedLyricsContainer - $gap={settings.gap} - className="synchronized-lyrics overlay-scrollbar" + <div + className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')} id="sychronized-lyrics-scroll-container" onMouseEnter={showScrollbar} onMouseLeave={hideScrollbar} + style={{ gap: `${settings.gap}px` }} > {settings.showProvider && source && ( <LyricLine @@ -388,6 +359,6 @@ export const SynchronizedLyrics = ({ )} </div> ))} - </SynchronizedLyricsContainer> + </div> ); }; diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.module.css b/src/renderer/features/lyrics/unsynchronized-lyrics.module.css new file mode 100644 index 00000000..5684e7c9 --- /dev/null +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.module.css @@ -0,0 +1,20 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 10vh 0 6vh; + overflow: scroll; + mask-image: linear-gradient( + 180deg, + transparent 5%, + rgb(0 0 0 / 100%) 20%, + rgb(0 0 0 / 100%) 85%, + transparent 95% + ); + transform: translateY(-2rem); + + @media screen and (orientation: portrait) { + padding: 5vh 0; + } +} diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx index 57ed268b..2e08a5a2 100644 --- a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; -import styled from 'styled-components'; + +import styles from './unsynchronized-lyrics.module.css'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { useLyricsSettings } from '/@/renderer/store'; @@ -10,37 +11,6 @@ export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyr translatedLyrics?: null | string; } -const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>` - display: flex; - flex-direction: column; - gap: ${(props) => props.$gap || 5}px; - width: 100%; - height: 100%; - padding: 10vh 0 6vh; - overflow: scroll; - - -webkit-mask-image: linear-gradient( - 180deg, - transparent 5%, - rgb(0 0 0 / 100%) 20%, - rgb(0 0 0 / 100%) 85%, - transparent 95% - ); - - mask-image: linear-gradient( - 180deg, - transparent 5%, - rgb(0 0 0 / 100%) 20%, - rgb(0 0 0 / 100%) 85%, - transparent 95% - ); - transform: translateY(-2rem); - - @media screen and (orientation: portrait) { - padding: 5vh 0; - } -`; - export const UnsynchronizedLyrics = ({ artist, lyrics, @@ -59,9 +29,9 @@ export const UnsynchronizedLyrics = ({ }, [translatedLyrics]); return ( - <UnsynchronizedLyricsContainer - $gap={settings.gapUnsync} - className="unsynchronized-lyrics" + <div + className={styles.container} + style={{ gap: `${settings.gapUnsync}px` }} > {settings.showProvider && source && ( <LyricLine @@ -98,6 +68,6 @@ export const UnsynchronizedLyrics = ({ )} </div> ))} - </UnsynchronizedLyricsContainer> + </div> ); }; diff --git a/src/renderer/features/now-playing/components/drawer-play-queue.tsx b/src/renderer/features/now-playing/components/drawer-play-queue.tsx index dee80bc5..03c4cc5e 100644 --- a/src/renderer/features/now-playing/components/drawer-play-queue.tsx +++ b/src/renderer/features/now-playing/components/drawer-play-queue.tsx @@ -1,10 +1,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Box, Flex } from '@mantine/core'; import { useRef } from 'react'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue'; import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls'; +import { Flex } from '/@/shared/components/flex/flex'; import { Song } from '/@/shared/types/domain-types'; export const DrawerPlayQueue = () => { @@ -15,17 +15,19 @@ export const DrawerPlayQueue = () => { direction="column" h="100%" > - <Box - bg="var(--main-bg)" - sx={{ borderRadius: '10px' }} + <div + style={{ + backgroundColor: 'var(--theme-colors-background)', + borderRadius: '10px', + }} > <PlayQueueListControls tableRef={queueRef} type="sideQueue" /> - </Box> + </div> <Flex - bg="var(--main-bg)" + bg="var(--theme-colors-background)" h="100%" mb="0.6rem" > diff --git a/src/renderer/features/now-playing/components/now-playing-header.tsx b/src/renderer/features/now-playing/components/now-playing-header.tsx index bfa80520..b60ce29e 100644 --- a/src/renderer/features/now-playing/components/now-playing-header.tsx +++ b/src/renderer/features/now-playing/components/now-playing-header.tsx @@ -1,4 +1,4 @@ -import { PageHeader } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { LibraryHeaderBar } from '/@/renderer/features/shared'; export const NowPlayingHeader = () => { @@ -6,7 +6,7 @@ export const NowPlayingHeader = () => { // const theme = useTheme(); return ( - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <LibraryHeaderBar> <LibraryHeaderBar.Title>Queue</LibraryHeaderBar.Title> </LibraryHeaderBar> diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index dceebf9b..afb0d550 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -1,26 +1,18 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { MutableRefObject } from 'react'; -import { Group } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { - RiArrowDownLine, - RiArrowGoForwardLine, - RiArrowUpLine, - RiDeleteBinLine, - RiEraserLine, - RiListSettingsLine, - RiShuffleLine, -} from 'react-icons/ri'; -import { Button, Popover } from '/@/renderer/components'; import { TableConfigDropdown } from '/@/renderer/components/virtual-table'; import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { usePlayerControls, useQueueControls } from '/@/renderer/store'; import { usePlayerStore, useSetCurrentTime } from '/@/renderer/store/player.store'; import { usePlaybackType } from '/@/renderer/store/settings.store'; import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { Popover } from '/@/shared/components/popover/popover'; import { Song } from '/@/shared/types/domain-types'; import { PlaybackType, TableType } from '/@/shared/types/types'; @@ -129,69 +121,57 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr return ( <Group - position="apart" + justify="space-between" px="1rem" py="1rem" - sx={{ alignItems: 'center' }} + style={{ alignItems: 'center' }} w="100%" > - <Group spacing="sm"> - <Button - compact + <Group gap="sm"> + <ActionIcon + icon="mediaShuffle" + iconProps={{ size: 'lg' }} onClick={handleShuffleQueue} - size="md" tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }} - variant="default" - > - <RiShuffleLine size="1.1rem" /> - </Button> - <Button - compact + variant="subtle" + /> + <ActionIcon + icon="mediaPlayNext" + iconProps={{ size: 'lg' }} onClick={handleMoveToNext} - size="md" tooltip={{ label: t('action.moveToNext', { postProcess: 'sentenceCase' }) }} - variant="default" - > - <RiArrowGoForwardLine size="1.1rem" /> - </Button> - <Button - compact + variant="subtle" + /> + <ActionIcon + icon="arrowDownToLine" + iconProps={{ size: 'lg' }} onClick={handleMoveToBottom} - size="md" tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }} - variant="default" - > - <RiArrowDownLine size="1.1rem" /> - </Button> - <Button - compact + variant="subtle" + /> + <ActionIcon + icon="arrowUpToLine" + iconProps={{ size: 'lg' }} onClick={handleMoveToTop} - size="md" tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }} - variant="default" - > - <RiArrowUpLine size="1.1rem" /> - </Button> - <Button - compact + variant="subtle" + /> + <ActionIcon + icon="delete" + iconProps={{ size: 'lg' }} onClick={handleRemoveSelected} - size="md" tooltip={{ label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }), }} - variant="default" - > - <RiEraserLine size="1.1rem" /> - </Button> - <Button - compact + variant="subtle" + /> + <ActionIcon + icon="x" + iconProps={{ size: 'lg' }} onClick={handleClearQueue} - size="md" tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }} - variant="default" - > - <RiDeleteBinLine size="1.1rem" /> - </Button> + variant="subtle" + /> </Group> <Group> <Popover @@ -199,16 +179,14 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr transitionProps={{ transition: 'fade' }} > <Popover.Target> - <Button - compact - size="md" + <ActionIcon + icon="settings" + iconProps={{ size: 'lg' }} tooltip={{ label: t('common.configure', { postProcess: 'sentenceCase' }), }} variant="subtle" - > - <RiListSettingsLine size="1.1rem" /> - </Button> + /> </Popover.Target> <Popover.Dropdown> <TableConfigDropdown type={type} /> diff --git a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx index 7d741f8d..ca86c703 100644 --- a/src/renderer/features/now-playing/components/sidebar-play-queue.tsx +++ b/src/renderer/features/now-playing/components/sidebar-play-queue.tsx @@ -1,14 +1,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Stack } from '@mantine/core'; import { useRef } from 'react'; import { PlayQueueListControls } from './play-queue-list-controls'; -import { PageHeader, Paper } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualGridContainer } from '/@/renderer/components/virtual-grid'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue'; import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { Box } from '/@/shared/components/box/box'; +import { Stack } from '/@/shared/components/stack/stack'; import { Song } from '/@/shared/types/domain-types'; import { Platform } from '/@/shared/types/types'; @@ -21,10 +22,10 @@ export const SidebarPlayQueue = () => { <VirtualGridContainer> {isWeb && ( <Stack mr={isWeb ? '130px' : undefined}> - <PageHeader backgroundColor="var(--titlebar-bg)" /> + <PageHeader /> </Stack> )} - <Paper + <Box display={!isWeb ? 'flex' : undefined} h={!isWeb ? '65px' : undefined} > @@ -32,7 +33,7 @@ export const SidebarPlayQueue = () => { tableRef={queueRef} type="sideQueue" /> - </Paper> + </Box> <PlayQueue ref={queueRef} type="sideQueue" diff --git a/src/renderer/features/now-playing/routes/now-playing-route.tsx b/src/renderer/features/now-playing/routes/now-playing-route.tsx index cd6fea62..ad2bb098 100644 --- a/src/renderer/features/now-playing/routes/now-playing-route.tsx +++ b/src/renderer/features/now-playing/routes/now-playing-route.tsx @@ -3,7 +3,6 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { useRef } from 'react'; -import { Paper } from '/@/renderer/components'; import { VirtualGridContainer } from '/@/renderer/components/virtual-grid'; import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue'; @@ -17,12 +16,10 @@ const NowPlayingRoute = () => { <AnimatedPage> <VirtualGridContainer> <NowPlayingHeader /> - <Paper sx={{ borderTop: '1px solid var(--generic-border-color)' }}> - <PlayQueueListControls - tableRef={queueRef} - type="nowPlaying" - /> - </Paper> + <PlayQueueListControls + tableRef={queueRef} + type="nowPlaying" + /> <PlayQueue ref={queueRef} type="nowPlaying" diff --git a/src/renderer/features/player/components/center-controls.module.css b/src/renderer/features/player/components/center-controls.module.css new file mode 100644 index 00000000..7199d7d9 --- /dev/null +++ b/src/renderer/features/player/components/center-controls.module.css @@ -0,0 +1,47 @@ +.buttons-container { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.slider-container { + display: flex; + width: 95%; + height: 20px; +} + +.slider-value-wrapper { + display: flex; + flex: 1; + align-self: center; + justify-content: center; + max-width: 50px; + + @media (width < 768px) { + display: none; + } +} + +.slider-wrapper { + display: flex; + flex: 6; + align-items: center; + height: 100%; +} + +.controls-container { + display: flex; + align-items: center; + justify-content: center; + height: 35px; + + @media (width < 768px) { + .buttons-container { + gap: 0; + } + + .slider-value-wrapper { + display: none; + } + } +} diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index 2c66637d..fbfa257d 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -4,22 +4,9 @@ import formatDuration from 'format-duration'; import isElectron from 'is-electron'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { BsDice3 } from 'react-icons/bs'; -import { IoIosPause } from 'react-icons/io'; -import { - RiPlayFill, - RiRepeat2Line, - RiRepeatOneLine, - RiRewindFill, - RiShuffleFill, - RiSkipBackFill, - RiSkipForwardFill, - RiSpeedFill, - RiStopFill, -} from 'react-icons/ri'; -import styled from 'styled-components'; -import { Text } from '/@/renderer/components'; +import styles from './center-controls.module.css'; + import { PlayerButton } from '/@/renderer/features/player/components/player-button'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal'; @@ -39,60 +26,14 @@ import { usePlaybackType, useSettingsStore, } from '/@/renderer/store/settings.store'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Text } from '/@/shared/components/text/text'; import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; interface CenterControlsProps { playersRef: any; } -const ButtonsContainer = styled.div` - display: flex; - gap: 0.5rem; - align-items: center; -`; - -const SliderContainer = styled.div` - display: flex; - width: 95%; - height: 20px; -`; - -const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>` - display: flex; - flex: 1; - align-self: center; - justify-content: center; - max-width: 50px; - - @media (width <= 768px) { - display: none; - } -`; - -const SliderWrapper = styled.div` - display: flex; - flex: 6; - align-items: center; - height: 100%; -`; - -const ControlsContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - height: 35px; - - @media (width <= 768px) { - ${ButtonsContainer} { - gap: 0; - } - - ${SliderValueWrapper} { - display: none; - } - } -`; - export const CenterControls = ({ playersRef }: CenterControlsProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -171,10 +112,16 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { return ( <> - <ControlsContainer> - <ButtonsContainer> + <div className={styles.controlsContainer}> + <div className={styles.buttonsContainer}> <PlayerButton - icon={<RiStopFill size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaStop" + size={buttonSize} + /> + } onClick={handleStop} tooltip={{ label: t('player.stop', { postProcess: 'sentenceCase' }), @@ -182,8 +129,14 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { variant="tertiary" /> <PlayerButton - $isActive={shuffle !== PlayerShuffle.NONE} - icon={<RiShuffleFill size={buttonSize} />} + icon={ + <Icon + fill={shuffle === PlayerShuffle.NONE ? 'default' : 'primary'} + icon="mediaShuffle" + size={buttonSize} + /> + } + isActive={shuffle !== PlayerShuffle.NONE} onClick={handleToggleShuffle} tooltip={{ label: @@ -197,7 +150,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { variant="tertiary" /> <PlayerButton - icon={<RiSkipBackFill size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaPrevious" + size={buttonSize} + /> + } onClick={handlePrevTrack} tooltip={{ label: t('player.previous', { postProcess: 'sentenceCase' }), @@ -206,7 +165,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { /> {skip?.enabled && ( <PlayerButton - icon={<RiRewindFill size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaStepBackward" + size={buttonSize} + /> + } onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)} tooltip={{ label: t('player.skip', { @@ -221,9 +186,15 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { disabled={currentSong?.id === undefined} icon={ status === PlayerStatus.PAUSED ? ( - <RiPlayFill size={buttonSize} /> + <Icon + icon="mediaPlay" + size={buttonSize} + /> ) : ( - <IoIosPause size={buttonSize} /> + <Icon + icon="mediaPause" + size={buttonSize} + /> ) } onClick={handlePlayPause} @@ -237,7 +208,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { /> {skip?.enabled && ( <PlayerButton - icon={<RiSpeedFill size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaStepForward" + size={buttonSize} + /> + } onClick={() => handleSkipForward(skip?.skipForwardSeconds)} tooltip={{ label: t('player.skip', { @@ -249,7 +226,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { /> )} <PlayerButton - icon={<RiSkipForwardFill size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaNext" + size={buttonSize} + /> + } onClick={handleNextTrack} tooltip={{ label: t('player.next', { postProcess: 'sentenceCase' }), @@ -257,14 +240,22 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { variant="secondary" /> <PlayerButton - $isActive={repeat !== PlayerRepeat.NONE} icon={ repeat === PlayerRepeat.ONE ? ( - <RiRepeatOneLine size={buttonSize} /> + <Icon + fill="primary" + icon="mediaRepeatOne" + size={buttonSize} + /> ) : ( - <RiRepeat2Line size={buttonSize} /> + <Icon + fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'} + icon="mediaRepeat" + size={buttonSize} + /> ) } + isActive={repeat !== PlayerRepeat.NONE} onClick={handleToggleRepeat} tooltip={{ label: `${ @@ -288,7 +279,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { /> <PlayerButton - icon={<BsDice3 size={buttonSize} />} + icon={ + <Icon + fill="default" + icon="mediaRandom" + size={buttonSize} + /> + } onClick={() => openShuffleAllModal({ handlePlayQueueAdd, @@ -300,20 +297,20 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { }} variant="tertiary" /> - </ButtonsContainer> - </ControlsContainer> - <SliderContainer> - <SliderValueWrapper $position="left"> + </div> + </div> + <div className={styles.sliderContainer}> + <div className={styles.sliderValueWrapper}> <Text - $noSelect - $secondary + fw={600} + isMuted + isNoSelect size="xs" - weight={600} > {formattedTime} </Text> - </SliderValueWrapper> - <SliderWrapper> + </div> + <div className={styles.sliderWrapper}> <PlayerbarSlider label={(value) => formatDuration(value * 1000)} max={songDuration} @@ -335,18 +332,18 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => { value={!isSeeking ? currentTime : seekValue} w="100%" /> - </SliderWrapper> - <SliderValueWrapper $position="right"> + </div> + <div className={styles.sliderValueWrapper}> <Text - $noSelect - $secondary + fw={600} + isMuted + isNoSelect size="xs" - weight={600} > {duration} </Text> - </SliderValueWrapper> - </SliderContainer> + </div> + </div> </> ); }; diff --git a/src/renderer/features/player/components/full-screen-player-image.module.css b/src/renderer/features/player/components/full-screen-player-image.module.css new file mode 100644 index 00000000..96e588e1 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-player-image.module.css @@ -0,0 +1,47 @@ +.image { + position: absolute; + max-width: 100%; + height: 100%; + object-fit: var(--theme-image-fit); + object-position: 50% 100%; + border-radius: 5px; + filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%)); +} + +.image-container { + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; + max-width: 100%; + height: 65%; + aspect-ratio: 1/1; + margin-bottom: 1rem; +} + +.metadata-container { + display: flex; + justify-content: center; + padding: 1rem; + text-align: center; + border-radius: 5px; + + h1 { + font-size: 3.5vh; + } +} + +.player-container { + @media screen and (height < 640px) { + .full-screen-player-image-metadata { + display: none; + height: 100%; + margin-bottom: 0; + } + + .image-container { + height: 100%; + margin-bottom: 0; + } + } +} diff --git a/src/renderer/features/player/components/full-screen-player-image.tsx b/src/renderer/features/player/components/full-screen-player-image.tsx index 5cd685b5..a022b7de 100644 --- a/src/renderer/features/player/components/full-screen-player-image.tsx +++ b/src/renderer/features/player/components/full-screen-player-image.tsx @@ -1,68 +1,27 @@ -import { Center, Flex, Group, Stack } from '@mantine/core'; import { useSetState } from '@mantine/hooks'; -import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion'; +import clsx from 'clsx'; +import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react'; import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { RiAlbumFill } from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Badge, Text, TextTitle } from '/@/renderer/components'; +import styles from './full-screen-player-image.module.css'; + import { useFastAverageColor } from '/@/renderer/hooks'; import { AppRoute } from '/@/renderer/router/routes'; -import { useFullScreenPlayerStore, usePlayerData, usePlayerStore } from '/@/renderer/store'; +import { usePlayerData, usePlayerStore } from '/@/renderer/store'; import { useSettingsStore } from '/@/renderer/store/settings.store'; +import { Badge } from '/@/shared/components/badge/badge'; +import { Center } from '/@/shared/components/center/center'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Image } from '/@/shared/components/image/image'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; +import { Text } from '/@/shared/components/text/text'; import { PlayerData, QueueSong } from '/@/shared/types/domain-types'; -const Image = styled(motion.img)<any>` - position: absolute; - max-width: 100%; - height: 100%; - object-fit: ${({ $useAspectRatio }) => ($useAspectRatio ? 'contain' : 'cover')}; - object-position: 50% 100%; - filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%)); - border-radius: 5px; -`; - -const ImageContainer = styled(motion.div)` - position: relative; - display: flex; - align-items: flex-end; - justify-content: center; - max-width: 100%; - height: 65%; - aspect-ratio: 1/1; - margin-bottom: 1rem; -`; - -interface TransparentMetadataContainer { - opacity?: number; -} - -const MetadataContainer = styled(Stack)<TransparentMetadataContainer>` - padding: 1rem; - border-radius: 5px; - - h1 { - font-size: 3.5vh; - } -`; - -const PlayerContainer = styled(Flex)` - @media screen and (height <= 640px) { - .full-screen-player-image-metadata { - display: none; - height: 100%; - margin-bottom: 0; - } - - ${ImageContainer} { - height: 100%; - margin-bottom: 0; - } - } -`; - const imageVariants: Variants = { closed: { opacity: 0, @@ -93,22 +52,22 @@ const scaleImageUrl = (imageSize: number, url?: null | string) => { .replace(/&height=\d+/, `&height=${imageSize}`); }; -const ImageWithPlaceholder = ({ - useAspectRatio, - ...props -}: HTMLMotionProps<'img'> & { placeholder?: string; useAspectRatio: boolean }) => { +const MotionImage = motion.create(Image); + +const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'> & { placeholder?: string }) => { if (!props.src) { return ( <Center - sx={{ - background: 'var(--placeholder-bg)', - borderRadius: 'var(--card-default-radius)', + style={{ + background: 'var(--theme-colors-surface)', + borderRadius: 'var(--theme-card-default-radius)', height: '100%', width: '100%', }} > - <RiAlbumFill - color="var(--placeholder-fg)" + <Icon + color="muted" + icon="itemAlbum" size="25%" /> </Center> @@ -116,8 +75,8 @@ const ImageWithPlaceholder = ({ } return ( - <Image - $useAspectRatio={useAspectRatio} + <MotionImage + className={styles.image} {...props} /> ); @@ -130,7 +89,6 @@ export const FullScreenPlayerImage = () => { const albumArtRes = useSettingsStore((store) => store.general.albumArtRes); const { queue } = usePlayerData(); - const { useImageAspectRatio } = useFullScreenPlayerStore(); const currentSong = queue.current; const { background } = useFastAverageColor({ algorithm: 'dominant', @@ -195,14 +153,17 @@ export const FullScreenPlayerImage = () => { }, [imageState, mainImageDimensions.idealSize, queue, setImageState]); return ( - <PlayerContainer + <Flex align="center" - className="full-screen-player-image-container" + className={clsx(styles.playerContainer, 'full-screen-player-image-container')} direction="column" justify="flex-start" p="1rem" > - <ImageContainer ref={mainImageRef}> + <div + className={styles.imageContainer} + ref={mainImageRef} + > <AnimatePresence initial={false} mode="sync" @@ -216,9 +177,8 @@ export const FullScreenPlayerImage = () => { exit="closed" initial="closed" key={imageKey} - placeholder="var(--placeholder-bg)" + placeholder="var(--theme-colors-foreground-muted)" src={imageState.topImage || ''} - useAspectRatio={useImageAspectRatio} variants={imageVariants} /> )} @@ -232,62 +192,55 @@ export const FullScreenPlayerImage = () => { exit="closed" initial="closed" key={imageKey} - placeholder="var(--placeholder-bg)" + placeholder="var(--theme-colors-foreground-muted)" src={imageState.bottomImage || ''} - useAspectRatio={useImageAspectRatio} variants={imageVariants} /> )} </AnimatePresence> - </ImageContainer> - <MetadataContainer - className="full-screen-player-image-metadata" + </div> + <Stack + className={styles.metadataContainer} + gap="xs" maw="100%" - spacing="xs" > <TextTitle - align="center" + fw={900} order={1} overflow="hidden" - style={{ - textShadow: 'var(--fullscreen-player-text-shadow)', - }} w="100%" - weight={900} > {currentSong?.name} </TextTitle> <TextTitle - $link - align="center" component={Link} + fw={600} + isLink order={3} overflow="hidden" style={{ - textShadow: 'var(--fullscreen-player-text-shadow)', + textShadow: 'var(--theme-fullscreen-player-text-shadow)', }} to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentSong?.albumId || '', })} w="100%" - weight={600} > {currentSong?.album}{' '} </TextTitle> <TextTitle - align="center" key="fs-artists" order={3} style={{ - textShadow: 'var(--fullscreen-player-text-shadow)', + textShadow: 'var(--theme-fullscreen-player-text-shadow)', }} > {currentSong?.artists?.map((artist, index) => ( <Fragment key={`fs-artist-${artist.id}`}> {index > 0 && ( <Text - $secondary - sx={{ + isMuted + style={{ display: 'inline-block', padding: '0 0.5rem', }} @@ -296,16 +249,16 @@ export const FullScreenPlayerImage = () => { </Text> )} <Text - $link - $secondary component={Link} + fw={600} + isLink + isMuted style={{ - textShadow: 'var(--fullscreen-player-text-shadow)', + textShadow: 'var(--theme-fullscreen-player-text-shadow)', }} to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: artist.id, })} - weight={600} > {artist.name} </Text> @@ -313,8 +266,8 @@ export const FullScreenPlayerImage = () => { ))} </TextTitle> <Group + justify="center" mt="sm" - position="center" > {currentSong?.container && ( <Badge size="lg"> @@ -325,7 +278,7 @@ export const FullScreenPlayerImage = () => { <Badge size="lg">{currentSong?.releaseYear}</Badge> )} </Group> - </MetadataContainer> - </PlayerContainer> + </Stack> + </Flex> ); }; diff --git a/src/renderer/features/player/components/full-screen-player-queue.module.css b/src/renderer/features/player/components/full-screen-player-queue.module.css new file mode 100644 index 00000000..4dac7859 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-player-queue.module.css @@ -0,0 +1,62 @@ +.queue-container { + position: relative; + display: flex; + height: 100%; + + :global(.ag-header) { + display: none; + } + + :global(.ag-theme-alpine-dark) { + --ag-header-background-color: rgb(0 0 0 / 0%) !important; + --ag-background-color: rgb(0 0 0 / 0%) !important; + --ag-odd-row-background-color: rgb(0 0 0 / 0%) !important; + } + + :global(.ag-row) { + &::before { + background: rgb(0 0 0 / 10%) !important; + border: none !important; + } + } + + :global(.ag-row-hover) { + background: rgb(0 0 0 / 10%) !important; + } +} + +.active-tab-indicator { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background: var(--theme-colors-foreground); +} + +.header-item-wrapper { + position: relative; + z-index: 2; + display: flex; + gap: 0; +} + +.grid-container { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: 1fr; + padding: 1rem; + + &::before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + content: ''; + background: var(--theme-colors-background); + border-radius: 5px; + opacity: var(--opacity, 1); + } +} diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx index 4babfb6d..19b6f262 100644 --- a/src/renderer/features/player/components/full-screen-player-queue.tsx +++ b/src/renderer/features/player/components/full-screen-player-queue.tsx @@ -1,12 +1,10 @@ -import { Group } from '@mantine/core'; -import { motion } from 'framer-motion'; -import { lazy, Suspense, useMemo } from 'react'; +import clsx from 'clsx'; +import { motion } from 'motion/react'; +import { CSSProperties, lazy, Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { HiOutlineQueueList } from 'react-icons/hi2'; -import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri'; -import styled from 'styled-components'; -import { Button } from '/@/renderer/components'; +import styles from './full-screen-player-queue.module.css'; + import { Lyrics } from '/@/renderer/features/lyrics/lyrics'; import { PlayQueue } from '/@/renderer/features/now-playing'; import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs'; @@ -15,6 +13,8 @@ import { useFullScreenPlayerStore, useFullScreenPlayerStoreActions, } from '/@/renderer/store/full-screen-player.store'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; import { PlaybackType } from '/@/shared/types/types'; const Visualizer = lazy(() => @@ -23,50 +23,6 @@ const Visualizer = lazy(() => })), ); -const QueueContainer = styled.div` - position: relative; - display: flex; - height: 100%; - - .ag-theme-alpine-dark { - --ag-header-background-color: rgb(0 0 0 / 0%) !important; - --ag-background-color: rgb(0 0 0 / 0%) !important; - --ag-odd-row-background-color: rgb(0 0 0 / 0%) !important; - } - - .ag-header { - display: none !important; - } -`; - -const ActiveTabIndicator = styled(motion.div)` - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2px; - background: var(--main-fg); -`; - -const HeaderItemWrapper = styled.div` - position: relative; - z-index: 2; -`; - -interface TransparentGridContainerProps { - opacity: number; -} - -const GridContainer = styled.div<TransparentGridContainerProps>` - display: grid; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: 1fr; - padding: 1rem; - /* stylelint-disable-next-line color-function-notation */ - background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%); - border-radius: 5px; -`; - export const FullScreenPlayerQueue = () => { const { t } = useTranslation(); const { activeTab, opacity } = useFullScreenPlayerStore(); @@ -77,19 +33,16 @@ export const FullScreenPlayerQueue = () => { const items = [ { active: activeTab === 'queue', - icon: <RiFileMusicLine size="1.5rem" />, label: t('page.fullscreenPlayer.upNext'), onClick: () => setStore({ activeTab: 'queue' }), }, { active: activeTab === 'related', - icon: <HiOutlineQueueList size="1.5rem" />, label: t('page.fullscreenPlayer.related'), onClick: () => setStore({ activeTab: 'related' }), }, { active: activeTab === 'lyrics', - icon: <RiFileTextLine size="1.5rem" />, label: t('page.fullscreenPlayer.lyrics'), onClick: () => setStore({ activeTab: 'lyrics' }), }, @@ -98,7 +51,6 @@ export const FullScreenPlayerQueue = () => { if (type === PlaybackType.WEB && webAudio) { items.push({ active: activeTab === 'visualizer', - icon: <RiFileTextLine size="1.5rem" />, label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }), onClick: () => setStore({ activeTab: 'visualizer' }), }); @@ -108,48 +60,54 @@ export const FullScreenPlayerQueue = () => { }, [activeTab, setStore, t, type, webAudio]); return ( - <GridContainer - className="full-screen-player-queue-container" - opacity={opacity} + <div + className={clsx(styles.gridContainer, 'full-screen-player-queue-container')} + style={ + { + '--opacity': opacity / 100, + } as CSSProperties + } > <Group align="center" className="full-screen-player-queue-header" + gap={0} grow - position="center" + justify="center" > {headerItems.map((item) => ( - <HeaderItemWrapper key={`tab-${item.label}`}> + <div + className={styles.headerItemWrapper} + key={`tab-${item.label}`} + > <Button - fullWidth + flex={1} fw="600" onClick={item.onClick} pos="relative" size="lg" - sx={{ - alignItems: 'center', - color: item.active - ? 'var(--main-fg) !important' - : 'var(--main-fg-secondary) !important', - letterSpacing: '1px', - }} uppercase variant="subtle" > {item.label} </Button> - {item.active ? <ActiveTabIndicator layoutId="underline" /> : null} - </HeaderItemWrapper> + {item.active ? ( + <motion.div + className={styles.activeTabIndicator} + layoutId="underline" + /> + ) : null} + </div> ))} </Group> {activeTab === 'queue' ? ( - <QueueContainer> + <div className={styles.queueContainer}> <PlayQueue type="fullScreen" /> - </QueueContainer> + </div> ) : activeTab === 'related' ? ( - <QueueContainer> + <div className={styles.queueContainer}> <FullScreenSimilarSongs /> - </QueueContainer> + </div> ) : activeTab === 'lyrics' ? ( <Lyrics /> ) : activeTab === 'visualizer' && type === PlaybackType.WEB && webAudio ? ( @@ -157,6 +115,6 @@ export const FullScreenPlayerQueue = () => { <Visualizer /> </Suspense> ) : null} - </GridContainer> + </div> ); }; diff --git a/src/renderer/features/player/components/full-screen-player.module.css b/src/renderer/features/player/components/full-screen-player.module.css new file mode 100644 index 00000000..79d888c4 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-player.module.css @@ -0,0 +1,40 @@ +.container { + position: absolute; + top: 0; + left: 0; + z-index: 200; + display: flex; + justify-content: center; + padding: 2rem; + + @media screen and (orientation: portrait) { + padding: 2rem 2rem 1rem; + } +} + +.responsive-container { + display: grid; + grid-template-rows: minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 2rem; + width: 100%; + max-width: 2560px; + margin-top: 5rem; + + @media screen and (orientation: portrait) { + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + margin-top: 0; + } +} + +.background-image-overlay { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + background: var(--theme-overlay-header); + backdrop-filter: blur(var(--image-blur)); +} diff --git a/src/renderer/features/player/components/full-screen-player.tsx b/src/renderer/features/player/components/full-screen-player.tsx index af73f630..df0325d1 100644 --- a/src/renderer/features/player/components/full-screen-player.tsx +++ b/src/renderer/features/player/components/full-screen-player.tsx @@ -1,21 +1,11 @@ -import { Divider, Group } from '@mantine/core'; import { useHotkeys } from '@mantine/hooks'; -import { motion, Variants } from 'framer-motion'; -import { useLayoutEffect, useRef } from 'react'; +import { motion, Variants } from 'motion/react'; +import { CSSProperties, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri'; import { useLocation } from 'react-router'; -import styled from 'styled-components'; -import { - Button, - NumberInput, - Option, - Popover, - Select, - Slider, - Switch, -} from '/@/renderer/components'; +import styles from './full-screen-player.module.css'; + import { TableConfigDropdown } from '/@/renderer/components/virtual-table'; import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image'; import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue'; @@ -29,54 +19,18 @@ import { useSettingsStoreActions, useWindowSettings, } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Option } from '/@/shared/components/option/option'; +import { Popover } from '/@/shared/components/popover/popover'; +import { Select } from '/@/shared/components/select/select'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Switch } from '/@/shared/components/switch/switch'; import { Platform } from '/@/shared/types/types'; -const Container = styled(motion.div)` - position: absolute; - top: 0; - left: 0; - z-index: 200; - display: flex; - justify-content: center; - padding: 2rem; - - @media screen and (orientation: portrait) { - padding: 2rem 2rem 1rem; - } -`; - -const ResponsiveContainer = styled.div` - display: grid; - grid-template-rows: minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 2rem 2rem; - width: 100%; - max-width: 2560px; - margin-top: 5rem; - - @media screen and (orientation: portrait) { - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - margin-top: 0; - } -`; - -interface BackgroundImageOverlayProps { - $blur: number; -} - -const BackgroundImageOverlay = styled.div<BackgroundImageOverlayProps>` - position: absolute; - top: 0; - left: 0; - z-index: -1; - width: 100%; - height: 100%; - background: var(--bg-header-overlay); - backdrop-filter: blur(${({ $blur }) => $blur}rem); -`; - -const mainBackground = 'var(--main-bg)'; +const mainBackground = 'var(--theme-colors-background)'; const Controls = () => { const { t } = useTranslation(); @@ -109,34 +63,30 @@ const Controls = () => { return ( <Group + gap="sm" p="1rem" pos="absolute" - spacing="sm" - sx={{ - background: `rgb(var(--main-bg-transparent), ${opacity}%)`, + style={{ + background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`, left: 0, top: 0, }} > - <Button - compact + <ActionIcon + icon="arrowDownS" + iconProps={{ size: 'lg' }} onClick={handleToggleFullScreenPlayer} - size="sm" tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }} variant="subtle" - > - <RiArrowDownSLine size="2rem" /> - </Button> + /> <Popover position="bottom-start"> <Popover.Target> - <Button - compact - size="sm" + <ActionIcon + icon="settings" + iconProps={{ size: 'lg' }} tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }} variant="subtle" - > - <RiSettings3Line size="1.5rem" /> - </Button> + /> </Popover.Target> <Popover.Dropdown> <Option> @@ -285,8 +235,8 @@ const Controls = () => { </Option.Label> <Option.Control> <Group - noWrap w="100%" + wrap="nowrap" > <Slider defaultValue={lyricConfig.fontSize} @@ -325,8 +275,8 @@ const Controls = () => { </Option.Label> <Option.Control> <Group - noWrap w="100%" + wrap="nowrap" > <Slider defaultValue={lyricConfig.gap} @@ -485,8 +435,9 @@ export const FullScreenPlayer = () => { : mainBackground; return ( - <Container + <motion.div animate="open" + className={styles.container} custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }} exit="closed" initial="closed" @@ -494,11 +445,20 @@ export const FullScreenPlayer = () => { variants={containerVariants} > <Controls /> - {dynamicBackground && <BackgroundImageOverlay $blur={dynamicImageBlur} />} - <ResponsiveContainer> + {dynamicBackground && ( + <div + className={styles.backgroundImageOverlay} + style={ + { + '--image-blur': `${dynamicImageBlur}`, + } as CSSProperties + } + /> + )} + <div className={styles.responsiveContainer}> <FullScreenPlayerImage /> <FullScreenPlayerQueue /> - </ResponsiveContainer> - </Container> + </div> + </motion.div> ); }; diff --git a/src/renderer/features/player/components/left-controls.module.css b/src/renderer/features/player/components/left-controls.module.css new file mode 100644 index 00000000..644fdc2a --- /dev/null +++ b/src/renderer/features/player/components/left-controls.module.css @@ -0,0 +1,82 @@ +.image-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md) 0; +} + +.metadata-stack { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-xs); + justify-content: center; + width: 100%; + overflow: hidden; +} + +@keyframes fadein { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.image { + position: relative; + width: 60px; + height: 60px; + cursor: pointer; + animation: fadein 0.2s ease-in-out; + + button { + display: none; + } + + &:hover button { + display: block; + } +} + +.playerbar-image { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + object-fit: var(--theme-image-fit); +} + +.line-item { + display: inline-block; + width: fit-content; + max-width: 20vw; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; + white-space: nowrap; +} + +.line-item.secondary { + color: var(--theme-colors-foreground-muted); + + a { + color: var(--theme-colors-foreground-muted); + } +} + +.left-controls-container { + display: flex; + width: 100%; + height: 100%; + padding-left: 1rem; + + @media (width < 640px) { + .image-wrapper { + display: none; + } + } +} diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index a9bb09e3..dbd4de75 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -1,14 +1,12 @@ -import { Center, Group } from '@mantine/core'; import { useHotkeys } from '@mantine/hooks'; -import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; +import clsx from 'clsx'; +import { AnimatePresence, LayoutGroup, motion } from 'motion/react'; import React, { MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri'; import { generatePath, Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { Button, Text, Tooltip } from '/@/renderer/components'; -import { Separator } from '/@/renderer/components/separator'; +import styles from './left-controls.module.css'; + import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { AppRoute } from '/@/renderer/router/routes'; @@ -20,80 +18,14 @@ import { useSetFullScreenPlayerStore, useSidebarStore, } from '/@/renderer/store'; -import { fadeIn } from '/@/renderer/styles'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { Image } from '/@/shared/components/image/image'; +import { Separator } from '/@/shared/components/separator/separator'; +import { Text } from '/@/shared/components/text/text'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { LibraryItem } from '/@/shared/types/domain-types'; -const ImageWrapper = styled.div` - position: relative; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem 1rem 1rem 0; -`; - -const MetadataStack = styled(motion.div)` - display: flex; - flex-direction: column; - gap: 0; - justify-content: center; - width: 100%; - overflow: hidden; -`; - -const Image = styled(motion.div)` - position: relative; - width: 60px; - height: 60px; - cursor: pointer; - background-color: var(--placeholder-bg); - filter: drop-shadow(0 5px 6px rgb(0 0 0 / 50%)); - - ${fadeIn}; - animation: fadein 0.2s ease-in-out; - - button { - display: none; - } - - &:hover button { - display: block; - } -`; - -const PlayerbarImage = styled.img` - width: 100%; - height: 100%; - object-fit: var(--image-fit); -`; - -const LineItem = styled.div<{ $secondary?: boolean }>` - display: inline-block; - width: fit-content; - max-width: 20vw; - overflow: hidden; - line-height: 1.3; - color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'}; - text-overflow: ellipsis; - white-space: nowrap; - - a { - color: ${(props) => props.$secondary && 'var(--text-secondary)'}; - } -`; - -const LeftControlsContainer = styled.div` - display: flex; - width: 100%; - height: 100%; - padding-left: 1rem; - - @media (width <= 640px) { - ${ImageWrapper} { - display: none; - } - } -`; - export const LeftControls = () => { const { t } = useTranslation(); const { setSideBar } = useAppStoreActions(); @@ -135,16 +67,17 @@ export const LeftControls = () => { ]); return ( - <LeftControlsContainer> + <div className={styles.leftControlsContainer}> <LayoutGroup> <AnimatePresence initial={false} mode="wait" > {!hideImage && ( - <ImageWrapper> - <Image + <div className={styles.imageWrapper}> + <motion.div animate={{ opacity: 1, scale: 1, x: 0 }} + className={styles.image} exit={{ opacity: 0, x: -50 }} initial={{ opacity: 0, x: -50 }} key="playerbar-image" @@ -158,34 +91,21 @@ export const LeftControls = () => { })} openDelay={500} > - {currentSong?.imageUrl ? ( - <PlayerbarImage - loading="eager" - src={currentSong?.imageUrl} - /> - ) : ( - <Center - sx={{ - background: 'var(--placeholder-bg)', - height: '100%', - }} - > - <RiDiscLine - color="var(--placeholder-fg)" - size={50} - /> - </Center> - )} + <Image + className={styles.playerbarImage} + loading="eager" + src={currentSong?.imageUrl ?? ''} + /> </Tooltip> - {!collapsed && ( - <Button - compact + <ActionIcon + icon="arrowUpS" + iconProps={{ size: 'xl' }} onClick={handleToggleSidebarImage} opacity={0.8} - radius={50} - size="md" - sx={{ + radius="md" + size="xs" + style={{ cursor: 'default', position: 'absolute', right: 2, @@ -197,56 +117,60 @@ export const LeftControls = () => { }), openDelay: 500, }} - variant="default" - > - <RiArrowUpSLine - color="white" - size={20} - /> - </Button> + /> )} - </Image> - </ImageWrapper> + </motion.div> + </div> )} </AnimatePresence> - <MetadataStack layout="position"> - <LineItem onClick={stopPropagation}> + <motion.div + className={styles.metadataStack} + layout="position" + > + <div + className={styles.lineItem} + onClick={stopPropagation} + > <Group - align="flex-start" - noWrap - spacing="xs" + align="center" + gap="xs" + wrap="nowrap" > <Text - $link component={Link} + fw={500} + isLink overflow="hidden" - size="md" to={AppRoute.NOW_PLAYING} - weight={500} > {title || '—'} </Text> {isSongDefined && ( - <Button - compact + <ActionIcon + icon="ellipsisVertical" onClick={(e) => handleGeneralContextMenu(e, [currentSong!])} + size="xs" + styles={{ + root: { + '--ai-size-xs': '1.15rem', + }, + }} variant="subtle" - > - <RiMore2Fill size="1.2rem" /> - </Button> + /> )} </Group> - </LineItem> - <LineItem - $secondary + </div> + <div + className={clsx(styles.lineItem, styles.secondary)} onClick={stopPropagation} > {artists?.map((artist, index) => ( <React.Fragment key={`bar-${artist.id}`}> {index > 0 && <Separator />} <Text - $link={artist.id !== ''} component={artist.id ? Link : undefined} + fw={500} + isLink={artist.id !== ''} overflow="hidden" size="md" to={ @@ -256,20 +180,20 @@ export const LeftControls = () => { }) : undefined } - weight={500} > {artist.name || '—'} </Text> </React.Fragment> ))} - </LineItem> - <LineItem - $secondary + </div> + <div + className={clsx(styles.lineItem, styles.secondary)} onClick={stopPropagation} > <Text - $link component={Link} + fw={500} + isLink overflow="hidden" size="md" to={ @@ -279,13 +203,12 @@ export const LeftControls = () => { }) : '' } - weight={500} > {currentSong?.album || '—'} </Text> - </LineItem> - </MetadataStack> + </div> + </motion.div> </LayoutGroup> - </LeftControlsContainer> + </div> ); }; diff --git a/src/renderer/features/player/components/player-button.module.css b/src/renderer/features/player/components/player-button.module.css new file mode 100644 index 00000000..da4d8daa --- /dev/null +++ b/src/renderer/features/player/components/player-button.module.css @@ -0,0 +1,67 @@ +.motion-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.motion-wrapper.main { + display: flex; + margin: 0 0.5rem; +} + +.player-button { + all: unset; + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem; + overflow: visible; + cursor: default; + + button { + display: flex; + } + + &:focus-visible { + outline: 1px var(--theme-colors-primary-filled) solid; + } + + &:disabled { + opacity: 0.5; + } + + svg { + display: flex; + } +} + +.player-button.active { + svg { + fill: var(--theme-colors-primary-filled); + } +} + +.main { + background: var(--theme-colors-foreground); + border-radius: 50%; + + svg { + display: flex; + color: var(--theme-colors-background); + fill: var(--theme-colors-background); + } +} + +.secondary { + color: var(--theme-colors-foreground); + + svg { + color: var(--theme-colors-foreground); + } +} + +.tertiary { + svg { + display: flex; + } +} diff --git a/src/renderer/features/player/components/player-button.tsx b/src/renderer/features/player/components/player-button.tsx index 7e75d62a..ee5b2109 100644 --- a/src/renderer/features/player/components/player-button.tsx +++ b/src/renderer/features/player/components/player-button.tsx @@ -1,167 +1,72 @@ -import type { TooltipProps, UnstyledButtonProps } from '@mantine/core'; +import clsx from 'clsx'; +import { motion } from 'motion/react'; +import { forwardRef, ReactNode } from 'react'; -import { UnstyledButton } from '@mantine/core'; -import { motion } from 'framer-motion'; -/* stylelint-disable no-descending-specificity */ -import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; -import styled, { css } from 'styled-components'; +import styles from './player-button.module.css'; -import { Tooltip } from '/@/renderer/components'; +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; +import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip'; -type MantineButtonProps = ComponentPropsWithoutRef<'button'> & UnstyledButtonProps; -interface PlayerButtonProps extends MantineButtonProps { - $isActive?: boolean; +interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> { icon: ReactNode; + isActive?: boolean; tooltip?: Omit<TooltipProps, 'children'>; variant: 'main' | 'secondary' | 'tertiary'; } -const WrapperMainVariant = css` - margin: 0 0.5rem; -`; - -type MotionWrapperProps = { variant: PlayerButtonProps['variant'] }; - -const MotionWrapper = styled(motion.div)<MotionWrapperProps>` - display: flex; - align-items: center; - justify-content: center; - - ${({ variant }) => variant === 'main' && WrapperMainVariant}; -`; - -const ButtonMainVariant = css` - padding: 0.5rem; - background: var(--playerbar-btn-main-bg); - border-radius: 50%; - - svg { - display: flex; - fill: var(--playerbar-btn-main-fg); - } - - &:focus-visible { - background: var(--playerbar-btn-main-bg-hover); - } - - &:hover { - background: var(--playerbar-btn-main-bg-hover); - - svg { - fill: var(--playerbar-btn-main-fg-hover); - } - } -`; - -const ButtonSecondaryVariant = css` - padding: 0.5rem; -`; - -const ButtonTertiaryVariant = css` - padding: 0.5rem; - - svg { - display: flex; - } - - &:focus-visible { - svg { - fill: var(--playerbar-btn-fg-hover); - stroke: var(--playerbar-btn-fg-hover); - } - } -`; - -type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>; - -const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>` - all: unset; - display: flex; - align-items: center; - width: 100%; - padding: 0.5rem; - overflow: visible; - cursor: default; - background: var(--playerbar-btn-bg-hover); - - button { - display: flex; - } - - &:focus-visible { - background: var(--playerbar-btn-bg-hover); - outline: 1px var(--primary-color) solid; - } - - &:disabled { - opacity: 0.5; - } - - svg { - display: flex; - fill: ${({ $isActive }) => - $isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'}; - stroke: var(--playerbar-btn-fg); - } - - &:hover { - color: var(--playerbar-btn-fg-hover); - background: var(--playerbar-btn-bg-hover); - - svg { - fill: ${({ $isActive }) => - $isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'}; - } - } - - ${({ variant }) => - variant === 'main' - ? ButtonMainVariant - : variant === 'secondary' - ? ButtonSecondaryVariant - : ButtonTertiaryVariant}; -`; - export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>( - ({ icon, tooltip, variant, ...rest }: PlayerButtonProps, ref) => { + ({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps, ref) => { if (tooltip) { return ( <Tooltip {...tooltip}> - <MotionWrapper + <motion.div + className={clsx({ + [styles.main]: variant === 'main', + [styles.motionWrapper]: true, + })} ref={ref} - variant={variant} > - <StyledPlayerButton - variant={variant} + <ActionIcon + className={clsx(styles.playerButton, styles[variant], { + [styles.active]: isActive, + })} {...rest} onClick={(e) => { e.stopPropagation(); rest.onClick?.(e); }} + variant="transparent" > {icon} - </StyledPlayerButton> - </MotionWrapper> + </ActionIcon> + </motion.div> </Tooltip> ); } return ( - <MotionWrapper + <motion.div + className={clsx({ + [styles.main]: variant === 'main', + [styles.motionWrapper]: true, + })} ref={ref} - variant={variant} > - <StyledPlayerButton - variant={variant} + <ActionIcon + className={clsx(styles.playerButton, styles[variant], { + [styles.active]: isActive, + })} {...rest} onClick={(e) => { e.stopPropagation(); rest.onClick?.(e); }} + size="compact-md" + variant="transparent" > {icon} - </StyledPlayerButton> - </MotionWrapper> + </ActionIcon> + </motion.div> ); }, ); diff --git a/src/renderer/features/player/components/playerbar-slider.module.css b/src/renderer/features/player/components/playerbar-slider.module.css new file mode 100644 index 00000000..2f4edc52 --- /dev/null +++ b/src/renderer/features/player/components/playerbar-slider.module.css @@ -0,0 +1,51 @@ +.bar { + background-color: var(--theme-colors-foreground); + transition: background-color 0.2s ease-in-out; +} + +.label { + max-width: 200px; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + font-size: var(--theme-font-size-md); + font-weight: 550; + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%); +} + +.root { + &:hover { + .bar { + background-color: var(--theme-colors-primary-filled); + } + + .thumb { + opacity: 1; + } + } + + &:focus { + .bar { + background-color: var(--theme-colors-primary-filled); + } + + .thumb { + opacity: 1; + } + } +} + +.thumb { + width: 1rem; + height: 1rem; + border-color: var(--theme-colors-primary-filled); + border-width: 1px; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.track { + &::before { + right: calc(0.1rem * -1); + } +} diff --git a/src/renderer/features/player/components/playerbar-slider.tsx b/src/renderer/features/player/components/playerbar-slider.tsx index 7f1ba27b..7af87521 100644 --- a/src/renderer/features/player/components/playerbar-slider.tsx +++ b/src/renderer/features/player/components/playerbar-slider.tsx @@ -1,44 +1,16 @@ -import { rem, Slider, SliderProps } from '@mantine/core'; +import styles from './playerbar-slider.module.css'; + +import { Slider, SliderProps } from '/@/shared/components/slider/slider'; export const PlayerbarSlider = ({ ...props }: SliderProps) => { return ( <Slider - styles={{ - bar: { - backgroundColor: 'var(--playerbar-slider-track-progress-bg)', - transition: 'background-color 0.2s ease', - }, - label: { - backgroundColor: 'var(--tooltip-bg)', - color: 'var(--tooltip-fg)', - fontSize: '1.1rem', - fontWeight: 600, - padding: '0 1rem', - }, - root: { - '&:hover': { - '& .mantine-Slider-bar': { - backgroundColor: 'var(--primary-color)', - }, - '& .mantine-Slider-thumb': { - opacity: 1, - }, - }, - }, - thumb: { - backgroundColor: 'var(--slider-thumb-bg)', - borderColor: 'var(--primary-color)', - borderWidth: rem(1), - height: '1rem', - opacity: 0, - width: '1rem', - }, - track: { - '&::before': { - backgroundColor: 'var(--playerbar-slider-track-bg)', - right: 'calc(0.1rem * -1)', - }, - }, + classNames={{ + bar: styles.bar, + label: styles.label, + root: styles.root, + thumb: styles.thumb, + track: styles.track, }} {...props} onClick={(e) => { diff --git a/src/renderer/features/player/components/playerbar.module.css b/src/renderer/features/player/components/playerbar.module.css new file mode 100644 index 00000000..e1566402 --- /dev/null +++ b/src/renderer/features/player/components/playerbar.module.css @@ -0,0 +1,40 @@ +.container { + width: 100vw; + height: 100%; + border-top: var(--theme-colors-border); +} + +.controls-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + height: 100%; + + @media (width < 768px) { + grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr); + } +} + +.right-grid-item { + align-self: center; + width: 100%; + height: 100%; + overflow: hidden; +} + +.left-grid-item { + width: 100%; + height: 100%; + overflow: hidden; +} + +.center-grid-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/src/renderer/features/player/components/playerbar.tsx b/src/renderer/features/player/components/playerbar.tsx index 2482230b..78074ce8 100644 --- a/src/renderer/features/player/components/playerbar.tsx +++ b/src/renderer/features/player/components/playerbar.tsx @@ -1,5 +1,6 @@ import { MouseEvent, useCallback } from 'react'; -import styled from 'styled-components'; + +import styles from './playerbar.module.css'; import { AudioPlayer } from '/@/renderer/components'; import { CenterControls } from '/@/renderer/features/player/components/center-controls'; @@ -25,47 +26,6 @@ import { } from '/@/renderer/store/settings.store'; import { PlaybackType } from '/@/shared/types/types'; -const PlayerbarContainer = styled.div` - width: 100vw; - height: 100%; - border-top: var(--playerbar-border-top); -`; - -const PlayerbarControlsGrid = styled.div` - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); - gap: 1rem; - height: 100%; - - @media (width <= 768px) { - grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr); - } -`; - -const RightGridItem = styled.div` - align-self: center; - width: 100%; - height: 100%; - overflow: hidden; -`; - -const LeftGridItem = styled.div` - width: 100%; - height: 100%; - overflow: hidden; -`; - -const CenterGridItem = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - overflow: hidden; -`; - export const Playerbar = () => { const playersRef = PlayersRef; const settings = useSettingsStore((state) => state.playback); @@ -92,20 +52,21 @@ export const Playerbar = () => { }, [autoNext]); return ( - <PlayerbarContainer + <div + className={styles.container} onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined} > - <PlayerbarControlsGrid> - <LeftGridItem> + <div className={styles.controlsGrid}> + <div className={styles.leftGridItem}> <LeftControls /> - </LeftGridItem> - <CenterGridItem> + </div> + <div className={styles.centerGridItem}> <CenterControls playersRef={playersRef} /> - </CenterGridItem> - <RightGridItem> + </div> + <div className={styles.rightGridItem}> <RightControls /> - </RightGridItem> - </PlayerbarControlsGrid> + </div> + </div> {playbackType === PlaybackType.WEB && ( <AudioPlayer autoNext={autoNextFn} @@ -122,6 +83,6 @@ export const Playerbar = () => { volume={(volume / 100) ** 2} /> )} - </PlayerbarContainer> + </div> ); }; diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 7b9863b7..7ac5d0b5 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -1,20 +1,8 @@ -import { Flex, Group } from '@mantine/core'; import { useHotkeys, useMediaQuery } from '@mantine/hooks'; import isElectron from 'is-electron'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { HiOutlineQueueList } from 'react-icons/hi2'; -import { - RiHeartFill, - RiHeartLine, - RiVolumeDownFill, - RiVolumeMuteFill, - RiVolumeUpFill, -} from 'react-icons/ri'; -import { DropdownMenu, Rating } from '/@/renderer/components'; -import { Slider } from '/@/renderer/components/slider'; -import { PlayerButton } from '/@/renderer/features/player/components/player-button'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { useRightControls } from '/@/renderer/features/player/hooks/use-right-controls'; import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared'; @@ -30,6 +18,12 @@ import { useSpeed, useVolume, } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Rating } from '/@/shared/components/rating/rating'; +import { Slider } from '/@/shared/components/slider/slider'; import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types'; const ipc = isElectron() ? window.api.ipc : null; @@ -210,15 +204,15 @@ export const RightControls = () => { {showRating && ( <Rating onChange={handleUpdateRating} - size="sm" + size="xs" value={currentSong?.userRating || 0} /> )} </Group> <Group align="center" - noWrap - spacing="xs" + gap="xs" + wrap="nowrap" > <DropdownMenu arrowOffset={12} @@ -228,13 +222,17 @@ export const RightControls = () => { withArrow > <DropdownMenu.Target> - <PlayerButton - icon={<>{speed} x</>} + <ActionIcon + icon="mediaSpeed" + iconProps={{ + size: 'lg', + }} + size="sm" tooltip={{ label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }), - openDelay: 500, + openDelay: 0, }} - variant="secondary" + variant="transparent" /> </DropdownMenu.Target> <DropdownMenu.Dropdown> @@ -264,78 +262,61 @@ export const RightControls = () => { /> </DropdownMenu.Dropdown> </DropdownMenu> - <PlayerButton - icon={ - currentSong?.userFavorite ? ( - <RiHeartFill - color="var(--primary-color)" - size="1.1rem" - /> - ) : ( - <RiHeartLine size="1.1rem" /> - ) - } - onClick={() => handleToggleFavorite(currentSong)} - sx={{ - svg: { - fill: !currentSong?.userFavorite - ? undefined - : 'var(--primary-color) !important', - }, + <ActionIcon + icon="favorite" + iconProps={{ + fill: currentSong?.userFavorite ? 'primary' : undefined, + size: 'lg', }} + onClick={() => handleToggleFavorite(currentSong)} + size="sm" tooltip={{ label: currentSong?.userFavorite ? t('player.unfavorite', { postProcess: 'titleCase' }) : t('player.favorite', { postProcess: 'titleCase' }), - openDelay: 500, + openDelay: 0, }} - variant="secondary" + variant="transparent" + /> + <ActionIcon + icon={isQueueExpanded ? 'panelRightClose' : 'panelRightOpen'} + iconProps={{ + size: 'lg', + }} + onClick={handleToggleQueue} + size="sm" + tooltip={{ + label: t('player.viewQueue', { postProcess: 'titleCase' }), + openDelay: 0, + }} + variant="transparent" + /> + <ActionIcon + icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'} + iconProps={{ + color: muted ? 'muted' : undefined, + size: 'xl', + }} + onClick={handleMute} + onWheel={handleVolumeWheel} + size="sm" + tooltip={{ + label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume, + openDelay: 0, + }} + variant="transparent" /> {!isMinWidth ? ( - <PlayerButton - icon={<HiOutlineQueueList size="1.1rem" />} - onClick={handleToggleQueue} - tooltip={{ - label: t('player.viewQueue', { postProcess: 'titleCase' }), - openDelay: 500, - }} - variant="secondary" + <PlayerbarSlider + max={100} + min={0} + onChange={handleVolumeSlider} + onWheel={handleVolumeWheel} + size={6} + value={volume} + w={volumeWidth} /> ) : null} - <Group - noWrap - spacing="xs" - > - <PlayerButton - icon={ - muted ? ( - <RiVolumeMuteFill size="1.2rem" /> - ) : volume > 50 ? ( - <RiVolumeUpFill size="1.2rem" /> - ) : ( - <RiVolumeDownFill size="1.2rem" /> - ) - } - onClick={handleMute} - onWheel={handleVolumeWheel} - tooltip={{ - label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume, - openDelay: 500, - }} - variant="secondary" - /> - {!isMinWidth ? ( - <PlayerbarSlider - max={100} - min={0} - onChange={handleVolumeSlider} - onWheel={handleVolumeWheel} - size={6} - value={volume} - w={volumeWidth} - /> - ) : null} - </Group> </Group> <Group h="calc(100% / 3)" /> </Flex> diff --git a/src/renderer/features/player/components/shuffle-all-modal.tsx b/src/renderer/features/player/components/shuffle-all-modal.tsx index ec7e5a4e..23c98f4b 100644 --- a/src/renderer/features/player/components/shuffle-all-modal.tsx +++ b/src/renderer/features/player/components/shuffle-all-modal.tsx @@ -1,18 +1,24 @@ -import { Divider, Group, SelectItem, Stack } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { QueryClient } from '@tanstack/react-query'; import merge from 'lodash/merge'; import { useMemo } from 'react'; -import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri'; -import { create } from 'zustand'; +import { useTranslation } from 'react-i18next'; import { persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, Checkbox, NumberInput, Select } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; import { GenreListResponse, GenreListSort, @@ -33,7 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery { enableMinYear: boolean; } -const useShuffleAllStore = create<ShuffleAllSlice>()( +const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()( persist( immer((set, get) => ({ actions: { @@ -58,7 +64,7 @@ const useShuffleAllStore = create<ShuffleAllSlice>()( ), ); -const PLAYED_DATA: SelectItem[] = [ +const PLAYED_DATA: { label: string; value: Played }[] = [ { label: 'all tracks', value: Played.All }, { label: 'only unplayed tracks', value: Played.Never }, { label: 'only played tracks', value: Played.Played }, @@ -81,6 +87,7 @@ export const ShuffleAllModal = ({ queryClient, server, }: ShuffleAllModalProps) => { + const { t } = useTranslation(); const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } = useShuffleAllStore(); const { setStore } = useShuffleAllStoreActions(); @@ -139,7 +146,7 @@ export const ShuffleAllModal = ({ }, [musicFolders]); return ( - <Stack spacing="md"> + <Stack gap="md"> <NumberInput label="How many tracks?" max={500} @@ -157,8 +164,8 @@ export const ShuffleAllModal = ({ rightSection={ <Checkbox checked={enableMinYear} - mr="0.5rem" onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })} + style={{ marginRight: '0.5rem' }} /> } value={minYear} @@ -172,8 +179,8 @@ export const ShuffleAllModal = ({ rightSection={ <Checkbox checked={enableMaxYear} - mr="0.5rem" onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })} + style={{ marginRight: '0.5rem' }} /> } value={maxYear} @@ -210,31 +217,31 @@ export const ShuffleAllModal = ({ <Group grow> <Button disabled={!limit} - leftIcon={<RiAddBoxFill size="1rem" />} + leftSection={<Icon icon="mediaPlayLast" />} onClick={() => handlePlay(Play.LAST)} type="submit" variant="default" > - Add + {t('player.addLast', { postProcess: 'sentenceCase' })} </Button> <Button disabled={!limit} - leftIcon={<RiAddCircleFill size="1rem" />} + leftSection={<Icon icon="mediaPlayNext" />} onClick={() => handlePlay(Play.NEXT)} type="submit" variant="default" > - Add next + {t('player.addNext', { postProcess: 'sentenceCase' })} </Button> </Group> <Button disabled={!limit} - leftIcon={<RiPlayFill size="1rem" />} + leftSection={<Icon icon="mediaPlay" />} onClick={() => handlePlay(Play.NOW)} type="submit" variant="filled" > - Play + {t('player.play', { postProcess: 'sentenceCase' })} </Button> </Stack> ); diff --git a/src/renderer/features/player/components/visualizer.module.css b/src/renderer/features/player/components/visualizer.module.css new file mode 100644 index 00000000..5e6e91d6 --- /dev/null +++ b/src/renderer/features/player/components/visualizer.module.css @@ -0,0 +1,9 @@ +.container { + max-width: 100%; + margin: auto; + + canvas { + width: 100%; + margin: auto; + } +} diff --git a/src/renderer/features/player/components/visualizer.tsx b/src/renderer/features/player/components/visualizer.tsx index 2d183695..2939e2a6 100644 --- a/src/renderer/features/player/components/visualizer.tsx +++ b/src/renderer/features/player/components/visualizer.tsx @@ -1,20 +1,11 @@ import AudioMotionAnalyzer from 'audiomotion-analyzer'; import { createRef, useCallback, useEffect, useState } from 'react'; -import styled from 'styled-components'; + +import styles from './visualizer.module.css'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { useSettingsStore } from '/@/renderer/store'; -const StyledContainer = styled.div` - max-width: 100%; - margin: auto; - - canvas { - width: 100%; - margin: auto; - } -`; - export const Visualizer = () => { const { webAudio } = useWebAudio(); const canvasRef = createRef<HTMLDivElement>(); @@ -67,7 +58,8 @@ export const Visualizer = () => { }, [resize]); return ( - <StyledContainer + <div + className={styles.container} ref={canvasRef} style={{ height: length, width: length }} /> diff --git a/src/renderer/features/player/hooks/use-center-controls.ts b/src/renderer/features/player/hooks/use-center-controls.ts index de7c33e8..77642a45 100644 --- a/src/renderer/features/player/hooks/use-center-controls.ts +++ b/src/renderer/features/player/hooks/use-center-controls.ts @@ -3,7 +3,6 @@ import debounce from 'lodash/debounce'; import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { toast } from '/@/renderer/components'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { @@ -18,6 +17,7 @@ import { } from '/@/renderer/store'; import { usePlaybackType } from '/@/renderer/store/settings.store'; import { setAutoNext, setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; +import { toast } from '/@/shared/components/toast/toast'; import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index d17e066b..efa07769 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -5,7 +5,6 @@ import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { toast } from '/@/renderer/components/toast/index'; import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { @@ -20,6 +19,7 @@ import { import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store'; import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store'; import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data'; +import { toast } from '/@/shared/components/toast/toast'; import { instanceOfCancellationError, LibraryItem, diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 2b300693..7e4d4be5 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -1,4 +1,3 @@ -import { Box, Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { closeModal, ContextModalProps } from '@mantine/modals'; import { useMemo, useState } from 'react'; @@ -6,12 +5,17 @@ import { useTranslation } from 'react-i18next'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, MultiSelect, Switch, toast } from '/@/renderer/components'; import { getGenreSongsById } from '/@/renderer/features/player'; import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation'; import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query'; import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentServer } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { MultiSelect } from '/@/shared/components/multi-select/multi-select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { toast } from '/@/shared/components/toast/toast'; import { PlaylistListSort, SongListQuery, @@ -218,7 +222,7 @@ export const AddToPlaylistContextModal = ({ }); return ( - <Box p="1rem"> + <div style={{ padding: '1rem' }}> <form onSubmit={handleSubmit}> <Stack> <MultiSelect @@ -240,29 +244,27 @@ export const AddToPlaylistContextModal = ({ })} {...form.getInputProps('skipDuplicates', { type: 'checkbox' })} /> - <Group position="right"> - <Group> - <Button - disabled={addToPlaylistMutation.isLoading} - onClick={() => closeModal(id)} - size="md" - variant="subtle" - > - {t('common.cancel', { postProcess: 'titleCase' })} - </Button> - <Button - disabled={isSubmitDisabled} - loading={isLoading} - size="md" - type="submit" - variant="filled" - > - {t('common.add', { postProcess: 'titleCase' })} - </Button> - </Group> + <Group justify="flex-end"> + <Button + disabled={addToPlaylistMutation.isLoading} + onClick={() => closeModal(id)} + size="md" + variant="subtle" + > + {t('common.cancel', { postProcess: 'titleCase' })} + </Button> + <Button + disabled={isSubmitDisabled} + loading={isLoading} + size="md" + type="submit" + variant="filled" + > + {t('common.add', { postProcess: 'titleCase' })} + </Button> </Group> </Stack> </form> - </Box> + </div> ); }; diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index e1c750a7..046476ca 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -1,9 +1,7 @@ -import { Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components'; import { PlaylistQueryBuilder, PlaylistQueryBuilderRef, @@ -12,6 +10,14 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; +import { Textarea } from '/@/shared/components/textarea/textarea'; +import { toast } from '/@/shared/components/toast/toast'; import { CreatePlaylistBody, ServerType, SongListSort } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; @@ -104,11 +110,13 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { {...form.getInputProps('name')} /> {server?.type === ServerType.NAVIDROME && ( - <TextInput + <Textarea + autosize label={t('form.createPlaylist.input', { context: 'description', postProcess: 'titleCase', })} + minRows={5} {...form.getInputProps('comment')} /> )} @@ -146,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { </Stack> )} - <Group position="right"> + <Group justify="flex-end"> <Button onClick={onCancel} variant="subtle" diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index 0dc81c61..312de882 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -10,14 +10,13 @@ import type { import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { useQueryClient } from '@tanstack/react-query'; -import { AnimatePresence } from 'framer-motion'; import debounce from 'lodash/debounce'; +import { AnimatePresence } from 'motion/react'; import { MutableRefObject, useCallback, useMemo } from 'react'; import { useParams } from 'react-router'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { toast } from '/@/renderer/components'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table'; import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; @@ -40,6 +39,7 @@ import { useSetPlaylistDetailTablePagination, } from '/@/renderer/store'; import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem, PlaylistSongListQuery, diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 695e74e5..f9495a5c 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -1,45 +1,28 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { IDatasource } from '@ag-grid-community/core'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { useQueryClient } from '@tanstack/react-query'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import debounce from 'lodash/debounce'; +import { MouseEvent, MutableRefObject, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiDeleteBinFill, - RiEditFill, - RiMoreFill, - RiPlayFill, - RiRefreshLine, - RiSettings3Fill, -} from 'react-icons/ri'; import { useNavigate, useParams } from 'react-router'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { - Button, - ConfirmModal, - DropdownMenu, - MultiSelect, - Slider, - Switch, - Text, - toast, -} from '/@/renderer/components'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; import { OrderToggleButton } from '/@/renderer/features/shared'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { AppRoute } from '/@/renderer/router/routes'; import { + PersistedTableColumn, SongListFilter, useCurrentServer, usePlaylistDetailStore, @@ -48,6 +31,15 @@ import { useSetPlaylistStore, useSetPlaylistTablePagination, } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem, PlaylistSongListQuery, @@ -55,7 +47,7 @@ import { SongListSort, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, Play, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType, Play } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -303,6 +295,8 @@ export const PlaylistDetailSongListHeaderFilters = ({ setTable({ rowHeight: e }); }; + const debouncedHandleItemSize = debounce(handleItemSize, 20); + const handleFilterChange = useCallback( async (filters: SongListFilter) => { if (server?.type !== ServerType.SUBSONIC) { @@ -392,14 +386,13 @@ export const PlaylistDetailSongListHeaderFilters = ({ }, [filters.sortOrder, handleFilterChange, playlistId, setFilter]); const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } }); + (displayType: ListDisplayType) => { + setPage({ detail: { ...page, display: displayType } }); }, [page, setPage], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = page.table.columns; if (values.length === 0) { @@ -410,7 +403,10 @@ export const PlaylistDetailSongListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; setTable({ columns: [...existingColumns, newColumn] }); } else { @@ -424,10 +420,10 @@ export const PlaylistDetailSongListHeaderFilters = ({ return tableRef.current?.api.sizeColumnsToFit(); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ autoFit: e.currentTarget.checked }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ autoFit: autoFitColumns }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -474,16 +470,13 @@ export const PlaylistDetailSongListHeaderFilters = ({ return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> <Button - compact - fw="600" - size="md" tooltip={{ label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }), }} @@ -495,7 +488,7 @@ export const PlaylistDetailSongListHeaderFilters = ({ <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( <DropdownMenu.Item - $isActive={filter.value === filters.sortBy} + isSelected={filter.value === filters.sortBy} key={`filter-${filter.name}`} onClick={handleSetSortBy} value={filter.value} @@ -511,40 +504,32 @@ export const PlaylistDetailSongListHeaderFilters = ({ onToggle={handleToggleSortOrder} sortOrder={filters.sortOrder || SortOrder.ASC} /> - <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - <RiMoreFill size="1.3rem" /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiPlayFill />} + leftSection={<Icon icon="mediaPlay" />} onClick={() => handlePlay(Play.NOW)} > {t('player.play', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddBoxFill />} + leftSection={<Icon icon="mediaPlayLast" />} onClick={() => handlePlay(Play.LAST)} > {t('player.addLast', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddCircleFill />} + leftSection={<Icon icon="mediaPlayNext" />} onClick={() => handlePlay(Play.NEXT)} > {t('player.addNext', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Divider /> <DropdownMenu.Item - icon={<RiEditFill />} + leftSection={<Icon icon="edit" />} onClick={() => openUpdatePlaylistModal({ playlist: detailQuery.data!, @@ -555,14 +540,14 @@ export const PlaylistDetailSongListHeaderFilters = ({ {t('action.editPlaylist', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiDeleteBinFill />} + leftSection={<Icon icon="delete" />} onClick={openDeletePlaylistModal} > {t('action.deletePlaylist', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Divider /> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('action.refresh', { postProcess: 'sentenceCase' })} @@ -571,7 +556,7 @@ export const PlaylistDetailSongListHeaderFilters = ({ <> <DropdownMenu.Divider /> <DropdownMenu.Item - $danger + isDanger onClick={handleToggleShowQueryBuilder} > {t('action.toggleSmartPlaylistEditor', { @@ -584,82 +569,18 @@ export const PlaylistDetailSongListHeaderFilters = ({ </DropdownMenu> </Group> <Group> - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={page.display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - Table - </DropdownMenu.Item> - {/* <DropdownMenu.Item - $isActive={page.display === ListDisplayType.TABLE_PAGINATED} - value={ListDisplayType.TABLE_PAGINATED} - onClick={handleSetViewType} - > - Table (paginated) - </DropdownMenu.Item> */} - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.itemSize', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={page.table.rowHeight} - label={null} - max={100} - min={25} - onChangeEnd={handleItemSize} - /> - </DropdownMenu.Item> - {(page.display === ListDisplayType.TABLE || - page.display === ListDisplayType.TABLE_PAGINATED) && ( - <> - <DropdownMenu.Label>Table Columns</DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={SONG_TABLE_COLUMNS} - defaultValue={page.table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text>Auto Fit Columns</Text> - <Switch - defaultChecked={page.table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + <ListConfigMenu + autoFitColumns={page.table.autoFit} + disabledViewTypes={[ListDisplayType.GRID, ListDisplayType.LIST]} + displayType={page.display} + itemSize={page.table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={page.table.columns.map((column) => column.column)} + tableColumnsData={SONG_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 9b4da1da..90fded26 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -1,17 +1,19 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Stack } from '@mantine/core'; import { MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; -import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { LibraryHeaderBar } from '/@/renderer/features/shared'; +import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { useCurrentServer } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { Badge } from '/@/shared/components/badge/badge'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; import { LibraryItem } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; @@ -45,32 +47,27 @@ export const PlaylistDetailSongListHeader = ({ const isSmartPlaylist = detailQuery?.data?.rules; return ( - <Stack spacing={0}> - <PageHeader backgroundColor="var(--titlebar-bg)"> + <Stack gap={0}> + <PageHeader> <LibraryHeaderBar> <LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} /> <LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title> - <Paper - fw="600" - px="1rem" - py="0.3rem" - radius="sm" - > + <Badge> {itemCount === null || itemCount === undefined ? ( <SpinnerIcon /> ) : ( itemCount )} - </Paper> + </Badge> {isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>} </LibraryHeaderBar> </PageHeader> - <Paper p="1rem"> + <FilterBar> <PlaylistDetailSongListHeaderFilters handleToggleShowQueryBuilder={handleToggleShowQueryBuilder} tableRef={tableRef} /> - </Paper> + </FilterBar> </Stack> ); }; diff --git a/src/renderer/features/playlists/components/playlist-list-content.tsx b/src/renderer/features/playlists/components/playlist-list-content.tsx index 8a4694e1..d1af6f1a 100644 --- a/src/renderer/features/playlists/components/playlist-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store/list.store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const PlaylistListTableView = lazy(() => @@ -32,7 +32,7 @@ export const PlaylistListContent = ({ gridRef, itemCount, tableRef }: PlaylistLi return ( <Suspense fallback={<Spinner container />}> - {display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? ( + {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( <PlaylistListGridView gridRef={gridRef} itemCount={itemCount} diff --git a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx index 7b487375..1b0a22b7 100644 --- a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx @@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { PLAYLIST_CARD_ROWS } from '/@/renderer/components'; +import { PLAYLIST_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index 3e105d77..094415cf 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -1,30 +1,45 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { IDatasource } from '@ag-grid-community/core'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; +import { closeAllModals, openModal } from '@mantine/modals'; import { useQueryClient } from '@tanstack/react-query'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import debounce from 'lodash/debounce'; +import { MouseEvent, MutableRefObject, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; +import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form'; import { OrderToggleButton } from '/@/renderer/features/shared'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { useContainerQuery } from '/@/renderer/hooks'; -import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; +import { + PersistedTableColumn, + PlaylistListFilter, + useCurrentServer, + useListStoreActions, +} from '/@/renderer/store'; import { useListStoreByKey } from '/@/renderer/store/list.store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { LibraryItem, PlaylistListQuery, PlaylistListSort, + ServerType, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -128,7 +143,7 @@ export const PlaylistListHeaderFilters = ({ const { display, filter, grid, table } = useListStoreByKey<PlaylistListQuery>({ key: pageKey }); const cq = useContainerQuery(); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const sortByLabel = (server?.type && @@ -267,14 +282,13 @@ export const PlaylistListHeaderFilters = ({ }, [filter.sortOrder, handleFilterChange, pageKey, setFilter]); const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + (displayType: ListDisplayType) => { + setDisplayType({ data: displayType, key: pageKey }); }, [pageKey, setDisplayType], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -286,7 +300,10 @@ export const PlaylistListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } @@ -298,10 +315,10 @@ export const PlaylistListHeaderFilters = ({ return setTable({ data: { columns: newColumns }, key: pageKey }); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -314,6 +331,8 @@ export const PlaylistListHeaderFilters = ({ } }; + const debouncedHandleItemSize = debounce(handleItemSize, 20); + const handleItemGap = (e: number) => { setGrid({ data: { itemGap: e }, key: pageKey }); }; @@ -323,28 +342,32 @@ export const PlaylistListHeaderFilters = ({ handleFilterChange(filter); }; + const handleCreatePlaylistModal = () => { + openModal({ + children: <CreatePlaylistForm onCancel={() => closeAllModals()} />, + onClose: () => { + tableRef?.current?.api?.purgeInfiniteCache(); + }, + size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm', + title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }), + }); + }; + return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -359,31 +382,14 @@ export const PlaylistListHeaderFilters = ({ onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} /> - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - <RiMoreFill size="1.3rem" /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { postProcess: 'titleCase' })} @@ -391,120 +397,29 @@ export const PlaylistListHeaderFilters = ({ </DropdownMenu.Dropdown> </DropdownMenu> </Group> - <Group> - <DropdownMenu - position="bottom-end" - width={425} + <Group + gap="xs" + wrap="nowrap" + > + <Button + onClick={handleCreatePlaylistModal} + variant="subtle" > - <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { postProcess: 'titleCase' })} - </DropdownMenu.Item> - {/* <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE_PAGINATED} - value={ListDisplayType.TABLE_PAGINATED} - onClick={handleSetViewType} - > - Table (paginated) - </DropdownMenu.Item> */} - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.itemSize', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight} - max={isGrid ? 300 : 100} - min={isGrid ? 100 : 25} - onChangeEnd={handleItemSize} - /> - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.itemGap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - {!isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.generaltableColumns', { - postProcess: 'titleCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={PLAYLIST_TABLE_COLUMNS} - defaultValue={table?.columns.map( - (column) => column.column, - )} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text> - {t('table.config.general.autoFitColumns', { - postProcess: 'titleCase', - })} - </Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </> - )} - </DropdownMenu.Dropdown> - </DropdownMenu> + {t('action.createPlaylist', { postProcess: 'sentenceCase' })} + </Button> + <ListConfigMenu + autoFitColumns={table.autoFit} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={PLAYLIST_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index 78b008d1..c7d96bac 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -1,21 +1,23 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Group, Stack } from '@mantine/core'; -import { closeAllModals, openModal } from '@mantine/modals'; import debounce from 'lodash/debounce'; import { ChangeEvent, MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiFileAddFill } from 'react-icons/ri'; -import { Button, PageHeader, Paper, SearchInput, SpinnerIcon } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; -import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form'; import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters'; -import { LibraryHeaderBar } from '/@/renderer/features/shared'; +import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store'; -import { LibraryItem, PlaylistListQuery, ServerType } from '/@/shared/types/domain-types'; +import { Badge } from '/@/shared/components/badge/badge'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { LibraryItem, PlaylistListQuery } from '/@/shared/types/domain-types'; interface PlaylistListHeaderProps { gridRef: MutableRefObject<null | VirtualInfiniteGridRef>; @@ -28,17 +30,6 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis const cq = useContainerQuery(); const server = useCurrentServer(); - const handleCreatePlaylistModal = () => { - openModal({ - children: <CreatePlaylistForm onCancel={() => closeAllModals()} />, - onClose: () => { - tableRef?.current?.api?.purgeInfiniteCache(); - }, - size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm', - title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }), - }); - }; - const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({ gridRef, itemCount, @@ -54,10 +45,10 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <Flex align="center" justify="space-between" @@ -67,44 +58,28 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis <LibraryHeaderBar.Title> {t('page.playlistList.title', { postProcess: 'titleCase' })} </LibraryHeaderBar.Title> - <Paper - fw="600" - px="1rem" - py="0.3rem" - radius="sm" - > + <Badge> {itemCount === null || itemCount === undefined ? ( <SpinnerIcon /> ) : ( itemCount )} - </Paper> - <Button - onClick={handleCreatePlaylistModal} - tooltip={{ - label: t('action.createPlaylist', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="filled" - > - <RiFileAddFill /> - </Button> + </Badge> </LibraryHeaderBar> <Group> <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> </PageHeader> - <Paper p="1rem"> + <FilterBar> <PlaylistListHeaderFilters gridRef={gridRef} tableRef={tableRef} /> - </Paper> + </FilterBar> </Stack> ); }; diff --git a/src/renderer/features/playlists/components/playlist-query-builder.tsx b/src/renderer/features/playlists/components/playlist-query-builder.tsx index 47121073..39927af5 100644 --- a/src/renderer/features/playlists/components/playlist-query-builder.tsx +++ b/src/renderer/features/playlists/components/playlist-query-builder.tsx @@ -1,4 +1,3 @@ -import { Group } from '@mantine/core'; import { useForm } from '@mantine/form'; import { openModal } from '@mantine/modals'; import clone from 'lodash/clone'; @@ -7,17 +6,8 @@ import setWith from 'lodash/setWith'; import { nanoid } from 'nanoid'; import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiMore2Fill, RiSaveLine } from 'react-icons/ri'; -import { - Button, - DropdownMenu, - MotionFlex, - NumberInput, - QueryBuilder, - ScrollArea, - Select, -} from '/@/renderer/components'; +import { QueryBuilder } from '/@/renderer/components/query-builder'; import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query'; import { convertNDQueryToQueryGroup, @@ -33,6 +23,15 @@ import { NDSongQueryPlaylistOperators, NDSongQueryStringOperators, } from '/@/shared/api/navidrome.types'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { Select } from '/@/shared/components/select/select'; import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types'; @@ -411,15 +410,12 @@ export const PlaylistQueryBuilder = forwardRef( ]; return ( - <MotionFlex + <Flex direction="column" - h="calc(100% - 3.5rem)" + h="calc(100% - 2rem)" justify="space-between" > - <ScrollArea - h="100%" - p="1rem" - > + <ScrollArea> <QueryBuilder data={filters} filters={NDSongQueryFields} @@ -448,14 +444,14 @@ export const PlaylistQueryBuilder = forwardRef( </ScrollArea> <Group align="flex-end" + justify="space-between" m="1rem" - noWrap - position="apart" + wrap="nowrap" > <Group - noWrap - spacing="sm" + gap="sm" w="100%" + wrap="nowrap" > <Select data={sortOptions} @@ -490,37 +486,38 @@ export const PlaylistQueryBuilder = forwardRef( </Group> {onSave && onSaveAs && ( <Group - noWrap - spacing="sm" + gap="sm" + wrap="nowrap" > <Button loading={isSaving} onClick={handleSaveAs} - variant="filled" > {t('common.saveAs', { postProcess: 'titleCase' })} </Button> <Button onClick={openPreviewModal} - p="0.5em" - variant="default" + variant="subtle" > {t('common.preview', { postProcess: 'titleCase' })} </Button> <DropdownMenu position="bottom-end"> <DropdownMenu.Target> - <Button + <ActionIcon disabled={isSaving} - p="0.5em" - variant="default" - > - <RiMore2Fill size={15} /> - </Button> + icon="ellipsisHorizontal" + variant="subtle" + /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - $danger - icon={<RiSaveLine color="var(--danger-color)" />} + isDanger + leftSection={ + <Icon + color="error" + icon="save" + /> + } onClick={handleSave} > {t('common.saveAndReplace', { postProcess: 'titleCase' })} @@ -530,7 +527,7 @@ export const PlaylistQueryBuilder = forwardRef( </Group> )} </Group> - </MotionFlex> + </Flex> ); }, ); diff --git a/src/renderer/features/playlists/components/save-as-playlist-form.tsx b/src/renderer/features/playlists/components/save-as-playlist-form.tsx index 3d7ca4e4..945f6a30 100644 --- a/src/renderer/features/playlists/components/save-as-playlist-form.tsx +++ b/src/renderer/features/playlists/components/save-as-playlist-form.tsx @@ -1,11 +1,15 @@ -import { Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useTranslation } from 'react-i18next'; -import { Button, Switch, TextInput, toast } from '/@/renderer/components'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; import { CreatePlaylistBody, CreatePlaylistResponse, @@ -98,7 +102,7 @@ export const SaveAsPlaylistForm = ({ {...form.getInputProps('public', { type: 'checkbox' })} /> )} - <Group position="right"> + <Group justify="flex-end"> <Button onClick={onCancel} variant="subtle" diff --git a/src/renderer/features/playlists/components/update-playlist-form.tsx b/src/renderer/features/playlists/components/update-playlist-form.tsx index 685ad166..98723b56 100644 --- a/src/renderer/features/playlists/components/update-playlist-form.tsx +++ b/src/renderer/features/playlists/components/update-playlist-form.tsx @@ -1,4 +1,3 @@ -import { Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { closeAllModals, openModal } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; @@ -6,11 +5,17 @@ import { useTranslation } from 'react-i18next'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components'; import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation'; import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; import { PlaylistDetailResponse, ServerListItem, @@ -134,7 +139,7 @@ export const UpdatePlaylistForm = ({ body, onCancel, query, users }: UpdatePlayl /> </> )} - <Group position="right"> + <Group justify="flex-end"> <Button onClick={onCancel} variant="subtle" diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index 371dc8c3..7d006dda 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -1,13 +1,11 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Box, Group } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; +import { motion } from 'motion/react'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiArrowDownSLine, RiArrowUpSLine } from 'react-icons/ri'; import { generatePath, useNavigate, useParams } from 'react-router'; -import { Button, Paper, Text, toast } from '/@/renderer/components'; import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content'; import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header'; import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder'; @@ -19,6 +17,11 @@ import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/play import { AnimatedPage } from '/@/renderer/features/shared'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, usePlaylistDetailStore } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Box } from '/@/shared/components/box/box'; +import { Group } from '/@/shared/components/group/group'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; import { PlaylistSongListQuery, ServerType, @@ -171,25 +174,23 @@ const PlaylistDetailSongListRoute = () => { /> {(isSmartPlaylist || showQueryBuilder) && ( - <Box> - <Paper + <motion.div> + <Box h="100%" mah="35vh" + p="md" w="100%" > - <Group p="1rem"> - <Button - compact + <Group pb="md"> + <ActionIcon + icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'} + iconProps={{ + size: 'md', + }} onClick={handleToggleExpand} - variant="default" - > - {isQueryBuilderExpanded ? ( - <RiArrowUpSLine size={20} /> - ) : ( - <RiArrowDownSLine size={20} /> - )} - </Button> - <Text>Query Editor</Text> + size="xs" + /> + <Text>{t('form.queryEditor.title', { postProcess: 'titleCase' })}</Text> </Group> {isQueryBuilderExpanded && ( <PlaylistQueryBuilder @@ -204,8 +205,8 @@ const PlaylistDetailSongListRoute = () => { sortOrder={detailQuery?.data?.rules?.order || 'asc'} /> )} - </Paper> - </Box> + </Box> + </motion.div> )} <PlaylistDetailSongListContent songs={ diff --git a/src/renderer/features/search/components/command-palette.tsx b/src/renderer/features/search/components/command-palette.tsx index 94acf5bb..ad0da392 100644 --- a/src/renderer/features/search/components/command-palette.tsx +++ b/src/renderer/features/search/components/command-palette.tsx @@ -1,12 +1,8 @@ -import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core'; import { useDebouncedValue, useDisclosure } from '@mantine/hooks'; import { Fragment, useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiCloseFill, RiSearchLine } from 'react-icons/ri'; import { generatePath, useNavigate } from 'react-router'; -import styled from 'styled-components'; -import { Button, Modal, Paper, Spinner, TextInput } from '/@/renderer/components'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command'; import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands'; @@ -16,18 +12,21 @@ import { ServerCommands } from '/@/renderer/features/search/components/server-co import { useSearch } from '/@/renderer/features/search/queries/search-query'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Box } from '/@/shared/components/box/box'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Kbd } from '/@/shared/components/kbd/kbd'; +import { Modal } from '/@/shared/components/modal/modal'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { TextInput } from '/@/shared/components/text-input/text-input'; import { LibraryItem } from '/@/shared/types/domain-types'; interface CommandPaletteProps { modalProps: (typeof useDisclosure)['arguments']; } -const CustomModal = styled(Modal)` - & .mantine-Modal-header { - display: none; - } -`; - export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { const navigate = useNavigate(); const server = useCurrentServer(); @@ -69,7 +68,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { const handlePlayQueueAdd = usePlayQueueAdd(); return ( - <CustomModal + <Modal {...modalProps} centered handlers={{ @@ -91,19 +90,21 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { } }, }} - scrollAreaComponent={ScrollArea.Autosize} size="lg" + styles={{ + header: { display: 'none' }, + }} > <Group + gap="sm" mb="1rem" - spacing="sm" > {pages.map((page, index) => ( <Fragment key={page}> {index > 0 && ' > '} <Button - compact disabled + size="compact-md" variant="default" > {page?.toLocaleUpperCase()} @@ -123,20 +124,23 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { > <TextInput data-autofocus - icon={<RiSearchLine />} + leftSection={<Icon icon="search" />} onChange={(e) => setQuery(e.currentTarget.value)} ref={searchInputRef} rightSection={ - <ActionIcon - onClick={() => { - setQuery(''); - searchInputRef.current?.focus(); - }} - > - <RiCloseFill /> - </ActionIcon> + query && ( + <ActionIcon + onClick={() => { + setQuery(''); + searchInputRef.current?.focus(); + }} + variant="transparent" + > + <Icon icon="x" /> + </ActionIcon> + ) } - size="lg" + size="sm" value={query} /> <Command.Separator /> @@ -263,22 +267,22 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { )} </Command.List> </Command> - <Paper + <Box mt="0.5rem" p="0.5rem" > - <Group position="apart"> + <Group justify="space-between"> <Command.Loading> {isHome && isLoading && query !== '' && <Spinner />} </Command.Loading> - <Group spacing="sm"> + <Group gap="sm"> <Kbd size="md">ESC</Kbd> <Kbd size="md">↑</Kbd> <Kbd size="md">↓</Kbd> <Kbd size="md">⏎</Kbd> </Group> </Group> - </Paper> - </CustomModal> + </Box> + </Modal> ); }; diff --git a/src/renderer/features/search/components/command.css b/src/renderer/features/search/components/command.css new file mode 100644 index 00000000..7f420de7 --- /dev/null +++ b/src/renderer/features/search/components/command.css @@ -0,0 +1,44 @@ +input[cmdk-input] { + width: 100%; + font-size: var(--theme-font-size-md); + border: none; + border-radius: var(--theme-radius-sm); +} + +[cmdk-group-heading] { + margin: var(--theme-spacing-md) 0; + font-size: var(--theme-font-size-sm); + opacity: 0.8; +} + +[cmdk-group-items] { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-xs); +} + +[cmdk-item] { + display: flex; + gap: var(--theme-spacing-sm); + align-items: center; + padding: var(--theme-spacing-sm); + font-size: var(--theme-font-size-md); + color: var(--theme-colors-foreground); + cursor: pointer; + border-radius: var(--theme-radius-sm); + + svg { + width: 1.2rem; + height: 1.2rem; + } + + &[data-selected] { + background: var(--theme-colors-surface); + } +} + +[cmdk-separator] { + height: 1px; + margin: 0 0 var(--theme-spacing-sm); + background: var(--theme-colors-border); +} diff --git a/src/renderer/features/search/components/command.tsx b/src/renderer/features/search/components/command.tsx index 176b4912..3fc71074 100644 --- a/src/renderer/features/search/components/command.tsx +++ b/src/renderer/features/search/components/command.tsx @@ -1,5 +1,6 @@ import { Command as Cmdk } from 'cmdk'; -import styled from 'styled-components'; + +import './command.css'; export enum CommandPalettePages { GO_TO = 'go', @@ -7,64 +8,4 @@ export enum CommandPalettePages { MANAGE_SERVERS = 'servers', } -export const Command = styled(Cmdk)` - [cmdk-root] { - background-color: var(--background-color); - } - - input[cmdk-input] { - width: 100%; - height: 1.5rem; - padding: 1.3rem 0.5rem; - margin-bottom: 1rem; - font-family: var(--content-font-family); - color: var(--input-fg); - background: var(--input-bg); - border: none; - border-radius: 5px; - - &::placeholder { - color: var(--input-placeholder-fg); - } - } - - [cmdk-group-heading] { - margin: 1rem 0; - font-size: 0.9rem; - opacity: 0.8; - } - - [cmdk-group-items] { - display: flex; - flex-direction: column; - gap: 0.4rem; - } - - [cmdk-item] { - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.5rem; - font-family: var(--content-font-family); - color: var(--btn-default-fg); - cursor: pointer; - background: var(--btn-default-bg); - border-radius: 5px; - - svg { - width: 1.2rem; - height: 1.2rem; - } - - &[data-selected] { - color: var(--btn-default-fg-hover); - background: var(--btn-default-bg-hover); - } - } - - [cmdk-separator] { - height: 1px; - margin: 0 0 0.5rem; - background: var(--generic-border-color); - } -`; +export const Command = Cmdk as typeof Cmdk; diff --git a/src/renderer/features/search/components/home-commands.tsx b/src/renderer/features/search/components/home-commands.tsx index 5cd8d91a..ed2e6245 100644 --- a/src/renderer/features/search/components/home-commands.tsx +++ b/src/renderer/features/search/components/home-commands.tsx @@ -35,7 +35,7 @@ export const HomeCommands = ({ openModal({ children: <CreatePlaylistForm onCancel={() => closeAllModals()} />, - size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm', + size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm', title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }), }); }, [handleClose, server?.type, t]); diff --git a/src/renderer/features/search/components/library-command-item.module.css b/src/renderer/features/search/components/library-command-item.module.css new file mode 100644 index 00000000..54a72fc7 --- /dev/null +++ b/src/renderer/features/search/components/library-command-item.module.css @@ -0,0 +1,34 @@ +.item-grid { + display: grid; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-template-columns: var(--item-height) minmax(0, 1fr); + grid-auto-columns: 1fr; + gap: 0.5rem; + width: 100%; + max-width: 100%; + height: 100%; + letter-spacing: 0.5px; +} + +.image-wrapper { + display: flex; + grid-area: image; + align-items: center; + justify-content: center; + height: 100%; +} + +.metadata-wrapper { + display: flex; + flex-direction: column; + grid-area: info; + justify-content: center; + width: 100%; +} + +.image { + object-fit: var(--theme-image-fit); + background: alpha(var(--theme-colors-foreground-muted), 0.3); + border-radius: 4px; +} diff --git a/src/renderer/features/search/components/library-command-item.tsx b/src/renderer/features/search/components/library-command-item.tsx index c497e159..4c44a1b5 100644 --- a/src/renderer/features/search/components/library-command-item.tsx +++ b/src/renderer/features/search/components/library-command-item.tsx @@ -1,59 +1,16 @@ -import { Center, Flex } from '@mantine/core'; -import { MouseEvent, useCallback } from 'react'; +import { CSSProperties, MouseEvent, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiAlbumFill, - RiPlayFill, - RiPlayListFill, - RiShuffleFill, - RiUserVoiceFill, -} from 'react-icons/ri'; -import styled from 'styled-components'; -import { Button, Text } from '/@/renderer/components'; +import styles from './library-command-item.module.css'; + +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Image } from '/@/shared/components/image/image'; +import { Text } from '/@/shared/components/text/text'; import { LibraryItem } from '/@/shared/types/domain-types'; import { Play, PlayQueueAddOptions } from '/@/shared/types/types'; -const Item = styled(Flex)``; - -const ItemGrid = styled.div<{ height: number }>` - display: grid; - grid-template-areas: 'image info'; - grid-template-rows: 1fr; - grid-template-columns: ${(props) => props.height}px minmax(0, 1fr); - grid-auto-columns: 1fr; - gap: 0.5rem; - width: 100%; - max-width: 100%; - height: 100%; - letter-spacing: 0.5px; -`; - -const ImageWrapper = styled.div` - display: flex; - grid-area: image; - align-items: center; - justify-content: center; - height: 100%; -`; - -const MetadataWrapper = styled.div` - display: flex; - flex-direction: column; - grid-area: info; - justify-content: center; - width: 100%; -`; - -const StyledImage = styled.img<{ placeholder?: string }>` - object-fit: var(--image-fit); - border-radius: 4px; -`; - -const ActionsContainer = styled(Flex)``; - interface LibraryCommandItemProps { disabled?: boolean; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; @@ -74,25 +31,6 @@ export const LibraryCommandItem = ({ title, }: LibraryCommandItemProps) => { const { t } = useTranslation(); - let Placeholder = RiAlbumFill; - - switch (itemType) { - case LibraryItem.ALBUM: - Placeholder = RiAlbumFill; - break; - case LibraryItem.ALBUM_ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.ARTIST: - Placeholder = RiUserVoiceFill; - break; - case LibraryItem.PLAYLIST: - Placeholder = RiPlayListFill; - break; - default: - Placeholder = RiAlbumFill; - break; - } const handlePlay = useCallback( (e: MouseEvent, id: string, playType: Play) => { @@ -108,110 +46,95 @@ export const LibraryCommandItem = ({ [handlePlayQueueAdd, itemType], ); + const [isHovered, setIsHovered] = useState(false); + return ( - <Item + <Flex gap="xl" justify="space-between" + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} style={{ height: '40px', width: '100%' }} > - <ItemGrid height={40}> - <ImageWrapper> - {imageUrl ? ( - <StyledImage - alt="cover" - height={40} - placeholder="var(--placeholder-bg)" - src={imageUrl} - style={{}} - width={40} - /> - ) : ( - <Center - style={{ - background: 'var(--placeholder-bg)', - borderRadius: 'var(--card-default-radius)', - height: `${40}px`, - width: `${40}px`, - }} - > - <Placeholder - color="var(--placeholder-fg)" - size={35} - /> - </Center> - )} - </ImageWrapper> - <MetadataWrapper> + <div + className={styles.itemGrid} + style={{ '--item-height': '40px' } as CSSProperties} + > + <div className={styles.imageWrapper}> + <Image + alt="cover" + className={styles.image} + height={40} + src={imageUrl || ''} + width={40} + /> + </div> + <div className={styles.metadataWrapper}> <Text overflow="hidden">{title}</Text> <Text - $secondary + isMuted overflow="hidden" > {subtitle} </Text> - </MetadataWrapper> - </ItemGrid> - <ActionsContainer - align="center" - gap="sm" - justify="flex-end" - > - <Button - compact - disabled={disabled} - onClick={(e) => handlePlay(e, id, Play.NOW)} - size="md" - tooltip={{ - label: t('player.play', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" + </div> + </div> + {isHovered && ( + <Group + align="center" + gap="sm" + justify="flex-end" + wrap="nowrap" > - <RiPlayFill /> - </Button> - {itemType !== LibraryItem.SONG && ( - <Button - compact + <ActionIcon disabled={disabled} - onClick={(e) => handlePlay(e, id, Play.SHUFFLE)} - size="md" + icon="mediaPlay" + onClick={(e) => handlePlay(e, id, Play.NOW)} + size="xs" tooltip={{ - label: t('player.shuffle', { postProcess: 'sentenceCase' }), + label: t('player.play', { postProcess: 'sentenceCase' }), openDelay: 500, }} - variant="default" - > - <RiShuffleFill /> - </Button> - )} - <Button - compact - disabled={disabled} - onClick={(e) => handlePlay(e, id, Play.LAST)} - size="md" - tooltip={{ - label: t('player.addLast', { postProcess: 'sentenceCase' }), + variant="subtle" + /> + {itemType !== LibraryItem.SONG && ( + <ActionIcon + disabled={disabled} + icon="mediaShuffle" + onClick={(e) => handlePlay(e, id, Play.SHUFFLE)} + size="xs" + tooltip={{ + label: t('player.shuffle', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + )} + <ActionIcon + disabled={disabled} + icon="mediaPlayLast" + onClick={(e) => handlePlay(e, id, Play.LAST)} + size="xs" + tooltip={{ + label: t('player.addLast', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiAddBoxFill /> - </Button> - <Button - compact - disabled={disabled} - onClick={(e) => handlePlay(e, id, Play.NEXT)} - size="md" - tooltip={{ - label: t('player.addNext', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiAddCircleFill /> - </Button> - </ActionsContainer> - </Item> + openDelay: 500, + }} + variant="subtle" + /> + <ActionIcon + disabled={disabled} + icon="mediaPlayNext" + onClick={(e) => handlePlay(e, id, Play.NEXT)} + size="xs" + tooltip={{ + label: t('player.addNext', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + </Group> + )} + </Flex> ); }; diff --git a/src/renderer/features/search/components/search-header.tsx b/src/renderer/features/search/components/search-header.tsx index dbbb4df0..b24f2d01 100644 --- a/src/renderer/features/search/components/search-header.tsx +++ b/src/renderer/features/search/components/search-header.tsx @@ -1,17 +1,21 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom'; -import { Button, PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useListStoreByKey } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { AlbumArtistListQuery, AlbumListQuery, @@ -46,8 +50,8 @@ export const SearchHeader = ({ navigationId, tableRef }: SearchHeaderProps) => { return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > <PageHeader> <Flex @@ -61,7 +65,6 @@ export const SearchHeader = ({ navigationId, tableRef }: SearchHeaderProps) => { <SearchInput defaultValue={searchParams.get('query') || ''} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> @@ -69,11 +72,10 @@ export const SearchHeader = ({ navigationId, tableRef }: SearchHeaderProps) => { <FilterBar> <Group> <Button - compact component={Link} fw={600} replace - size="md" + size="compact-md" state={{ navigationId }} to={{ pathname: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }), @@ -84,11 +86,10 @@ export const SearchHeader = ({ navigationId, tableRef }: SearchHeaderProps) => { {t('entity.track_other', { postProcess: 'sentenceCase' })} </Button> <Button - compact component={Link} fw={600} replace - size="md" + size="compact-md" state={{ navigationId }} to={{ pathname: generatePath(AppRoute.SEARCH, { @@ -101,11 +102,10 @@ export const SearchHeader = ({ navigationId, tableRef }: SearchHeaderProps) => { {t('entity.album_other', { postProcess: 'sentenceCase' })} </Button> <Button - compact component={Link} fw={600} replace - size="md" + size="compact-md" state={{ navigationId }} to={{ pathname: generatePath(AppRoute.SEARCH, { diff --git a/src/renderer/features/servers/assets/jellyfin.png b/src/renderer/features/servers/assets/jellyfin.png new file mode 100644 index 00000000..b690b74c Binary files /dev/null and b/src/renderer/features/servers/assets/jellyfin.png differ diff --git a/src/renderer/features/servers/assets/navidrome.png b/src/renderer/features/servers/assets/navidrome.png new file mode 100644 index 00000000..1fa2234e Binary files /dev/null and b/src/renderer/features/servers/assets/navidrome.png differ diff --git a/src/renderer/features/servers/assets/opensubsonic.png b/src/renderer/features/servers/assets/opensubsonic.png new file mode 100644 index 00000000..2608817e Binary files /dev/null and b/src/renderer/features/servers/assets/opensubsonic.png differ diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index e08d9c1e..bb4d8916 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -1,4 +1,3 @@ -import { Checkbox, Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useFocusTrap } from '@mantine/hooks'; import { closeAllModals } from '@mantine/modals'; @@ -8,23 +7,74 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '/@/renderer/api'; -import { Button, PasswordInput, SegmentedControl, TextInput, toast } from '/@/renderer/components'; +import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png'; +import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png'; +import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png'; import { useAuthStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Group } from '/@/shared/components/group/group'; +import { PasswordInput } from '/@/shared/components/password-input/password-input'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; import { AuthenticationResponse } from '/@/shared/types/domain-types'; import { ServerType, toServerType } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; -const SERVER_TYPES = [ - { label: 'Jellyfin', value: ServerType.JELLYFIN }, - { label: 'Navidrome', value: ServerType.NAVIDROME }, - { label: 'Subsonic', value: ServerType.SUBSONIC }, -]; - interface AddServerFormProps { - onCancel: () => void; + onCancel: (() => void) | null; } +function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) { + return ( + <Stack + align="center" + justify="center" + > + <img + height="50" + src={icon} + width="50" + /> + <Text>{label}</Text> + </Stack> + ); +} + +const SERVER_TYPES = [ + { + label: ( + <ServerIconWithLabel + icon={JellyfinIcon} + label="Jellyfin" + /> + ), + value: ServerType.JELLYFIN, + }, + { + label: ( + <ServerIconWithLabel + icon={NavidromeIcon} + label="Navidrome" + /> + ), + value: ServerType.NAVIDROME, + }, + { + label: ( + <ServerIconWithLabel + icon={SubsonicIcon} + label="OpenSubsonic" + /> + ), + value: ServerType.SUBSONIC, + }, +]; + export const AddServerForm = ({ onCancel }: AddServerFormProps) => { const { t } = useTranslation(); const focusTrapRef = useFocusTrap(true); @@ -40,7 +90,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { type: (localSettings ? localSettings.env.SERVER_TYPE - : toServerType(window.SERVER_TYPE)) ?? ServerType.JELLYFIN, + : toServerType(window.SERVER_TYPE)) ?? ServerType.NAVIDROME, url: (localSettings ? localSettings.env.SERVER_URL : window.SERVER_URL) ?? 'https://', username: '', }, @@ -131,6 +181,8 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { <SegmentedControl data={SERVER_TYPES} disabled={Boolean(serverLock)} + p="md" + withItemsBorders={false} {...form.getInputProps('type')} /> <Group grow> @@ -186,13 +238,18 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {...form.getInputProps('legacyAuth', { type: 'checkbox' })} /> )} - <Group position="right"> - <Button - onClick={onCancel} - variant="subtle" - > - {t('common.cancel', { postProcess: 'titleCase' })} - </Button> + <Group + grow + justify="flex-end" + > + {onCancel && ( + <Button + onClick={onCancel} + variant="subtle" + > + {t('common.cancel', { postProcess: 'titleCase' })} + </Button> + )} <Button disabled={isSubmitDisabled} loading={isLoading} diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index 6b1fc5fd..bd547fa9 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -1,18 +1,24 @@ -import { Group, Stack } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useFocusTrap } from '@mantine/hooks'; import { closeAllModals } from '@mantine/modals'; import isElectron from 'is-electron'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiInformationLine } from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, Checkbox, PasswordInput, TextInput, toast, Tooltip } from '/@/renderer/components'; import { queryClient } from '/@/renderer/lib/react-query'; import { useAuthStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { PasswordInput } from '/@/shared/components/password-input/password-input'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { AuthenticationResponse, ServerListItem, ServerType } from '/@/shared/types/domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -27,9 +33,10 @@ interface EditServerFormProps { const ModifiedFieldIndicator = () => { return ( <Tooltip label={i18n.t('common.modified', { postProcess: 'titleCase' }) as string}> - <span> - <RiInformationLine color="red" /> - </span> + <Icon + color="warn" + icon="info" + /> </Tooltip> ); }; @@ -185,7 +192,7 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer })} /> )} - <Group position="right"> + <Group justify="flex-end"> <Button onClick={onCancel} variant="subtle" diff --git a/src/renderer/features/servers/components/server-list-item.tsx b/src/renderer/features/servers/components/server-list-item.tsx index a2336154..134ae61e 100644 --- a/src/renderer/features/servers/components/server-list-item.tsx +++ b/src/renderer/features/servers/components/server-list-item.tsx @@ -1,14 +1,17 @@ -import { Divider, Group, Stack } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import isElectron from 'is-electron'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri'; -import { Button, Text, TimeoutButton } from '/@/renderer/components'; import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form'; import { ServerSection } from '/@/renderer/features/servers/components/server-section'; import { useAuthStoreActions } from '/@/renderer/store'; +import { Button, TimeoutButton } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Table } from '/@/shared/components/table/table'; import { ServerListItem as ServerItem } from '/@/shared/types/domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -54,17 +57,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { return ( <Stack> - <ServerSection - title={ - <Group position="apart"> - <Text> - {t('page.manageServers.serverDetails', { - postProcess: 'sentenceCase', - })} - </Text> - </Group> - } - > + <ServerSection title={null}> {edit ? ( <EditServerForm onCancel={() => editHandlers.toggle()} @@ -73,36 +66,41 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { /> ) : ( <Stack> - <Group noWrap> - <Stack> - <Text> - {t('page.manageServers.url', { - postProcess: 'sentenceCase', - })} - </Text> - <Text> - {t('page.manageServers.username', { - postProcess: 'sentenceCase', - })} - </Text> - </Stack> - <Stack> - <Text>{server.url}</Text> - <Text>{server.username}</Text> - </Stack> - </Group> + <Table + layout="fixed" + variant="vertical" + withTableBorder + > + <Table.Tbody> + <Table.Tr> + <Table.Th> + {t('page.manageServers.url', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td>{server.url}</Table.Td> + </Table.Tr> + <Table.Tr> + <Table.Th> + {t('page.manageServers.username', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td>{server.username}</Table.Td> + </Table.Tr> + </Table.Tbody> + </Table> <Group grow> <Button - leftIcon={<RiEdit2Fill />} + leftSection={<Icon icon="edit" />} onClick={() => handleEdit()} tooltip={{ label: t('page.manageServers.editServerDetailsTooltip', { postProcess: 'sentenceCase', }), }} - variant="subtle" > - {t('common.edit')} + {t('common.edit', { postProcess: 'titleCase' })} </Button> </Group> </Stack> @@ -110,9 +108,9 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { </ServerSection> <Divider my="sm" /> <TimeoutButton - leftIcon={<RiDeleteBin2Line />} + leftSection={<Icon icon="delete" />} timeoutProps={{ callback: handleDeleteServer, duration: 1000 }} - variant="subtle" + variant="state-error" > {t('page.manageServers.removeServer', { postProcess: 'sentenceCase' })} </TimeoutButton> diff --git a/src/renderer/features/servers/components/server-list.tsx b/src/renderer/features/servers/components/server-list.tsx index da394155..f88e2b44 100644 --- a/src/renderer/features/servers/components/server-list.tsx +++ b/src/renderer/features/servers/components/server-list.tsx @@ -1,16 +1,25 @@ -import { Divider, Group, Stack } from '@mantine/core'; import { useLocalStorage } from '@mantine/hooks'; import { openContextModal } from '@mantine/modals'; import isElectron from 'is-electron'; import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiAddFill, RiServerFill } from 'react-icons/ri'; -import { Accordion, Button, ContextModalVars, Switch, Text } from '/@/renderer/components'; +import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; +import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; +import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; import { AddServerForm } from '/@/renderer/features/servers/components/add-server-form'; import { ServerListItem } from '/@/renderer/features/servers/components/server-list-item'; import { useCurrentServer, useServerList } from '/@/renderer/store'; -import { titleCase } from '/@/renderer/utils'; +import { Accordion } from '/@/shared/components/accordion/accordion'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ContextModalVars } from '/@/shared/components/modal/modal'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; +import { ServerType } from '/@/shared/types/domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -59,27 +68,6 @@ export const ServerList = () => { return ( <> - <Group - mb={10} - position="right" - sx={{ - position: 'absolute', - right: 55, - transform: 'translateY(-3.5rem)', - zIndex: 2000, - }} - > - <Button - autoFocus - compact - leftIcon={<RiAddFill size={15} />} - onClick={handleAddServerModal} - size="sm" - variant="filled" - > - {t('form.addServer.title', { postProcess: 'titleCase' })} - </Button> - </Group> <Stack> <Accordion variant="separated"> {Object.keys(serverListQuery)?.map((serverId) => { @@ -89,10 +77,23 @@ export const ServerList = () => { key={server.id} value={server.name} > - <Accordion.Control icon={<RiServerFill size={15} />}> - <Group position="apart"> - <Text weight={server.id === currentServer?.id ? 800 : 400}> - {titleCase(server?.type)} - {server?.name} + <Accordion.Control> + <Group> + <img + src={ + server.type === ServerType.NAVIDROME + ? NavidromeLogo + : server.type === ServerType.JELLYFIN + ? JellyfinLogo + : OpenSubsonicLogo + } + style={{ + height: 'var(--theme-font-size-lg)', + width: 'var(--theme-font-size-lg)', + }} + /> + <Text fw={server.id === currentServer?.id ? 600 : 400}> + {server?.name} </Text> </Group> </Accordion.Control> @@ -102,6 +103,18 @@ export const ServerList = () => { </Accordion.Item> ); })} + <Group + grow + pt="md" + > + <Button + autoFocus + leftSection={<Icon icon="add" />} + onClick={handleAddServerModal} + > + {t('form.addServer.title', { postProcess: 'titleCase' })} + </Button> + </Group> </Accordion> {isElectron() && ( <> diff --git a/src/renderer/features/servers/components/server-section.tsx b/src/renderer/features/servers/components/server-section.tsx index 520c42ce..b4eba2b1 100644 --- a/src/renderer/features/servers/components/server-section.tsx +++ b/src/renderer/features/servers/components/server-section.tsx @@ -1,25 +1,17 @@ -import React from 'react'; -import styled from 'styled-components'; +import React, { Fragment } from 'react'; -import { Text } from '/@/renderer/components'; +import { Text } from '/@/shared/components/text/text'; interface ServerSectionProps { children: React.ReactNode; title: React.ReactNode | string; } -const Container = styled.div``; - -const Section = styled.div` - padding: 1rem; - border: 1px dashed var(--generic-border-color); -`; - export const ServerSection = ({ children, title }: ServerSectionProps) => { return ( - <Container> + <Fragment> {React.isValidElement(title) ? title : <Text>{title}</Text>} - <Section>{children}</Section> - </Container> + <div style={{ padding: '1rem' }}>{children}</div> + </Fragment> ); }; diff --git a/src/renderer/features/settings/components/advanced/advanced-tab.tsx b/src/renderer/features/settings/components/advanced/advanced-tab.tsx index 19824f56..3affb216 100644 --- a/src/renderer/features/settings/components/advanced/advanced-tab.tsx +++ b/src/renderer/features/settings/components/advanced/advanced-tab.tsx @@ -1,10 +1,9 @@ -import { Stack } from '@mantine/core'; - import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings'; +import { Stack } from '/@/shared/components/stack/stack'; export const AdvancedTab = () => { return ( - <Stack spacing="md"> + <Stack gap="md"> <StylesSettings /> </Stack> ); diff --git a/src/renderer/features/settings/components/advanced/styles-settings.tsx b/src/renderer/features/settings/components/advanced/styles-settings.tsx index 7defbcda..5621b552 100644 --- a/src/renderer/features/settings/components/advanced/styles-settings.tsx +++ b/src/renderer/features/settings/components/advanced/styles-settings.tsx @@ -1,12 +1,16 @@ -import { Code } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, ConfirmModal, Switch, Text, Textarea } from '/@/renderer/components'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; import { useCssSettings, useSettingsStoreActions } from '/@/renderer/store'; import { sanitizeCss } from '/@/renderer/utils/sanitize'; +import { Button } from '/@/shared/components/button/button'; +import { Code } from '/@/shared/components/code/code'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; +import { Textarea } from '/@/shared/components/textarea/textarea'; export const StylesSettings = () => { const [open, setOpen] = useState(false); @@ -82,8 +86,8 @@ export const StylesSettings = () => { <> {open && ( <Button - compact onClick={handleSave} + size="compact-md" // disabled={isSaveButtonDisabled} variant="filled" > @@ -91,8 +95,8 @@ export const StylesSettings = () => { </Button> )} <Button - compact onClick={() => setOpen(!open)} + size="compact-md" variant="filled" > {t(open ? 'common.close' : 'common.edit', { diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index 03cef3b2..c9c8ee7d 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -5,7 +5,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import i18n, { languages } from '/@/i18n/i18n'; -import { FileInput, NumberInput, Select, toast } from '/@/renderer/components'; import { SettingOption, SettingsSection, @@ -15,6 +14,10 @@ import { useGeneralSettings, useSettingsStoreActions, } from '/@/renderer/store/settings.store'; +import { FileInput } from '/@/shared/components/file-input/file-input'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { toast } from '/@/shared/components/toast/toast'; import { FontType } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -148,7 +151,8 @@ export const ApplicationSettings = () => { getFonts(); }, [fontSettings, localFonts, setSettings, t]); - const handleChangeLanguage = (e: string) => { + const handleChangeLanguage = (e: null | string) => { + if (!e) return; setSettings({ general: { ...settings, diff --git a/src/renderer/features/settings/components/general/context-menu-settings.tsx b/src/renderer/features/settings/components/general/context-menu-settings.tsx index 9388eb38..30e9f824 100644 --- a/src/renderer/features/settings/components/general/context-menu-settings.tsx +++ b/src/renderer/features/settings/components/general/context-menu-settings.tsx @@ -1,14 +1,16 @@ -import { Divider, Stack } from '@mantine/core'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Checkbox } from '/@/renderer/components'; import { CONFIGURABLE_CONTEXT_MENU_ITEMS, CONTEXT_MENU_ITEM_MAPPING, } from '/@/renderer/features/context-menu'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Stack } from '/@/shared/components/stack/stack'; export const ContextMenuSettings = () => { const disabledItems = useSettingsStore((state) => state.general.disabledContextMenu); @@ -21,8 +23,8 @@ export const ContextMenuSettings = () => { <SettingsOptions control={ <Button - compact onClick={() => setOpen(!open)} + size="compact-md" variant="filled" > {t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })} diff --git a/src/renderer/features/settings/components/general/control-settings.tsx b/src/renderer/features/settings/components/general/control-settings.tsx index 187cad71..4d5ace11 100644 --- a/src/renderer/features/settings/components/general/control-settings.tsx +++ b/src/renderer/features/settings/components/general/control-settings.tsx @@ -1,9 +1,7 @@ -import { Group } from '@mantine/core'; import { t } from 'i18next'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Select, Slider, Switch, Tooltip } from '/@/renderer/components'; import { SettingOption, SettingsSection, @@ -14,6 +12,13 @@ import { useGeneralSettings, useSettingsStoreActions, } from '/@/renderer/store/settings.store'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Play } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -59,7 +64,7 @@ export const ControlSettings = () => { }, }); }} - rightSection="px" + rightSection={<Text size="sm">px</Text>} width={75} /> ), @@ -83,7 +88,7 @@ export const ControlSettings = () => { setSettings({ general: { ...settings, albumArtRes: newVal } }); }} placeholder="0" - rightSection="px" + rightSection={<Text size="sm">px</Text>} value={settings.albumArtRes ?? 0} width={75} /> @@ -346,7 +351,7 @@ export const ControlSettings = () => { }); }} placeholder="0" - rightSection="px" + rightSection={<Text size="sm">px</Text>} width={75} /> ), diff --git a/src/renderer/features/settings/components/general/draggable-item.tsx b/src/renderer/features/settings/components/general/draggable-item.tsx index c4c67078..5879ce49 100644 --- a/src/renderer/features/settings/components/general/draggable-item.tsx +++ b/src/renderer/features/settings/components/general/draggable-item.tsx @@ -1,15 +1,21 @@ -import { Group } from '@mantine/core'; -import { DragControls, Reorder, useDragControls } from 'framer-motion'; -import { MdDragIndicator } from 'react-icons/md'; +import { DragControls, Reorder, useDragControls } from 'motion/react'; -import { Checkbox } from '/@/renderer/components'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Group } from '/@/shared/components/group/group'; +import { Text } from '/@/shared/components/text/text'; const DragHandle = ({ dragControls }: { dragControls: DragControls }) => { return ( - <MdDragIndicator - color="white" + <ActionIcon + icon="dragVertical" + iconProps={{ + size: 'md', + }} onPointerDown={(event) => dragControls.start(event)} + size="xs" style={{ cursor: 'grab' }} + variant="transparent" /> ); }; @@ -36,16 +42,17 @@ export const DraggableItem = ({ handleChangeDisabled, item, value }: DraggableIt value={item} > <Group - h="3rem" - noWrap + py="md" style={{ boxShadow: '0 1px 3px rgba(0,0,0,.1)' }} + wrap="nowrap" > <Checkbox checked={!item.disabled} onChange={(e) => handleChangeDisabled(item.id, e.target.checked)} + size="xs" /> <DragHandle dragControls={dragControls} /> - {value} + <Text>{value}</Text> </Group> </Reorder.Item> ); diff --git a/src/renderer/features/settings/components/general/draggable-items.tsx b/src/renderer/features/settings/components/general/draggable-items.tsx index 0db2cf85..3c4c3a0f 100644 --- a/src/renderer/features/settings/components/general/draggable-items.tsx +++ b/src/renderer/features/settings/components/general/draggable-items.tsx @@ -1,14 +1,14 @@ -import { Divider } from '@mantine/core'; -import { Reorder } from 'framer-motion'; import isEqual from 'lodash/isEqual'; +import { Reorder } from 'motion/react'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button } from '/@/renderer/components'; import { DraggableItem } from '/@/renderer/features/settings/components/general/draggable-item'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context'; import { SortableItem } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; export type DraggableItemsProps<K, T> = { description: string; @@ -85,18 +85,18 @@ export const DraggableItems = <K extends string, T extends SortableItem<K>>({ <> {open && ( <Button - compact disabled={isSaveButtonDisabled} onClick={handleSave} + size="compact-md" variant="filled" > {t('common.save', { postProcess: 'titleCase' })} </Button> )} <Button - compact onClick={() => setOpen(!open)} - variant="filled" + size="compact-md" + variant={open ? 'subtle' : 'filled'} > {t(open ? 'common.close' : 'common.edit', { postProcess: 'titleCase' })} </Button> diff --git a/src/renderer/features/settings/components/general/general-tab.tsx b/src/renderer/features/settings/components/general/general-tab.tsx index e46134ec..dfe436ae 100644 --- a/src/renderer/features/settings/components/general/general-tab.tsx +++ b/src/renderer/features/settings/components/general/general-tab.tsx @@ -1,4 +1,3 @@ -import { Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings'; @@ -11,10 +10,11 @@ import { SidebarReorder } from '/@/renderer/features/settings/components/general import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings'; import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings'; import { CacheSettings } from '/@/renderer/features/settings/components/window/cache-settngs'; +import { Stack } from '/@/shared/components/stack/stack'; export const GeneralTab = () => { return ( - <Stack spacing="md"> + <Stack gap="md"> <ApplicationSettings /> <ThemeSettings /> <ControlSettings /> diff --git a/src/renderer/features/settings/components/general/remote-settings.tsx b/src/renderer/features/settings/components/general/remote-settings.tsx index 34371336..a982e7c1 100644 --- a/src/renderer/features/settings/components/general/remote-settings.tsx +++ b/src/renderer/features/settings/components/general/remote-settings.tsx @@ -2,9 +2,13 @@ import isElectron from 'is-electron'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, Text, TextInput, toast } from '/@/renderer/components'; import { SettingsSection } from '/@/renderer/features/settings/components/settings-section'; import { useRemoteSettings, useSettingsStoreActions } from '/@/renderer/store'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; const remote = isElectron() ? window.api.remote : null; @@ -70,8 +74,8 @@ export const RemoteSettings = () => { ), description: ( <Text - $noSelect - $secondary + isMuted + isNoSelect size="sm" > {t('setting.enableRemote', { diff --git a/src/renderer/features/settings/components/general/sidebar-settings.tsx b/src/renderer/features/settings/components/general/sidebar-settings.tsx index 1857f976..a53956ec 100644 --- a/src/renderer/features/settings/components/general/sidebar-settings.tsx +++ b/src/renderer/features/settings/components/general/sidebar-settings.tsx @@ -1,12 +1,12 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { Switch } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store'; +import { Switch } from '/@/shared/components/switch/switch'; export const SidebarSettings = () => { const { t } = useTranslation(); diff --git a/src/renderer/features/settings/components/general/theme-settings.tsx b/src/renderer/features/settings/components/general/theme-settings.tsx index dc0eb42a..9261f3d5 100644 --- a/src/renderer/features/settings/components/general/theme-settings.tsx +++ b/src/renderer/features/settings/components/general/theme-settings.tsx @@ -1,15 +1,17 @@ -import { ColorInput, Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Select, Switch } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; -import { THEME_DATA } from '/@/renderer/hooks'; import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; -import { AppTheme } from '/@/shared/types/domain-types'; +import { THEME_DATA, useSetColorScheme } from '/@/renderer/themes/use-app-theme'; +import { ColorInput } from '/@/shared/components/color-input/color-input'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { AppTheme } from '/@/shared/themes/app-theme-types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -17,6 +19,7 @@ export const ThemeSettings = () => { const { t } = useTranslation(); const settings = useGeneralSettings(); const { setSettings } = useSettingsStoreActions(); + const { setColorScheme } = useSetColorScheme(); const themeOptions: SettingOption[] = [ { @@ -30,6 +33,7 @@ export const ThemeSettings = () => { followSystemTheme: e.currentTarget.checked, }, }); + if (localSettings) { localSettings.themeSet( e.currentTarget.checked @@ -56,16 +60,20 @@ export const ThemeSettings = () => { defaultValue={settings.theme} onChange={(e) => { const theme = e as AppTheme; + setSettings({ general: { ...settings, theme, }, }); + + const colorScheme = theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light'; + + setColorScheme(colorScheme); + if (localSettings) { - localSettings.themeSet( - theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light', - ); + localSettings.themeSet(colorScheme); } }} /> diff --git a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx index d0fb4adf..0b3eddb0 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx @@ -1,16 +1,19 @@ -import { Group } from '@mantine/core'; import isElectron from 'is-electron'; import debounce from 'lodash/debounce'; import { ChangeEvent, KeyboardEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiDeleteBinLine, RiEditLine, RiKeyboardBoxLine } from 'react-icons/ri'; -import styled from 'styled-components'; + +import styles from './hotkeys-manager-settings.module.css'; import i18n from '/@/i18n/i18n'; -import { Button, Checkbox, TextInput } from '/@/renderer/components'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context'; import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { TextInput } from '/@/shared/components/text-input/text-input'; const ipc = isElectron() ? window.api.ipc : null; @@ -92,18 +95,6 @@ const BINDINGS_MAP: Record<BindingActions, string> = { zoomOut: i18n.t('setting.hotkey', { context: 'zoomOut', postProcess: 'sentenceCase' }), }; -const HotkeysContainer = styled.div` - display: flex; - flex-direction: column; - gap: 1rem; - justify-content: center; - width: 100%; - - button { - padding: 0 1rem; - } -`; - export const HotkeyManagerSettings = () => { const { t } = useTranslation(); const { bindings, globalMediaHotkeys } = useHotkeySettings(); @@ -247,11 +238,11 @@ export const HotkeyManagerSettings = () => { })} title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })} /> - <HotkeysContainer> + <div className={styles.container}> {filteredBindings.map((binding) => ( <Group key={`hotkey-${binding}`} - noWrap + wrap="nowrap" > <TextInput readOnly @@ -259,8 +250,8 @@ export const HotkeyManagerSettings = () => { value={BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]} /> <TextInput - icon={<RiKeyboardBoxLine />} id={`hotkey-${binding}`} + leftSection={<Icon icon="keyboard" />} onBlur={() => setSelected(null)} onChange={() => {}} onKeyDownCapture={(e) => { @@ -268,6 +259,16 @@ export const HotkeyManagerSettings = () => { handleSetHotkey(binding as BindingActions, e); }} readOnly + rightSection={ + <ActionIcon + icon="edit" + onClick={() => { + setSelected(binding as BindingActions); + document.getElementById(`hotkey-${binding}`)?.focus(); + }} + variant="transparent" + /> + } style={{ opacity: selected === (binding as BindingActions) ? 0.8 : 1, outline: duplicateHotkeyMap.includes( @@ -287,7 +288,7 @@ export const HotkeyManagerSettings = () => { onChange={(e) => handleSetGlobalHotkey(binding as BindingActions, e) } - size="xl" + size="md" style={{ opacity: bindings[binding as keyof typeof BINDINGS_MAP] .allowGlobal @@ -296,25 +297,19 @@ export const HotkeyManagerSettings = () => { }} /> )} - <Button - onClick={() => { - setSelected(binding as BindingActions); - document.getElementById(`hotkey-${binding}`)?.focus(); - }} - variant="default" - w={100} - > - <RiEditLine /> - </Button> - <Button - onClick={() => handleClearHotkey(binding as BindingActions)} - variant="default" - > - <RiDeleteBinLine /> - </Button> + {bindings[binding as keyof typeof BINDINGS_MAP].hotkey && ( + <ActionIcon + icon="x" + iconProps={{ + color: 'error', + }} + onClick={() => handleClearHotkey(binding as BindingActions)} + variant="transparent" + /> + )} </Group> ))} - </HotkeysContainer> + </div> </> ); }; diff --git a/src/renderer/features/settings/components/hotkeys/hotkeys-manager-settings.module.css b/src/renderer/features/settings/components/hotkeys/hotkeys-manager-settings.module.css new file mode 100644 index 00000000..20a86aba --- /dev/null +++ b/src/renderer/features/settings/components/hotkeys/hotkeys-manager-settings.module.css @@ -0,0 +1,7 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-md); + justify-content: center; + width: 100%; +} diff --git a/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx b/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx index 628cbf0b..c1b60b08 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx @@ -1,12 +1,12 @@ -import { Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { HotkeyManagerSettings } from '/@/renderer/features/settings/components/hotkeys/hotkey-manager-settings'; import { WindowHotkeySettings } from '/@/renderer/features/settings/components/hotkeys/window-hotkey-settings'; +import { Stack } from '/@/shared/components/stack/stack'; export const HotkeysTab = () => { return ( - <Stack spacing="md"> + <Stack gap="md"> {isElectron() && <WindowHotkeySettings />} <HotkeyManagerSettings /> </Stack> diff --git a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx index 7fb65549..2a307232 100644 --- a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx @@ -1,12 +1,12 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Switch } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store'; +import { Switch } from '/@/shared/components/switch/switch'; const localSettings = isElectron() ? window.api.localSettings : null; diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index bd637db6..03a20d19 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -1,9 +1,7 @@ -import { SelectItem } from '@mantine/core'; import isElectron from 'is-electron'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Select, Slider, Switch, toast } from '/@/renderer/components'; import { SettingOption, SettingsSection, @@ -11,6 +9,10 @@ import { import { useCurrentStatus, usePlayerStore } from '/@/renderer/store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data'; +import { Select } from '/@/shared/components/select/select'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Switch } from '/@/shared/components/switch/switch'; +import { toast } from '/@/shared/components/toast/toast'; import { CrossfadeStyle, PlaybackStyle, PlaybackType, PlayerStatus } from '/@/shared/types/types'; const getAudioDevice = async () => { @@ -24,7 +26,7 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) => const { setSettings } = useSettingsStoreActions(); const status = useCurrentStatus(); - const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]); + const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]); useEffect(() => { const getAudioDevices = () => { diff --git a/src/renderer/features/settings/components/playback/lyric-settings.tsx b/src/renderer/features/settings/components/playback/lyric-settings.tsx index 9d7f94ba..0e0caf56 100644 --- a/src/renderer/features/settings/components/playback/lyric-settings.tsx +++ b/src/renderer/features/settings/components/playback/lyric-settings.tsx @@ -1,31 +1,21 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; import { languages } from '/@/i18n/i18n'; -import { - MultiSelect, - MultiSelectProps, - NumberInput, - Select, - Switch, - TextInput, -} from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store'; +import { MultiSelect } from '/@/shared/components/multi-select/multi-select'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; import { LyricSource } from '/@/shared/types/domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; -const WorkingButtonSelect = styled(MultiSelect)<MultiSelectProps>` - & button { - padding: 0; - } -`; - export const LyricSettings = () => { const { t } = useTranslation(); const settings = useLyricsSettings(); @@ -77,17 +67,17 @@ export const LyricSettings = () => { }, { control: ( - <WorkingButtonSelect + <MultiSelect aria-label="Lyric providers" clearable data={Object.values(LyricSource)} defaultValue={settings.sources} - onChange={(e: LyricSource[]) => { + onChange={(e: string[]) => { localSettings?.set('lyrics', e); setSettings({ lyrics: { ...settings, - sources: e, + sources: e.map((source) => source as LyricSource), }, }); }} diff --git a/src/renderer/features/settings/components/playback/mpv-settings.tsx b/src/renderer/features/settings/components/playback/mpv-settings.tsx index 19130a23..55f518cb 100644 --- a/src/renderer/features/settings/components/playback/mpv-settings.tsx +++ b/src/renderer/features/settings/components/playback/mpv-settings.tsx @@ -1,18 +1,7 @@ -import { Group, Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiCloseLine, RiRestartLine } from 'react-icons/ri'; -import { - Button, - NumberInput, - Select, - Switch, - Text, - Textarea, - TextInput, -} from '/@/renderer/components'; import { SettingOption, SettingsSection, @@ -24,6 +13,15 @@ import { useSettingsStore, useSettingsStoreActions, } from '/@/renderer/store/settings.store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; +import { Textarea } from '/@/shared/components/textarea/textarea'; import { PlaybackType } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -79,7 +77,9 @@ export const MpvSettings = () => { const { pause } = usePlayerControls(); const { clearQueue } = useQueueControls(); - const [mpvPath, setMpvPath] = useState(''); + const [mpvPath, setMpvPath] = useState( + (localSettings?.get('mpv_path') as string | undefined) || '', + ); const handleSetMpvPath = async (clear?: boolean) => { if (clear) { @@ -157,35 +157,34 @@ export const MpvSettings = () => { const options: SettingOption[] = [ { control: ( - <Group spacing="sm"> - <Button + <Group gap="sm"> + <ActionIcon + icon="refresh" onClick={handleReloadMpv} tooltip={{ label: t('common.reload', { postProcess: 'titleCase' }), openDelay: 0, }} variant="subtle" - > - <RiRestartLine /> - </Button> + /> <TextInput + onChange={(e) => { + setMpvPath(e.currentTarget.value); + + // Transform backslashes to forward slashes + const transformedValue = e.currentTarget.value.replace(/\\/g, '/'); + localSettings?.set('mpv_path', transformedValue); + }} onClick={() => handleSetMpvPath()} rightSection={ mpvPath && ( - <Button - compact + <ActionIcon + icon="x" onClick={() => handleSetMpvPath(true)} - tooltip={{ - label: t('common.clear', { postProcess: 'titleCase' }), - openDelay: 0, - }} - variant="subtle" - > - <RiCloseLine /> - </Button> + variant="transparent" + /> ) } - type="button" value={mpvPath} width={200} /> @@ -201,7 +200,7 @@ export const MpvSettings = () => { }, { control: ( - <Stack spacing="xs"> + <Stack gap="xs"> <Textarea autosize defaultValue={settings.mpvExtraParameters.join('\n')} @@ -218,10 +217,10 @@ export const MpvSettings = () => { </Stack> ), description: ( - <Stack spacing={0}> + <Stack gap={0}> <Text - $noSelect - $secondary + isMuted + isNoSelect size="sm" > {t('setting.mpvExtraParameters', { @@ -288,7 +287,7 @@ export const MpvSettings = () => { handleSetMpvProperty('audioSampleRateHz', value >= 8000 ? value : value); }} placeholder="48000" - rightSection="Hz" + rightSection={<Text size="xs">Hz</Text>} width={100} /> ), diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index be48a770..37781c5d 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -1,4 +1,3 @@ -import { Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { lazy, Suspense, useMemo } from 'react'; @@ -7,6 +6,7 @@ import { LyricSettings } from '/@/renderer/features/settings/components/playback import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings'; import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings'; import { useSettingsStore } from '/@/renderer/store'; +import { Stack } from '/@/shared/components/stack/stack'; import { PlaybackType } from '/@/shared/types/types'; const MpvSettings = lazy(() => @@ -27,7 +27,7 @@ export const PlaybackTab = () => { }, [audioType, useWebAudio]); return ( - <Stack spacing="md"> + <Stack gap="md"> <AudioSettings hasFancyAudio={hasFancyAudio} /> <Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense> <TranscodeSettings /> diff --git a/src/renderer/features/settings/components/playback/scrobble-settings.tsx b/src/renderer/features/settings/components/playback/scrobble-settings.tsx index eb9eab53..e8659155 100644 --- a/src/renderer/features/settings/components/playback/scrobble-settings.tsx +++ b/src/renderer/features/settings/components/playback/scrobble-settings.tsx @@ -1,11 +1,13 @@ import { useTranslation } from 'react-i18next'; -import { NumberInput, Slider, Switch } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Switch } from '/@/shared/components/switch/switch'; export const ScrobbleSettings = () => { const { t } = useTranslation(); @@ -79,7 +81,7 @@ export const ScrobbleSettings = () => { ...settings, scrobble: { ...settings.scrobble, - scrobbleAtDuration: e, + scrobbleAtDuration: Number(e), }, }, }); diff --git a/src/renderer/features/settings/components/playback/transcode-settings.tsx b/src/renderer/features/settings/components/playback/transcode-settings.tsx index 160941f6..bc8a8843 100644 --- a/src/renderer/features/settings/components/playback/transcode-settings.tsx +++ b/src/renderer/features/settings/components/playback/transcode-settings.tsx @@ -1,11 +1,13 @@ import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, TextInput } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; export const TranscodeSettings = () => { const { t } = useTranslation(); diff --git a/src/renderer/features/settings/components/settings-content.tsx b/src/renderer/features/settings/components/settings-content.tsx index 3e6372fb..a5bc6474 100644 --- a/src/renderer/features/settings/components/settings-content.tsx +++ b/src/renderer/features/settings/components/settings-content.tsx @@ -1,10 +1,9 @@ import isElectron from 'is-electron'; import { lazy } from 'react'; import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; -import { Tabs } from '/@/renderer/components'; import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { Tabs } from '/@/shared/components/tabs/tabs'; const GeneralTab = lazy(() => import('/@/renderer/features/settings/components/general/general-tab').then((module) => ({ @@ -36,23 +35,16 @@ const AdvancedTab = lazy(() => })), ); -const TabContainer = styled.div` - width: 100%; - height: 100%; - padding: 1rem; - overflow: scroll; -`; - export const SettingsContent = () => { const { t } = useTranslation(); const currentTab = useSettingsStore((state) => state.tab); const { setSettings } = useSettingsStoreActions(); return ( - <TabContainer> + <div style={{ height: '100%', overflow: 'scroll', padding: '1rem', width: '100%' }}> <Tabs keepMounted={false} - onTabChange={(e) => e && setSettings({ tab: e })} + onChange={(e) => e && setSettings({ tab: e })} orientation="horizontal" value={currentTab} variant="default" @@ -94,6 +86,6 @@ export const SettingsContent = () => { <AdvancedTab /> </Tabs.Panel> </Tabs> - </TabContainer> + </div> ); }; diff --git a/src/renderer/features/settings/components/settings-header.tsx b/src/renderer/features/settings/components/settings-header.tsx index f98b0a38..4ca8c1d5 100644 --- a/src/renderer/features/settings/components/settings-header.tsx +++ b/src/renderer/features/settings/components/settings-header.tsx @@ -1,13 +1,18 @@ -import { Flex, Group } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; -import { RiSettings2Fill } from 'react-icons/ri'; -import { Button, ConfirmModal, PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context'; import { LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { useContainerQuery } from '/@/renderer/hooks'; import { useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { Button } from '/@/shared/components/button/button'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { Text } from '/@/shared/components/text/text'; export type SettingsHeaderProps = { setSearch: (search: string) => void; @@ -28,7 +33,7 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => { openModal({ children: ( <ConfirmModal onConfirm={handleResetToDefault}> - {t('common.areYouSure', { postProcess: 'sentenceCase' })} + <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text> </ConfirmModal> ), title: t('common.resetToDefault', { postProcess: 'sentenceCase' }), @@ -44,8 +49,11 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => { justify="space-between" w="100%" > - <Group noWrap> - <RiSettings2Fill size="2rem" /> + <Group wrap="nowrap"> + <Icon + icon="settings" + size="5xl" + /> <LibraryHeaderBar.Title> {t('common.setting', { count: 2, postProcess: 'titleCase' })} </LibraryHeaderBar.Title> @@ -56,10 +64,8 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => { onChange={(event) => setSearch(event.target.value.toLocaleLowerCase()) } - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> <Button - compact onClick={openResetConfirmModal} variant="default" > diff --git a/src/renderer/features/settings/components/settings-option.tsx b/src/renderer/features/settings/components/settings-option.tsx index d36f75cc..545b2881 100644 --- a/src/renderer/features/settings/components/settings-option.tsx +++ b/src/renderer/features/settings/components/settings-option.tsx @@ -1,8 +1,10 @@ -import { Group, Stack } from '@mantine/core'; import React from 'react'; -import { RiInformationLine } from 'react-icons/ri'; -import { Text, Tooltip } from '/@/renderer/components'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; interface SettingsOptionProps { control: React.ReactNode; @@ -15,13 +17,13 @@ export const SettingsOptions = ({ control, description, note, title }: SettingsO return ( <> <Group - noWrap - position="apart" - sx={{ alignItems: 'center' }} + justify="space-between" + style={{ alignItems: 'center' }} + wrap="nowrap" > <Stack - spacing="xs" - sx={{ + gap="xs" + style={{ alignSelf: 'flex-start', display: 'flex', maxWidth: '50%', @@ -29,7 +31,7 @@ export const SettingsOptions = ({ control, description, note, title }: SettingsO > <Group> <Text - $noSelect + isNoSelect size="md" > {title} @@ -39,9 +41,7 @@ export const SettingsOptions = ({ control, description, note, title }: SettingsO label={note} openDelay={0} > - <Group> - <RiInformationLine size={15} /> - </Group> + <Icon icon="info" /> </Tooltip> )} </Group> @@ -49,15 +49,15 @@ export const SettingsOptions = ({ control, description, note, title }: SettingsO description ) : ( <Text - $noSelect - $secondary + isMuted + isNoSelect size="sm" > {description} </Text> )} </Stack> - <Group position="right">{control}</Group> + <Group justify="flex-end">{control}</Group> </Group> </> ); diff --git a/src/renderer/features/settings/components/settings-section.tsx b/src/renderer/features/settings/components/settings-section.tsx index 05eba72c..32441a58 100644 --- a/src/renderer/features/settings/components/settings-section.tsx +++ b/src/renderer/features/settings/components/settings-section.tsx @@ -1,8 +1,8 @@ -import { Divider } from '@mantine/core'; import { ReactNode } from 'react'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context'; +import { Divider } from '/@/shared/components/divider/divider'; export type SettingOption = { control: ReactNode; diff --git a/src/renderer/features/settings/components/window/cache-settngs.tsx b/src/renderer/features/settings/components/window/cache-settngs.tsx index e216a349..a32138da 100644 --- a/src/renderer/features/settings/components/window/cache-settngs.tsx +++ b/src/renderer/features/settings/components/window/cache-settngs.tsx @@ -4,11 +4,13 @@ import isElectron from 'is-electron'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, ConfirmModal, toast } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; +import { Button } from '/@/shared/components/button/button'; +import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { toast } from '/@/shared/components/toast/toast'; const browser = isElectron() ? window.api.browser : null; @@ -58,9 +60,9 @@ export const CacheSettings = () => { { control: ( <Button - compact disabled={isClearing} onClick={() => openResetConfirmModal(false)} + size="compact-md" variant="filled" > {t('common.clear', { postProcess: 'sentenceCase' })} @@ -75,9 +77,9 @@ export const CacheSettings = () => { { control: ( <Button - compact disabled={isClearing} onClick={() => openResetConfirmModal(true)} + size="compact-md" variant="filled" > {t('common.clear', { postProcess: 'sentenceCase' })} diff --git a/src/renderer/features/settings/components/window/discord-settings.tsx b/src/renderer/features/settings/components/window/discord-settings.tsx index 0acb2e15..5ad4887f 100644 --- a/src/renderer/features/settings/components/window/discord-settings.tsx +++ b/src/renderer/features/settings/components/window/discord-settings.tsx @@ -1,7 +1,6 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Switch, TextInput } from '/@/renderer/components'; import { SettingOption, SettingsSection, @@ -11,6 +10,8 @@ import { useGeneralSettings, useSettingsStoreActions, } from '/@/renderer/store'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; export const DiscordSettings = () => { const { t } = useTranslation(); diff --git a/src/renderer/features/settings/components/window/password-settings.tsx b/src/renderer/features/settings/components/window/password-settings.tsx index c517fb84..cfe76611 100644 --- a/src/renderer/features/settings/components/window/password-settings.tsx +++ b/src/renderer/features/settings/components/window/password-settings.tsx @@ -1,17 +1,16 @@ -import { SelectItem } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Select } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store'; +import { Select } from '/@/shared/components/select/select'; const localSettings = isElectron() ? window.api.localSettings : null; -const PASSWORD_SETTINGS: SelectItem[] = [ +const PASSWORD_SETTINGS: { label: string; value: string }[] = [ { label: 'libsecret', value: 'gnome_libsecret' }, { label: 'KDE 4 (kwallet4)', value: 'kwallet' }, { label: 'KDE 5 (kwallet5)', value: 'kwallet5' }, diff --git a/src/renderer/features/settings/components/window/update-settings.tsx b/src/renderer/features/settings/components/window/update-settings.tsx index 302ff774..f090a570 100644 --- a/src/renderer/features/settings/components/window/update-settings.tsx +++ b/src/renderer/features/settings/components/window/update-settings.tsx @@ -1,12 +1,12 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Switch } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useSettingsStoreActions, useWindowSettings } from '/@/renderer/store'; +import { Switch } from '/@/shared/components/switch/switch'; const localSettings = isElectron() ? window.api.localSettings : null; const utils = isElectron() ? window.api.utils : null; diff --git a/src/renderer/features/settings/components/window/window-settings.tsx b/src/renderer/features/settings/components/window/window-settings.tsx index 5d0cd67c..44fcf862 100644 --- a/src/renderer/features/settings/components/window/window-settings.tsx +++ b/src/renderer/features/settings/components/window/window-settings.tsx @@ -1,12 +1,14 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { Select, Switch, toast } from '/@/renderer/components'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; import { useSettingsStoreActions, useWindowSettings } from '/@/renderer/store'; +import { Select } from '/@/shared/components/select/select'; +import { Switch } from '/@/shared/components/switch/switch'; +import { toast } from '/@/shared/components/toast/toast'; import { Platform } from '/@/shared/types/types'; const WINDOW_BAR_OPTIONS = [ diff --git a/src/renderer/features/settings/components/window/window-tab.tsx b/src/renderer/features/settings/components/window/window-tab.tsx index 3e280385..415bb9ca 100644 --- a/src/renderer/features/settings/components/window/window-tab.tsx +++ b/src/renderer/features/settings/components/window/window-tab.tsx @@ -1,16 +1,16 @@ -import { Stack } from '@mantine/core'; import isElectron from 'is-electron'; import { DiscordSettings } from '/@/renderer/features/settings/components/window/discord-settings'; import { PasswordSettings } from '/@/renderer/features/settings/components/window/password-settings'; import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings'; import { WindowSettings } from '/@/renderer/features/settings/components/window/window-settings'; +import { Stack } from '/@/shared/components/stack/stack'; const utils = isElectron() ? window.api.utils : null; export const WindowTab = () => { return ( - <Stack spacing="md"> + <Stack gap="md"> <WindowSettings /> <DiscordSettings /> <UpdateSettings /> diff --git a/src/renderer/features/settings/routes/settings-route.tsx b/src/renderer/features/settings/routes/settings-route.tsx index a3c41a61..3d25f6d7 100644 --- a/src/renderer/features/settings/routes/settings-route.tsx +++ b/src/renderer/features/settings/routes/settings-route.tsx @@ -1,10 +1,10 @@ -import { Flex } from '@mantine/core'; import { useState } from 'react'; import { SettingsContent } from '/@/renderer/features/settings/components/settings-content'; import { SettingsHeader } from '/@/renderer/features/settings/components/settings-header'; import { SettingSearchContext } from '/@/renderer/features/settings/context/search-context'; import { AnimatedPage } from '/@/renderer/features/shared'; +import { Flex } from '/@/shared/components/flex/flex'; const SettingsRoute = () => { const [search, setSearch] = useState(''); diff --git a/src/renderer/features/shared/components/animated-page.module.scss b/src/renderer/features/shared/components/animated-page.module.css similarity index 100% rename from src/renderer/features/shared/components/animated-page.module.scss rename to src/renderer/features/shared/components/animated-page.module.css index 11bdf176..307077a2 100644 --- a/src/renderer/features/shared/components/animated-page.module.scss +++ b/src/renderer/features/shared/components/animated-page.module.css @@ -1,9 +1,9 @@ .animated-page { - container-type: inline-size; position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; + container-type: inline-size; overflow: hidden; } diff --git a/src/renderer/features/shared/components/animated-page.tsx b/src/renderer/features/shared/components/animated-page.tsx index f5ab231a..20dc87fc 100644 --- a/src/renderer/features/shared/components/animated-page.tsx +++ b/src/renderer/features/shared/components/animated-page.tsx @@ -1,9 +1,9 @@ import type { ReactNode, Ref } from 'react'; -import { motion } from 'framer-motion'; +import { motion } from 'motion/react'; import { forwardRef } from 'react'; -import styles from './animated-page.module.scss'; +import styles from './animated-page.module.css'; interface AnimatedPageProps { children: ReactNode; diff --git a/src/renderer/features/shared/components/filter-bar.module.css b/src/renderer/features/shared/components/filter-bar.module.css new file mode 100644 index 00000000..a4e184b8 --- /dev/null +++ b/src/renderer/features/shared/components/filter-bar.module.css @@ -0,0 +1,5 @@ +.filter-bar { + z-index: 1; + padding: var(--theme-spacing-md) var(--theme-spacing-sm); + box-shadow: 0 5px 15px rgb(0 0 0 / 65%); +} diff --git a/src/renderer/features/shared/components/filter-bar.tsx b/src/renderer/features/shared/components/filter-bar.tsx index cd8e266d..d87c0a9d 100644 --- a/src/renderer/features/shared/components/filter-bar.tsx +++ b/src/renderer/features/shared/components/filter-bar.tsx @@ -1,14 +1,12 @@ -import { PaperProps } from '@mantine/core'; -import styled from 'styled-components'; +import styles from './filter-bar.module.css'; -import { Paper } from '/@/renderer/components'; - -const StyledFilterBar = styled(Paper)` - z-index: 1; - padding: 1rem; - box-shadow: 0 5px 15px rgb(0 0 0 / 65%); -`; - -export const FilterBar = ({ children, ...props }: PaperProps) => { - return <StyledFilterBar {...props}>{children}</StyledFilterBar>; +export const FilterBar = ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => { + return ( + <div + className={styles.filterBar} + {...props} + > + {children} + </div> + ); }; diff --git a/src/renderer/features/shared/components/filter-button.tsx b/src/renderer/features/shared/components/filter-button.tsx new file mode 100644 index 00000000..82023c28 --- /dev/null +++ b/src/renderer/features/shared/components/filter-button.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next'; + +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; + +interface FilterButtonProps extends ActionIconProps { + isActive?: boolean; +} + +export const FilterButton = ({ isActive, onClick, ...props }: FilterButtonProps) => { + const { t } = useTranslation(); + + return ( + <ActionIcon + icon="filter" + iconProps={{ + fill: isActive ? 'primary' : undefined, + size: 'lg', + ...props.iconProps, + }} + onClick={onClick} + tooltip={{ + label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }), + ...props.tooltip, + }} + variant="subtle" + {...props} + /> + ); +}; diff --git a/src/renderer/features/shared/components/folder-button.tsx b/src/renderer/features/shared/components/folder-button.tsx new file mode 100644 index 00000000..331f1097 --- /dev/null +++ b/src/renderer/features/shared/components/folder-button.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; + +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; + +interface FolderButtonProps extends ActionIconProps { + isActive?: boolean; +} + +export const FolderButton = ({ isActive, ...props }: FolderButtonProps) => { + const { t } = useTranslation(); + + return ( + <ActionIcon + icon="folder" + iconProps={{ + fill: isActive ? 'primary' : undefined, + size: 'lg', + ...props.iconProps, + }} + tooltip={{ + label: t('entity.folder', { postProcess: 'sentenceCase' }), + ...props.tooltip, + }} + variant="subtle" + {...props} + /> + ); +}; diff --git a/src/renderer/features/shared/components/item-image-placeholder.module.css b/src/renderer/features/shared/components/item-image-placeholder.module.css deleted file mode 100644 index afca7b67..00000000 --- a/src/renderer/features/shared/components/item-image-placeholder.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.image-placeholder { - width: 100%; - height: 100%; - background-color: var(--placeholder-bg); - border-radius: var(--card-default-radius); - - svg { - width: 35px; - height: 35px; - color: var(--placeholder-fg); - } -} diff --git a/src/renderer/features/shared/components/item-image-placeholder.tsx b/src/renderer/features/shared/components/item-image-placeholder.tsx deleted file mode 100644 index d64c2e46..00000000 --- a/src/renderer/features/shared/components/item-image-placeholder.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Center } from '@mantine/core'; -import clsx from 'clsx'; -import { memo } from 'react'; -import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri'; - -import styles from './item-image-placeholder.module.css'; - -import { LibraryItem } from '/@/shared/types/domain-types'; - -interface ItemImagePlaceholderProps { - itemType?: LibraryItem; -} - -const Image = memo(function Image(props: ItemImagePlaceholderProps) { - switch (props.itemType) { - case LibraryItem.ALBUM: - return <RiAlbumFill />; - case LibraryItem.ALBUM_ARTIST: - return <RiUserVoiceFill />; - case LibraryItem.ARTIST: - return <RiUserVoiceFill />; - case LibraryItem.PLAYLIST: - return <RiPlayListFill />; - default: - return <RiAlbumFill />; - } -}); - -export const ItemImagePlaceholder = ({ itemType }: ItemImagePlaceholderProps) => { - return ( - <Center className={clsx(styles.imagePlaceholder, 'item-image-placeholder')}> - <Image itemType={itemType} /> - </Center> - ); -}; diff --git a/src/renderer/features/shared/components/library-background-overlay.module.css b/src/renderer/features/shared/components/library-background-overlay.module.css new file mode 100644 index 00000000..d76f0133 --- /dev/null +++ b/src/renderer/features/shared/components/library-background-overlay.module.css @@ -0,0 +1,11 @@ +.root { + position: absolute; + z-index: -1; + width: 100%; + height: 20vh; + min-height: 200px; + pointer-events: none; + user-select: none; + background-image: var(--theme-overlay-subheader); + opacity: 0.3; +} diff --git a/src/renderer/features/shared/components/library-background-overlay.tsx b/src/renderer/features/shared/components/library-background-overlay.tsx index c734e473..66246720 100644 --- a/src/renderer/features/shared/components/library-background-overlay.tsx +++ b/src/renderer/features/shared/components/library-background-overlay.tsx @@ -1,14 +1,14 @@ -import styled from 'styled-components'; +import styles from './library-background-overlay.module.css'; -export const LibraryBackgroundOverlay = styled.div<{ $backgroundColor?: string }>` - position: absolute; - z-index: -1; - width: 100%; - height: 20vh; - min-height: 200px; - pointer-events: none; - user-select: none; - background: ${(props) => props.$backgroundColor}; - background-image: var(--bg-subheader-overlay); - opacity: 0.3; -`; +interface LibraryBackgroundOverlayProps { + backgroundColor?: string; +} + +export const LibraryBackgroundOverlay = ({ backgroundColor }: LibraryBackgroundOverlayProps) => { + return ( + <div + className={styles.root} + style={{ backgroundColor }} + /> + ); +}; diff --git a/src/renderer/features/shared/components/library-header-bar.module.css b/src/renderer/features/shared/components/library-header-bar.module.css new file mode 100644 index 00000000..3f4c2a7b --- /dev/null +++ b/src/renderer/features/shared/components/library-header-bar.module.css @@ -0,0 +1,15 @@ +.header-container { + display: flex; + flex-wrap: nowrap; + gap: 1rem; + align-items: center; + width: 100%; + height: 100%; + padding: 0 1rem; +} + +.play-button-container { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/renderer/features/shared/components/library-header-bar.tsx b/src/renderer/features/shared/components/library-header-bar.tsx index b746b8b9..11074723 100644 --- a/src/renderer/features/shared/components/library-header-bar.tsx +++ b/src/renderer/features/shared/components/library-header-bar.tsx @@ -1,67 +1,48 @@ -import { Box } from '@mantine/core'; import { ReactNode } from 'react'; -import styled from 'styled-components'; -import { Paper, PaperProps, SpinnerIcon, TextTitle } from '/@/renderer/components'; -import { PlayButton as PlayBtn } from '/@/renderer/features/shared/components/play-button'; +import styles from './library-header-bar.module.css'; + +import { PlayButton, PlayButtonProps } from '/@/renderer/features/shared/components/play-button'; +import { Badge, BadgeProps } from '/@/shared/components/badge/badge'; +import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; interface LibraryHeaderBarProps { children: ReactNode; } -const HeaderContainer = styled.div` - display: flex; - flex-wrap: nowrap; - gap: 1rem; - align-items: center; - width: 100%; - height: 100%; - padding: 0 1rem; -`; - export const LibraryHeaderBar = ({ children }: LibraryHeaderBarProps) => { - return <HeaderContainer>{children}</HeaderContainer>; + return <div className={styles.headerContainer}>{children}</div>; }; interface TitleProps { children: ReactNode; } +const HeaderPlayButton = ({ className, ...props }: PlayButtonProps) => { + return ( + <div className={styles.playButtonContainer}> + <PlayButton + className={className} + {...props} + /> + </div> + ); +}; + const Title = ({ children }: TitleProps) => { return ( <TextTitle + fw={700} order={1} overflow="hidden" - weight={700} > {children} </TextTitle> ); }; -interface PlayButtonProps { - onClick: (args: any) => void; -} - -const PlayButton = ({ onClick }: PlayButtonProps) => { - return ( - <Box> - <PlayBtn - h="45px" - onClick={onClick} - w="45px" - /> - </Box> - ); -}; - -const Badge = styled(Paper)` - padding: 0.3rem 1rem; - font-weight: 600; - border-radius: 0.3rem; -`; - -interface HeaderBadgeProps extends PaperProps { +interface HeaderBadgeProps extends BadgeProps { isLoading?: boolean; } @@ -70,5 +51,5 @@ const HeaderBadge = ({ children, isLoading, ...props }: HeaderBadgeProps) => { }; LibraryHeaderBar.Title = Title; -LibraryHeaderBar.PlayButton = PlayButton; +LibraryHeaderBar.PlayButton = HeaderPlayButton; LibraryHeaderBar.Badge = HeaderBadge; diff --git a/src/renderer/features/shared/components/library-header.module.css b/src/renderer/features/shared/components/library-header.module.css index 47d466f5..2840d58f 100644 --- a/src/renderer/features/shared/components/library-header.module.css +++ b/src/renderer/features/shared/components/library-header.module.css @@ -5,6 +5,7 @@ grid-template-rows: 100%; grid-template-columns: 175px minmax(0, 1fr); gap: 1rem; + align-items: flex-end; width: 100%; max-width: 100%; height: 30vh; @@ -79,7 +80,8 @@ @container (min-width: 1200px) { grid-template-columns: 250px minmax(0, 1fr); - .image { + .image, + .image-section { width: 250px !important; height: 250px; } @@ -98,12 +100,11 @@ align-items: flex-end; justify-content: center; height: 100%; - filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%)); + filter: drop-shadow(0 0 8px rgb(0 0 0 / 50%)); } .metadata-section { z-index: 15; - font-size: 1.15rem; display: flex; flex-direction: column; grid-area: info; @@ -112,7 +113,7 @@ } .image { - object-fit: var(--image-fit); + object-fit: var(--theme-image-fit); border-radius: 5px; } @@ -122,9 +123,9 @@ z-index: 0; width: 100%; height: 100%; - opacity: 0.9; - background-size: cover !important; background-position: center !important; + background-size: cover !important; + opacity: 0.9; } .background-overlay { @@ -134,7 +135,7 @@ z-index: 0; width: 100%; height: 100%; - background: var(--bg-header-overlay); + background: var(--theme-overlay-header); } .opaque-overlay { @@ -142,12 +143,14 @@ } .title { - display: -webkit-box; + display: flex; + align-items: center !important; + margin: var(--theme-spacing-sm) 0; overflow: hidden; - color: var(--main-fg); + -webkit-line-clamp: 2; + line-clamp: 2; font-size: 2rem; line-height: 1.2; - line-clamp: 2; - -webkit-line-clamp: 2; + color: var(--theme-colors-foreground); -webkit-box-orient: vertical; } diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index 132191e6..21c62b64 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -1,4 +1,3 @@ -import { Center, Group } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { AutoTextSize } from 'auto-text-size'; import clsx from 'clsx'; @@ -8,9 +7,10 @@ import { Link } from 'react-router-dom'; import styles from './library-header.module.css'; -import { Text } from '/@/renderer/components'; -import { ItemImagePlaceholder } from '/@/renderer/features/shared/components/item-image-placeholder'; import { useGeneralSettings } from '/@/renderer/store'; +import { Center } from '/@/shared/components/center/center'; +import { Image } from '/@/shared/components/image/image'; +import { Text } from '/@/shared/components/text/text'; import { LibraryItem } from '/@/shared/types/domain-types'; interface LibraryHeaderProps { @@ -107,37 +107,34 @@ export const LibraryHeader = forwardRef( style={{ cursor: 'pointer' }} tabIndex={0} > - {!loading && - (imageUrl && !isImageError ? ( - <img - alt="cover" - className={styles.image} - onError={onImageError} - // placeholder={imagePlaceholderUrl || 'var(--placeholder-bg)'} - src={imageUrl} - style={{ height: '' }} - /> - ) : ( - <ItemImagePlaceholder itemType={item.type} /> - ))} + {!loading && imageUrl && !isImageError && ( + <Image + alt="cover" + className={styles.image} + onError={onImageError} + src={imageUrl || ''} + /> + )} </div> {title && ( <div className={styles.metadataSection}> - <Group> - <h2> - <Text - $link - component={Link} - to={item.route} - tt="uppercase" - weight={600} - > - {itemTypeString()} - </Text> - </h2> - </Group> + <Text + component={Link} + fw={600} + isLink + size="md" + to={item.route} + tt="uppercase" + > + {itemTypeString()} + </Text> <h1 className={styles.title}> - <AutoTextSize mode="box">{title}</AutoTextSize> + <AutoTextSize + maxFontSizePx={80} + mode="box" + > + {title} + </AutoTextSize> </h1> {children} </div> diff --git a/src/renderer/features/shared/components/list-config-menu.tsx b/src/renderer/features/shared/components/list-config-menu.tsx new file mode 100644 index 00000000..7e448abc --- /dev/null +++ b/src/renderer/features/shared/components/list-config-menu.tsx @@ -0,0 +1,271 @@ +import { useTranslation } from 'react-i18next'; + +import i18n from '/@/i18n/i18n'; +import { SettingsButton } from '/@/renderer/features/shared/components/settings-button'; +import { CheckboxSelect } from '/@/shared/components/checkbox-select/checkbox-select'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Popover } from '/@/shared/components/popover/popover'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Table } from '/@/shared/components/table/table'; +import { ListDisplayType } from '/@/shared/types/types'; + +const DISPLAY_TYPES = [ + { + label: ( + <Stack + align="center" + p="sm" + > + <Icon + icon="layoutTable" + size="lg" + /> + {i18n.t('table.config.view.table', { postProcess: 'sentenceCase' }) as string} + </Stack> + ), + value: ListDisplayType.TABLE, + }, + { + label: ( + <Stack + align="center" + p="sm" + > + <Icon + icon="layoutGrid" + size="lg" + /> + {i18n.t('table.config.view.card', { postProcess: 'sentenceCase' }) as string} + </Stack> + ), + value: ListDisplayType.GRID, + }, + { + disabled: true, + label: ( + <Stack + align="center" + p="sm" + > + <Icon + icon="layoutList" + size="lg" + /> + {i18n.t('table.config.view.list', { postProcess: 'sentenceCase' }) as string} + </Stack> + ), + value: ListDisplayType.LIST, + }, +]; + +interface ListConfigMenuProps { + autoFitColumns?: boolean; + disabledViewTypes?: ListDisplayType[]; + displayType: ListDisplayType; + itemGap?: number; + itemSize?: number; + onChangeAutoFitColumns?: (autoFitColumns: boolean) => void; + onChangeDisplayType?: (displayType: ListDisplayType) => void; + onChangeItemGap?: (itemGap: number) => void; + onChangeItemSize?: (itemSize: number) => void; + onChangeTableColumns?: (tableColumns: string[]) => void; + tableColumns?: string[]; + tableColumnsData?: { label: string; value: string }[]; +} + +export const ListConfigMenu = (props: ListConfigMenuProps) => { + return ( + <Popover + position="bottom-end" + width={300} + > + <Popover.Target> + <SettingsButton /> + </Popover.Target> + <Popover.Dropdown p="md"> + <Stack> + <SegmentedControl + data={DISPLAY_TYPES.map((type) => ({ + ...type, + disabled: props.disabledViewTypes?.includes(type.value), + }))} + onChange={(value) => props.onChangeDisplayType?.(value as ListDisplayType)} + value={props.displayType} + w="100%" + withItemsBorders={false} + /> + <Config {...props} /> + </Stack> + </Popover.Dropdown> + </Popover> + ); +}; + +const Config = (props: ListConfigMenuProps) => { + switch (props.displayType) { + case ListDisplayType.GRID: + return <GridConfig {...props} />; + + case ListDisplayType.TABLE: + return <TableConfig {...props} />; + + default: + return null; + } +}; + +type TableConfigProps = Pick< + ListConfigMenuProps, + | 'autoFitColumns' + | 'itemSize' + | 'onChangeAutoFitColumns' + | 'onChangeItemSize' + | 'onChangeTableColumns' + | 'tableColumns' + | 'tableColumnsData' +>; + +const TableConfig = ({ + autoFitColumns, + itemSize, + onChangeAutoFitColumns, + onChangeItemSize, + onChangeTableColumns, + tableColumns, + tableColumnsData, +}: TableConfigProps) => { + const { t } = useTranslation(); + + if ( + !tableColumnsData || + !onChangeTableColumns || + !tableColumns || + !onChangeItemSize || + autoFitColumns === undefined || + !onChangeAutoFitColumns || + itemSize === undefined + ) { + console.error('TableConfig: Missing required props', { + itemSize, + onChangeItemSize, + onChangeTableColumns, + tableColumns, + tableColumnsData, + }); + return null; + } + + return ( + <> + <Table + variant="vertical" + withColumnBorders + withRowBorders + withTableBorder + > + <Table.Tbody> + <Table.Tr> + <Table.Th> + {t('table.config.general.size', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td> + <Slider + defaultValue={itemSize} + max={100} + min={30} + onChangeEnd={onChangeItemSize} + /> + </Table.Td> + </Table.Tr> + <Table.Tr> + <Table.Th w="50%"> + {t('table.config.general.autoFitColumns', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td style={{ display: 'flex', justifyContent: 'flex-end' }}> + <Switch + defaultChecked={autoFitColumns} + onChange={(e) => onChangeAutoFitColumns?.(e.target.checked)} + size="xs" + /> + </Table.Td> + </Table.Tr> + </Table.Tbody> + </Table> + <ScrollArea + allowDragScroll + style={{ maxHeight: '200px' }} + > + <CheckboxSelect + data={tableColumnsData} + onChange={onChangeTableColumns} + value={tableColumns} + /> + </ScrollArea> + </> + ); +}; + +type GridConfigProps = Pick< + ListConfigMenuProps, + 'itemGap' | 'itemSize' | 'onChangeItemGap' | 'onChangeItemSize' +>; + +const GridConfig = ({ itemSize, onChangeItemGap, onChangeItemSize }: GridConfigProps) => { + const { t } = useTranslation(); + + if (!onChangeItemGap || !onChangeItemSize || !itemSize) { + return null; + } + + return ( + <> + <Table + variant="vertical" + withColumnBorders + withRowBorders + withTableBorder + > + <Table.Tbody> + <Table.Tr> + <Table.Th w="50%"> + {t('table.config.general.gap', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td> + <Slider + defaultValue={itemSize} + max={30} + min={0} + onChangeEnd={onChangeItemGap} + /> + </Table.Td> + </Table.Tr> + <Table.Tr> + <Table.Th w="50%"> + {t('table.config.general.size', { + postProcess: 'sentenceCase', + })} + </Table.Th> + <Table.Td> + <Slider + defaultValue={itemSize} + max={300} + min={135} + onChangeEnd={onChangeItemSize} + /> + </Table.Td> + </Table.Tr> + </Table.Tbody> + </Table> + </> + ); +}; diff --git a/src/renderer/features/shared/components/more-button.tsx b/src/renderer/features/shared/components/more-button.tsx new file mode 100644 index 00000000..6441d154 --- /dev/null +++ b/src/renderer/features/shared/components/more-button.tsx @@ -0,0 +1,17 @@ +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; + +interface MoreButtonProps extends ActionIconProps {} + +export const MoreButton = ({ ...props }: MoreButtonProps) => { + return ( + <ActionIcon + icon="ellipsisHorizontal" + iconProps={{ + size: 'lg', + ...props.iconProps, + }} + variant="subtle" + {...props} + /> + ); +}; diff --git a/src/renderer/features/shared/components/order-toggle-button.tsx b/src/renderer/features/shared/components/order-toggle-button.tsx index 87a465f4..561e468f 100644 --- a/src/renderer/features/shared/components/order-toggle-button.tsx +++ b/src/renderer/features/shared/components/order-toggle-button.tsx @@ -1,42 +1,32 @@ -import { ButtonProps } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { RiSortAsc, RiSortDesc } from 'react-icons/ri'; -import { Button, Tooltip } from '/@/renderer/components'; +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; import { SortOrder } from '/@/shared/types/domain-types'; interface OrderToggleButtonProps { - buttonProps?: Partial<ButtonProps>; + buttonProps?: Partial<ActionIconProps>; onToggle: () => void; sortOrder: SortOrder; } export const OrderToggleButton = ({ buttonProps, onToggle, sortOrder }: OrderToggleButtonProps) => { const { t } = useTranslation(); + return ( - <Tooltip - label={ - sortOrder === SortOrder.ASC - ? t('common.ascending', { postProcess: 'sentenceCase' }) - : t('common.descending', { postProcess: 'sentenceCase' }) - } - > - <Button - compact - fw="600" - onClick={onToggle} - size="md" - variant="subtle" - {...buttonProps} - > - <> - {sortOrder === SortOrder.ASC ? ( - <RiSortAsc size="1.3rem" /> - ) : ( - <RiSortDesc size="1.3rem" /> - )} - </> - </Button> - </Tooltip> + <ActionIcon + icon={sortOrder === SortOrder.ASC ? 'sortAsc' : 'sortDesc'} + iconProps={{ + size: 'lg', + }} + onClick={onToggle} + tooltip={{ + label: + sortOrder === SortOrder.ASC + ? t('common.ascending', { postProcess: 'sentenceCase' }) + : t('common.descending', { postProcess: 'sentenceCase' }), + }} + variant="subtle" + {...buttonProps} + /> ); }; diff --git a/src/renderer/features/shared/components/play-button.module.css b/src/renderer/features/shared/components/play-button.module.css new file mode 100644 index 00000000..54a4a1cd --- /dev/null +++ b/src/renderer/features/shared/components/play-button.module.css @@ -0,0 +1,16 @@ +.button { + width: 3rem; + height: 3rem; + border-radius: 50%; + opacity: 0.8; + transition: background-color 0.2s ease-in-out; + transition: transform 0.2s ease-in-out; + + &:active { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.6; + } +} diff --git a/src/renderer/features/shared/components/play-button.tsx b/src/renderer/features/shared/components/play-button.tsx index 86c819c1..336c944a 100644 --- a/src/renderer/features/shared/components/play-button.tsx +++ b/src/renderer/features/shared/components/play-button.tsx @@ -1,49 +1,24 @@ -import { UnstyledButton } from '@mantine/core'; -import { RiPlayFill } from 'react-icons/ri'; -import styled from 'styled-components'; +import clsx from 'clsx'; -const MotionButton = styled(UnstyledButton)` - display: flex; - align-items: center; - justify-content: center; - background: var(--btn-filled-bg); - border: none; - border-radius: 50%; - opacity: 0.8; +import styles from './play-button.module.css'; - svg { - fill: var(--btn-filled-fg); - } +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; - &:hover:not([disabled]) { - background: var(--btn-filled-bg); - transform: scale(1.1); +export interface PlayButtonProps extends ActionIconProps { + size?: number | string; +} - svg { - fill: var(--btn-filled-fg-hover); - } - } - - &:active { - transform: scale(0.95); - } - - &:disabled { - opacity: 0.6; - } - - transition: background-color 0.2s ease-in-out; - transition: transform 0.2s ease-in-out; -`; - -export const PlayButton = ({ ...props }: any) => { +export const PlayButton = ({ className, ...props }: PlayButtonProps) => { return ( - <MotionButton + <ActionIcon + className={clsx(styles.button, className)} + icon="mediaPlay" + iconProps={{ + fill: 'default', + size: 'lg', + }} + variant="filled" {...props} - h="45px" - w="45px" - > - <RiPlayFill size={20} /> - </MotionButton> + /> ); }; diff --git a/src/renderer/features/shared/components/refresh-button.tsx b/src/renderer/features/shared/components/refresh-button.tsx new file mode 100644 index 00000000..01a60662 --- /dev/null +++ b/src/renderer/features/shared/components/refresh-button.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next'; + +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; + +interface RefreshButtonProps extends ActionIconProps {} + +export const RefreshButton = ({ onClick, ...props }: RefreshButtonProps) => { + const { t } = useTranslation(); + + return ( + <ActionIcon + icon="refresh" + iconProps={{ + size: 'lg', + ...props.iconProps, + }} + onClick={onClick} + tooltip={{ + label: t('common.refresh', { postProcess: 'sentenceCase' }), + ...props.tooltip, + }} + variant="subtle" + {...props} + /> + ); +}; diff --git a/src/renderer/features/shared/components/resize-handle.module.css b/src/renderer/features/shared/components/resize-handle.module.css new file mode 100644 index 00000000..a42d31fe --- /dev/null +++ b/src/renderer/features/shared/components/resize-handle.module.css @@ -0,0 +1,40 @@ +.handle { + position: absolute; + z-index: 90; + width: 4px; + height: 100%; + cursor: ew-resize; + background-color: var(--theme-colors-border); + opacity: 0; + + &:hover { + opacity: 0.6; + } + + &::before { + position: absolute; + width: 1px; + height: 100%; + content: ''; + } +} + +.handle-top { + top: 0; +} + +.handle-right { + right: 0; +} + +.handle-bottom { + bottom: 0; +} + +.handle-left { + left: 0; +} + +.handle.resizing { + opacity: 1; +} diff --git a/src/renderer/features/shared/components/resize-handle.tsx b/src/renderer/features/shared/components/resize-handle.tsx index 9446af64..a91dfac9 100644 --- a/src/renderer/features/shared/components/resize-handle.tsx +++ b/src/renderer/features/shared/components/resize-handle.tsx @@ -1,33 +1,25 @@ -import styled from 'styled-components'; +import clsx from 'clsx'; +import { forwardRef, HTMLAttributes } from 'react'; -export const ResizeHandle = styled.div<{ - $isResizing: boolean; - $placement: 'bottom' | 'left' | 'right' | 'top'; -}>` - position: absolute; - top: ${(props) => props.$placement === 'top' && 0}; - right: ${(props) => props.$placement === 'right' && 0}; - bottom: ${(props) => props.$placement === 'bottom' && 0}; - left: ${(props) => props.$placement === 'left' && 0}; - z-index: 90; - width: 4px; - height: 100%; - cursor: ew-resize; - opacity: ${(props) => (props.$isResizing ? 1 : 0)}; +import styles from './resize-handle.module.css'; - &:hover { - opacity: 0.7; - } +interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> { + isResizing: boolean; + placement: 'bottom' | 'left' | 'right' | 'top'; +} - &::before { - position: absolute; - top: ${(props) => props.$placement === 'top' && 0}; - right: ${(props) => props.$placement === 'right' && 0}; - bottom: ${(props) => props.$placement === 'bottom' && 0}; - left: ${(props) => props.$placement === 'left' && 0}; - width: 1px; - height: 100%; - content: ''; - background-color: var(--sidebar-handle-bg); - } -`; +export const ResizeHandle = forwardRef<HTMLDivElement, ResizeHandleProps>( + ({ isResizing, placement, ...props }: ResizeHandleProps, ref) => { + return ( + <div + className={clsx({ + [styles.handle]: true, + [styles.resizing]: isResizing, + [styles[`handle-${placement}`]]: true, + })} + ref={ref} + {...props} + /> + ); + }, +); diff --git a/src/renderer/features/shared/components/search-input.tsx b/src/renderer/features/shared/components/search-input.tsx new file mode 100644 index 00000000..c9980d7c --- /dev/null +++ b/src/renderer/features/shared/components/search-input.tsx @@ -0,0 +1,58 @@ +import { useHotkeys } from '@mantine/hooks'; +import { ChangeEvent, KeyboardEvent, useRef } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { useSettingsStore } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Icon } from '/@/shared/components/icon/icon'; +import { TextInput, TextInputProps } from '/@/shared/components/text-input/text-input'; + +interface SearchInputProps extends TextInputProps { + value?: string; +} + +export const SearchInput = ({ onChange, ...props }: SearchInputProps) => { + const ref = useRef<HTMLInputElement>(null); + const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow); + + useHotkeys([[binding.hotkey, () => ref?.current?.select()]]); + + const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.code === 'Escape') { + onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>); + if (ref.current) { + ref.current.value = ''; + ref.current.blur(); + } + } + }; + + const handleClear = () => { + if (ref.current) { + ref.current.value = ''; + ref.current.focus(); + onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>); + } + }; + + return ( + <TextInput + leftSection={<Icon icon="search" />} + onChange={onChange} + onKeyDown={handleEscape} + ref={ref} + size="sm" + width={200} + {...props} + rightSection={ + ref.current?.value ? ( + <ActionIcon + icon="x" + onClick={handleClear} + variant="transparent" + /> + ) : null + } + /> + ); +}; diff --git a/src/renderer/features/shared/components/settings-button.tsx b/src/renderer/features/shared/components/settings-button.tsx new file mode 100644 index 00000000..9b1ad524 --- /dev/null +++ b/src/renderer/features/shared/components/settings-button.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from 'react-i18next'; + +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; + +interface SettingsButtonProps extends ActionIconProps {} + +export const SettingsButton = ({ ...props }: SettingsButtonProps) => { + const { t } = useTranslation(); + + return ( + <ActionIcon + icon="settings" + iconProps={{ + size: 'lg', + ...props.iconProps, + }} + tooltip={{ + label: t('common.configure', { postProcess: 'sentenceCase' }), + ...props.tooltip, + }} + variant="subtle" + {...props} + /> + ); +}; diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx index 7911cc0c..cc5683a5 100644 --- a/src/renderer/features/sharing/components/share-item-context-modal.tsx +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -1,17 +1,16 @@ -import { Box, Group, Stack, TextInput } from '@mantine/core'; -import { DateTimePicker } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { closeModal, ContextModalProps } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; -import { Button, Switch, toast } from '/@/renderer/components'; import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation'; import { useCurrentServer } from '/@/renderer/store'; - -// Bugged prop types in mantine v6 -const WrappedDateTimePicker = ({ ...props }: any) => { - return <DateTimePicker {...props} />; -}; +import { Button } from '/@/shared/components/button/button'; +import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Textarea } from '/@/shared/components/textarea/textarea'; +import { toast } from '/@/shared/components/toast/toast'; export const ShareItemContextModal = ({ id, @@ -98,53 +97,54 @@ export const ShareItemContextModal = ({ }); return ( - <Box p="1rem"> - <form onSubmit={handleSubmit}> - <Stack> - <TextInput - label={t('form.shareItem.description', { - postProcess: 'titleCase', - })} - {...form.getInputProps('description')} - /> - <Switch - defaultChecked={false} - label={t('form.shareItem.allowDownloading', { - postProcess: 'titleCase', - })} - {...form.getInputProps('allowDownloading')} - /> - <WrappedDateTimePicker - clearable - label={t('form.shareItem.setExpiration', { - postProcess: 'titleCase', - })} - minDate={new Date()} - placeholder={defaultDate.toLocaleDateString()} - popoverProps={{ withinPortal: true }} - valueFormat="MM/DD/YYYY HH:mm" - {...form.getInputProps('expires')} - /> - <Group position="right"> - <Group> - <Button - onClick={() => closeModal(id)} - size="md" - variant="subtle" - > - {t('common.cancel', { postProcess: 'titleCase' })} - </Button> - <Button - size="md" - type="submit" - variant="filled" - > - {t('common.share', { postProcess: 'titleCase' })} - </Button> - </Group> + <form onSubmit={handleSubmit}> + <Stack> + <DateTimePicker + clearable + label={t('form.shareItem.setExpiration', { + postProcess: 'titleCase', + })} + minDate={new Date()} + placeholder={defaultDate.toLocaleDateString()} + popoverProps={{ withinPortal: true }} + valueFormat="MM/DD/YYYY HH:mm" + {...form.getInputProps('expires')} + /> + <Textarea + autosize + label={t('form.shareItem.description', { + postProcess: 'titleCase', + })} + minRows={5} + {...form.getInputProps('description')} + /> + <Switch + defaultChecked={false} + label={t('form.shareItem.allowDownloading', { + postProcess: 'titleCase', + })} + {...form.getInputProps('allowDownloading')} + /> + + <Group justify="flex-end"> + <Group> + <Button + onClick={() => closeModal(id)} + size="md" + variant="subtle" + > + {t('common.cancel', { postProcess: 'titleCase' })} + </Button> + <Button + size="md" + type="submit" + variant="filled" + > + {t('common.share', { postProcess: 'titleCase' })} + </Button> </Group> - </Stack> - </form> - </Box> + </Group> + </Stack> + </form> ); }; diff --git a/src/renderer/features/sidebar/components/action-bar.module.css b/src/renderer/features/sidebar/components/action-bar.module.css new file mode 100644 index 00000000..3d10016c --- /dev/null +++ b/src/renderer/features/sidebar/components/action-bar.module.css @@ -0,0 +1,10 @@ +.container { + display: flex; + align-items: center; + height: 65px; + -webkit-app-region: drag; + + input { + -webkit-app-region: no-drag; + } +} diff --git a/src/renderer/features/sidebar/components/action-bar.tsx b/src/renderer/features/sidebar/components/action-bar.tsx index 35376a42..d772ed06 100644 --- a/src/renderer/features/sidebar/components/action-bar.tsx +++ b/src/renderer/features/sidebar/components/action-bar.tsx @@ -1,24 +1,17 @@ -import { Grid, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { RiArrowLeftSLine, RiArrowRightSLine, RiMenuFill, RiSearchLine } from 'react-icons/ri'; import { useNavigate } from 'react-router'; -import styled from 'styled-components'; -import { Button, DropdownMenu, TextInput } from '/@/renderer/components'; +import styles from './action-bar.module.css'; + import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; import { useContainerQuery } from '/@/renderer/hooks'; import { useCommandPalette } from '/@/renderer/store'; - -const ActionsContainer = styled.div` - display: flex; - align-items: center; - height: 70px; - -webkit-app-region: drag; - - input { - -webkit-app-region: no-drag; - } -`; +import { Button } from '/@/shared/components/button/button'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Grid } from '/@/shared/components/grid/grid'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { TextInput } from '/@/shared/components/text-input/text-input'; export const ActionBar = () => { const { t } = useTranslation(); @@ -27,114 +20,60 @@ export const ActionBar = () => { const { open } = useCommandPalette(); return ( - <ActionsContainer ref={cq.ref}> - {cq.isMd ? ( - <Grid - display="flex" - gutter="sm" - px="1rem" - w="100%" - > - <Grid.Col span={6}> - <TextInput - icon={<RiSearchLine />} - onClick={open} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - open(); - } - }} - placeholder={t('common.search', { postProcess: 'titleCase' })} - readOnly - size="md" - /> - </Grid.Col> - <Grid.Col span={6}> - <Group - grow - noWrap - spacing="sm" - > - <DropdownMenu position="bottom-start"> - <DropdownMenu.Target> - <Button - p="0.5rem" - size="md" - variant="default" - > - <RiMenuFill size="1rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <AppMenu /> - </DropdownMenu.Dropdown> - </DropdownMenu> - <Button - onClick={() => navigate(-1)} - p="0.5rem" - size="md" - variant="default" - > - <RiArrowLeftSLine size="1.5rem" /> - </Button> - <Button - onClick={() => navigate(1)} - p="0.5rem" - size="md" - variant="default" - > - <RiArrowRightSLine size="1.5rem" /> - </Button> - </Group> - </Grid.Col> - </Grid> - ) : ( - <Group - grow - px="1rem" - spacing="sm" - w="100%" - > - <Button + <div + className={styles.container} + ref={cq.ref} + > + <Grid + display="flex" + gutter="sm" + px="1rem" + w="100%" + > + <Grid.Col span={6}> + <TextInput + leftSection={<Icon icon="search" />} onClick={open} - p="0.5rem" - size="md" - variant="default" + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + open(); + } + }} + placeholder={t('common.search', { postProcess: 'titleCase' })} + readOnly + /> + </Grid.Col> + <Grid.Col span={6}> + <Group + gap="sm" + grow + wrap="nowrap" > - <RiSearchLine size="1rem" /> - </Button> - <DropdownMenu position="bottom-start"> - <DropdownMenu.Target> - <Button - p="0.5rem" - size="md" - variant="default" - > - <RiMenuFill size="1rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <AppMenu /> - </DropdownMenu.Dropdown> - </DropdownMenu> - <Button - onClick={() => navigate(-1)} - p="0.5rem" - size="md" - variant="default" - > - <RiArrowLeftSLine size="1.5rem" /> - </Button> - <Button - onClick={() => navigate(1)} - p="0.5rem" - size="md" - variant="default" - > - <RiArrowRightSLine size="1.5rem" /> - </Button> - </Group> - )} - </ActionsContainer> + <DropdownMenu position="bottom-start"> + <DropdownMenu.Target> + <Button p="0.5rem"> + <Icon icon="menu" /> + </Button> + </DropdownMenu.Target> + <DropdownMenu.Dropdown> + <AppMenu /> + </DropdownMenu.Dropdown> + </DropdownMenu> + <Button + onClick={() => navigate(-1)} + p="0.5rem" + > + <Icon icon="arrowLeftS" /> + </Button> + <Button + onClick={() => navigate(1)} + p="0.5rem" + > + <Icon icon="arrowRightS" /> + </Button> + </Group> + </Grid.Col> + </Grid> + </div> ); }; diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar-button.module.css b/src/renderer/features/sidebar/components/collapsed-sidebar-button.module.css new file mode 100644 index 00000000..68e469e8 --- /dev/null +++ b/src/renderer/features/sidebar/components/collapsed-sidebar-button.module.css @@ -0,0 +1,5 @@ +.button { + width: 100%; + height: 100%; + padding: 0.9rem 0.3rem; +} diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar-button.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar-button.tsx index c927e827..4fbdce41 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar-button.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar-button.tsx @@ -1,65 +1,22 @@ -import { createPolymorphicComponent, Flex } from '@mantine/core'; -import { forwardRef, ReactNode } from 'react'; -import styled from 'styled-components'; +import { forwardRef } from 'react'; -const Container = styled(Flex)<{ $active?: boolean; $disabled?: boolean }>` - position: relative; - display: flex; - justify-content: center; - width: 100%; - height: 65px; - pointer-events: ${(props) => (props.$disabled ? 'none' : 'all')}; - cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; - user-select: ${(props) => (props.$disabled ? 'none' : 'initial')}; - opacity: ${(props) => props.$disabled && 0.6}; +import styles from './collapsed-sidebar-button.module.css'; - svg { - fill: ${(props) => (props.$active ? 'var(--primary-color)' : 'var(--sidebar-fg)')}; - } +import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; - &:focus-visible { - background-color: var(--sidebar-bg-hover); - outline: none; - } +interface CollapsedSidebarButtonProps extends ActionIconProps {} - ${(props) => - !props.$disabled && - ` - &:hover { - background-color: var(--sidebar-bg-hover); - - div { - color: var(--main-fg) !important; - } - - svg { - fill: var(--primary-color); - } - } - `} -`; - -interface CollapsedSidebarButtonProps { - children: ReactNode; - onClick?: () => void; -} - -const _CollapsedSidebarButton = forwardRef<HTMLDivElement, CollapsedSidebarButtonProps>( +export const CollapsedSidebarButton = forwardRef<HTMLButtonElement, CollapsedSidebarButtonProps>( ({ children, ...props }: CollapsedSidebarButtonProps, ref) => { return ( - <Container - align="center" - direction="column" + <ActionIcon + className={styles.button} ref={ref} + variant="subtle" {...props} > {children} - </Container> + </ActionIcon> ); }, ); - -export const CollapsedSidebarButton = createPolymorphicComponent< - 'button', - CollapsedSidebarButtonProps ->(_CollapsedSidebarButton); diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar-item.module.css b/src/renderer/features/sidebar/components/collapsed-sidebar-item.module.css new file mode 100644 index 00000000..bcfac9ee --- /dev/null +++ b/src/renderer/features/sidebar/components/collapsed-sidebar-item.module.css @@ -0,0 +1,68 @@ +.container { + position: relative; + width: 100%; + padding: 0.9rem 0.3rem; + color: var(--theme-colors-foreground-muted); + cursor: pointer; + + &:focus-visible { + outline: none; + } + + svg { + fill: var(--theme-colors-foreground-muted); + } + + &:hover { + color: var(--theme-colors-foreground); + + svg { + fill: var(--theme-colors-foreground); + } + } +} + +.container.active { + svg { + fill: var(--theme-colors-primary-filled); + } +} + +.container.disabled { + pointer-events: none; + cursor: default; + user-select: none; + opacity: 0.6; + + &:hover { + div { + color: var(--theme-colors-foreground) !important; + } + + svg { + fill: var(--theme-colors-primary-filled); + } + } +} + +.text-wrapper { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + white-space: pre-line; +} + +.text-wrapper.active { + color: var(--theme-colors-foreground); +} + +.active-tab-indicator { + position: absolute; + inset: 0 0 0 3px; + width: 2px; + height: 80%; + margin-top: auto; + margin-bottom: auto; + background: var(--theme-colors-primary-filled); +} diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx index 3562e43a..db8ac736 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar-item.tsx @@ -1,67 +1,12 @@ -import { createPolymorphicComponent, Flex } from '@mantine/core'; -import { motion } from 'framer-motion'; +import clsx from 'clsx'; import { forwardRef, ReactNode } from 'react'; import { useMatch } from 'react-router'; -import styled from 'styled-components'; -import { Text } from '/@/renderer/components'; +import styles from './collapsed-sidebar-item.module.css'; -const Container = styled(Flex)<{ $active?: boolean; $disabled?: boolean }>` - position: relative; - width: 100%; - padding: 0.9rem 0.3rem; - pointer-events: ${(props) => (props.$disabled ? 'none' : 'all')}; - cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; - user-select: ${(props) => (props.$disabled ? 'none' : 'initial')}; - border-right: var(--sidebar-border); - opacity: ${(props) => props.$disabled && 0.6}; - - svg { - fill: ${(props) => (props.$active ? 'var(--primary-color)' : 'var(--sidebar-fg)')}; - } - - &:focus-visible { - background-color: var(--sidebar-bg-hover); - outline: none; - } - - ${(props) => - !props.$disabled && - ` - &:hover { - background-color: var(--sidebar-bg-hover); - - div { - color: var(--main-fg) !important; - } - - svg { - fill: var(--primary-color); - } - } - `} -`; - -const TextWrapper = styled.div` - width: 100%; - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - white-space: pre-line; -`; - -const ActiveTabIndicator = styled(motion.div)` - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 3px; - width: 2px; - height: 80%; - margin-top: auto; - margin-bottom: auto; - background: var(--primary-color); -`; +import { Flex } from '/@/shared/components/flex/flex'; +import { Text } from '/@/shared/components/text/text'; +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; interface CollapsedSidebarItemProps { activeIcon: ReactNode; @@ -77,26 +22,32 @@ const _CollapsedSidebarItem = forwardRef<HTMLDivElement, CollapsedSidebarItemPro const isMatch = Boolean(match); return ( - <Container - $active={Boolean(match)} - $disabled={disabled} + <Flex align="center" + className={clsx({ + [styles.active]: isMatch, + [styles.container]: true, + [styles.disabled]: disabled, + })} direction="column" ref={ref} + tabIndex={0} {...props} > - {isMatch ? <ActiveTabIndicator /> : null} + {isMatch ? <div className={styles.activeTabIndicator} /> : null} {isMatch ? activeIcon : icon} - <TextWrapper> - <Text - $secondary={!isMatch} - fw="600" - size="xs" - > - {label} - </Text> - </TextWrapper> - </Container> + <Text + className={clsx({ + [styles.active]: isMatch, + [styles.textWrapper]: true, + })} + fw="600" + isMuted={!isMatch} + size="xs" + > + {label} + </Text> + </Flex> ); }, ); diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.module.css b/src/renderer/features/sidebar/components/collapsed-sidebar.module.css new file mode 100644 index 00000000..b226a061 --- /dev/null +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.module.css @@ -0,0 +1,13 @@ +.sidebar-container { + display: flex; + flex-direction: column; + height: 100%; + max-height: calc(100vh - 119px); + user-select: none; + background: var(--theme-colors-background-alternate); +} + +.sidebar-container.web, +.sidebar-container.linux { + max-height: calc(100vh - 149px); +} diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 5f555294..c6139249 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -1,30 +1,23 @@ -import { Group, UnstyledButton } from '@mantine/core'; -import { motion } from 'framer-motion'; +import clsx from 'clsx'; +import { motion } from 'motion/react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiArrowLeftSLine, RiArrowRightSLine, RiMenuFill } from 'react-icons/ri'; import { NavLink, useNavigate } from 'react-router-dom'; -import styled from 'styled-components'; -import { DropdownMenu, ScrollArea } from '/@/renderer/components'; +import styles from './collapsed-sidebar.module.css'; + import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button'; import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; import { SidebarItemType, useGeneralSettings, useWindowSettings } from '/@/renderer/store'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { Platform } from '/@/shared/types/types'; -const SidebarContainer = styled(motion.div)<{ $windowBarStyle: Platform }>` - display: flex; - flex-direction: column; - height: 100%; - max-height: ${(props) => - props.$windowBarStyle === Platform.WEB || props.$windowBarStyle === Platform.LINUX - ? 'calc(100vh - 149px)' - : 'calc(100vh - 119px)'}; - user-select: none; -`; - export const CollapsedSidebar = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -67,40 +60,50 @@ export const CollapsedSidebar = () => { }, [sidebarItems, translatedSidebarItemMap]); return ( - <SidebarContainer $windowBarStyle={windowBarStyle}> - <ScrollArea - scrollbarSize={8} - scrollHideDelay={0} - > + <motion.div + className={clsx({ + [styles.linux]: windowBarStyle === Platform.LINUX, + [styles.sidebarContainer]: true, + [styles.web]: windowBarStyle === Platform.WEB, + })} + > + <ScrollArea> {sidebarCollapsedNavigation && ( <Group + gap={0} grow - spacing={0} - style={{ - borderRight: 'var(--sidebar-border)', - }} > - <CollapsedSidebarButton - component={UnstyledButton} - onClick={() => navigate(-1)} - > - <RiArrowLeftSLine size="22" /> + <CollapsedSidebarButton onClick={() => navigate(-1)}> + <Icon + icon="arrowLeftS" + size="xl" + /> </CollapsedSidebarButton> - <CollapsedSidebarButton - component={UnstyledButton} - onClick={() => navigate(1)} - > - <RiArrowRightSLine size="22" /> + <CollapsedSidebarButton onClick={() => navigate(1)}> + <Icon + icon="arrowRightS" + size="xl" + /> </CollapsedSidebarButton> </Group> )} <DropdownMenu position="right-start"> <DropdownMenu.Target> <CollapsedSidebarItem - activeIcon={<RiMenuFill size="25" />} - component={UnstyledButton} - icon={<RiMenuFill size="25" />} + activeIcon={null} + component={Flex} + icon={ + <Icon + fill="muted" + icon="menu" + size="3xl" + /> + } label={t('common.menu', { postProcess: 'titleCase' })} + style={{ + cursor: 'pointer', + padding: 'var(--theme-spacing-md) 0', + }} /> </DropdownMenu.Target> <DropdownMenu.Dropdown> @@ -130,6 +133,6 @@ export const CollapsedSidebar = () => { /> ))} </ScrollArea> - </SidebarContainer> + </motion.div> ); }; diff --git a/src/renderer/features/sidebar/components/sidebar-item.module.css b/src/renderer/features/sidebar/components/sidebar-item.module.css new file mode 100644 index 00000000..ce459b5a --- /dev/null +++ b/src/renderer/features/sidebar/components/sidebar-item.module.css @@ -0,0 +1,28 @@ +.item { + width: 100%; + font-family: var(--theme-content-font-family); + font-size: var(--theme-font-size-md); + font-weight: 600; + + &:focus-visible { + border: 1px solid var(--theme-colors-primary-filled); + } +} + +.link { + display: flex; + width: 100%; + font-size: var(--theme-font-size-md); + color: var(--theme-colors-foreground); + border: 1px transparent solid; + transition: color 0.2s ease-in-out; + + &:focus-visible { + border: 1px solid var(--theme-colors-primary-filled); + } +} + +.link.disabled { + pointer-events: none; + opacity: 0.6; +} diff --git a/src/renderer/features/sidebar/components/sidebar-item.tsx b/src/renderer/features/sidebar/components/sidebar-item.tsx index f7a1aee4..8873de2c 100644 --- a/src/renderer/features/sidebar/components/sidebar-item.tsx +++ b/src/renderer/features/sidebar/components/sidebar-item.tsx @@ -1,72 +1,30 @@ -import type { ReactNode } from 'react'; -import type { LinkProps } from 'react-router-dom'; +import clsx from 'clsx'; +import { memo } from 'react'; +import { Link, LinkProps } from 'react-router-dom'; -import { createPolymorphicComponent, Flex, FlexProps } from '@mantine/core'; -import { Link } from 'react-router-dom'; -import styled, { css } from 'styled-components'; +import styles from './sidebar-item.module.css'; -interface ListItemProps extends FlexProps { - children: ReactNode; - disabled?: boolean; - to?: string; +import { Button, ButtonProps } from '/@/shared/components/button/button'; + +interface SidebarItemProps extends ButtonProps { + to: LinkProps['to']; } -const StyledItem = styled(Flex)` - width: 100%; - font-family: var(--content-font-family); - font-weight: 600; - - &:focus-visible { - border: 1px solid var(--primary-color); - } -`; - -const ItemStyle = css` - display: flex; - width: 100%; - padding: 0.5rem 1rem; - color: var(--sidebar-fg); - border: 1px transparent solid; - transition: color 0.2s ease-in-out; - - &:hover { - color: var(--sidebar-fg-hover); - } -`; - -const _ItemLink = styled(StyledItem)<LinkProps & { disabled?: boolean }>` - pointer-events: ${(props) => props.disabled && 'none'}; - opacity: ${(props) => props.disabled && 0.6}; - - &:focus-visible { - border: 1px solid var(--primary-color); - } - - ${ItemStyle} -`; - -const ItemLink = createPolymorphicComponent<'a', ListItemProps>(_ItemLink); - -export const SidebarItem = ({ children, to, ...props }: ListItemProps) => { - if (to) { - return ( - <ItemLink - component={Link} - to={to} - {...props} - > - {children} - </ItemLink> - ); - } +export const SidebarItem = ({ children, to, ...props }: SidebarItemProps) => { return ( - <StyledItem - tabIndex={0} + <Button + className={clsx({ + [styles.disabled]: props.disabled, + [styles.link]: true, + })} + component={Link} + to={to} + variant="subtle" {...props} > {children} - </StyledItem> + </Button> ); }; -SidebarItem.Link = ItemLink; +export const MemoizedSidebarItem = memo(SidebarItem); diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css new file mode 100644 index 00000000..b6b88503 --- /dev/null +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css @@ -0,0 +1,16 @@ +.list { + padding: var(--theme-spacing-sm) var(--theme-spacing-md); +} + +.row { + position: relative; + display: flex; + width: 100%; +} + +.controls { + position: absolute; + top: 50%; + right: var(--theme-spacing-xs); + transform: translateY(-50%); +} diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 6e36d82d..3bb20939 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -1,200 +1,150 @@ -import { Box, Flex, Group } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; -import { useCallback, useMemo, useState } from 'react'; +import { closeAllModals, openModal } from '@mantine/modals'; +import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiArrowDownSLine, - RiArrowUpSLine, - RiPlayFill, - RiShuffleFill, -} from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeList, ListChildComponentProps } from 'react-window'; -import { Button, Text } from '/@/renderer/components'; -import { openContextMenu } from '/@/renderer/features/context-menu'; -import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import styles from './sidebar-playlist-list.module.css'; + import { usePlayQueueAdd } from '/@/renderer/features/player'; -import { usePlaylistList } from '/@/renderer/features/playlists'; -import { useHideScrollbar } from '/@/renderer/hooks'; +import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; +import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store'; -import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types'; +import { useCurrentServer } from '/@/renderer/store'; +import { Accordion } from '/@/shared/components/accordion/accordion'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { ButtonProps } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Text } from '/@/shared/components/text/text'; +import { + LibraryItem, + Playlist, + PlaylistListSort, + ServerType, + SortOrder, +} from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -const PlaylistRow = ({ data, index, style }: ListChildComponentProps) => { - const { t } = useTranslation(); +interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onPlay'> { + name: string; + onPlay: (id: string, playType: Play.LAST | Play.NEXT | Play.NOW | Play.SHUFFLE) => void; + to: string; +} - if (Array.isArray(data?.items[index])) { - const [collapse, setCollapse] = data.items[index]; +const PlaylistRowButton = ({ name, onPlay, to, ...props }: PlaylistRowButtonProps) => { + const url = generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }); - return ( - <div style={{ margin: '0.5rem 0', padding: '0 1.5rem', ...style }}> - <Box - fw="600" - sx={{ fontSize: '1.2rem' }} - > - <Group> - <Text>{t('page.sidebar.shared', { postProcess: 'titleCase' })}</Text> - <Button - compact - onClick={() => setCollapse()} - tooltip={{ - label: t(collapse ? 'common.expand' : 'common.collapse', { - postProcess: 'titleCase', - }), - openDelay: 500, - }} - variant="default" - > - {collapse ? ( - <RiArrowUpSLine size={20} /> - ) : ( - <RiArrowDownSLine size={20} /> - )} - </Button> - </Group> - </Box> - </div> - ); - } - - const path = data?.items[index].id - ? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id }) - : undefined; + const [isHovered, setIsHovered] = useState(false); return ( <div - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - - if (!data?.items?.[index].id) return; - - openContextMenu({ - data: [data?.items?.[index]], - dataNodes: undefined, - menuItems: PLAYLIST_CONTEXT_MENU_ITEMS, - type: LibraryItem.PLAYLIST, - xPos: e.clientX + 15, - yPos: e.clientY + 5, - }); - }} - style={{ margin: '0.5rem 0', padding: '0 1.5rem', ...style }} + className={styles.row} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - <Group - className="sidebar-playlist-item" - noWrap - pos="relative" - position="apart" - sx={{ - '&:hover': { - '.sidebar-playlist-controls': { - display: 'flex', - }, - '.sidebar-playlist-name': { - color: 'var(--sidebar-fg-hover) !important', - }, - }, - }} + <SidebarItem + to={url} + variant="subtle" + {...props} > - <Text - className="sidebar-playlist-name" - component={Link} - overflow="hidden" - size="md" - sx={{ - color: 'var(--sidebar-fg) !important', - cursor: 'default', - width: '100%', - }} - to={path} - > - {data?.items[index].name} - </Text> - <Group - className="sidebar-playlist-controls" - display="none" - noWrap - pos="absolute" - right="0" - spacing="sm" - > - <Button - compact - onClick={() => { - if (!data?.items?.[index].id) return; - data.handlePlay(data?.items[index].id, Play.NOW); - }} - size="md" - tooltip={{ - label: t('player.play', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiPlayFill /> - </Button> - <Button - compact - onClick={() => { - if (!data?.items?.[index].id) return; - data.handlePlay(data?.items[index].id, Play.SHUFFLE); - }} - size="md" - tooltip={{ - label: t('player.shuffle', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiShuffleFill /> - </Button> - <Button - compact - onClick={() => { - if (!data?.items?.[index].id) return; - data.handlePlay(data?.items[index].id, Play.LAST); - }} - size="md" - tooltip={{ - label: t('player.addLast', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiAddBoxFill /> - </Button> - <Button - compact - onClick={() => { - if (!data?.items?.[index].id) return; - data.handlePlay(data?.items[index].id, Play.NEXT); - }} - size="md" - tooltip={{ - label: t('player.addNext', { postProcess: 'sentenceCase' }), - openDelay: 500, - }} - variant="default" - > - <RiAddCircleFill /> - </Button> - </Group> - </Group> + {name} + </SidebarItem> + {isHovered && ( + <RowControls + id={to} + onPlay={onPlay} + /> + )} </div> ); }; +const RowControls = ({ + id, + onPlay, +}: { + id: string; + onPlay: (id: string, playType: Play) => void; +}) => { + const { t } = useTranslation(); + + return ( + <Group + className={styles.controls} + gap="xs" + wrap="nowrap" + > + <ActionIcon + icon="mediaPlay" + iconProps={{ + size: 'md', + }} + onClick={() => { + if (!id) return; + onPlay(id, Play.NOW); + }} + size="xs" + tooltip={{ + label: t('player.play', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + <ActionIcon + icon="mediaShuffle" + iconProps={{ + size: 'md', + }} + onClick={() => { + if (!id) return; + onPlay(id, Play.SHUFFLE); + }} + size="xs" + tooltip={{ + label: t('player.shuffle', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + <ActionIcon + icon="mediaPlayLast" + iconProps={{ + size: 'md', + }} + onClick={() => { + if (!id) return; + onPlay(id, Play.LAST); + }} + size="xs" + tooltip={{ + label: t('player.addLast', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + <ActionIcon + icon="mediaPlayNext" + iconProps={{ + size: 'md', + }} + onClick={() => { + if (!id) return; + onPlay(id, Play.NEXT); + }} + size="xs" + tooltip={{ + label: t('player.addNext', { postProcess: 'sentenceCase' }), + openDelay: 500, + }} + variant="subtle" + /> + </Group> + ); +}; + export const SidebarPlaylistList = () => { - const { hideScrollbarElementProps, isScrollbarHidden } = useHideScrollbar(0); const handlePlayQueueAdd = usePlayQueueAdd(); - const { sidebarCollapseShared } = useGeneralSettings(); - const { toggleSidebarCollapseShare } = useSettingsStoreActions(); + const { t } = useTranslation(); const server = useCurrentServer(); const playlistsQuery = usePlaylistList({ @@ -206,13 +156,6 @@ export const SidebarPlaylistList = () => { serverId: server?.id, }); - const [rect, setRect] = useState({ - height: 0, - width: 0, - }); - - const [debounced] = useDebouncedValue(rect, 25); - const handlePlayPlaylist = useCallback( (id: string, playType: Play) => { handlePlayQueueAdd?.({ @@ -236,56 +179,164 @@ export const SidebarPlaylistList = () => { } const owned: Array<[boolean, () => void] | Playlist> = []; + + for (const playlist of data.items) { + owned.push(playlist); + } + + return { ...base, items: owned }; + }, [data?.items, handlePlayPlaylist, server?.type, server?.username]); + + const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + + openModal({ + children: <CreatePlaylistForm onCancel={() => closeAllModals()} />, + size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm', + title: t('form.createPlaylist.title', { postProcess: 'titleCase' }), + }); + }; + + return ( + <Accordion.Item value="playlists"> + <Accordion.Control + component="div" + role="button" + style={{ userSelect: 'none' }} + > + <Group + justify="space-between" + pr="var(--theme-spacing-md)" + > + <Text fw={600}> + {t('page.sidebar.playlists', { + postProcess: 'titleCase', + })} + </Text> + <Group gap="xs"> + <ActionIcon + icon="add" + iconProps={{ + size: 'lg', + }} + onClick={handleCreatePlaylistModal} + size="xs" + tooltip={{ + label: t('action.createPlaylist', { + postProcess: 'sentenceCase', + }), + openDelay: 500, + }} + variant="subtle" + /> + <ActionIcon + component={Link} + icon="list" + iconProps={{ + size: 'lg', + }} + onClick={(e) => e.stopPropagation()} + size="xs" + to={AppRoute.PLAYLISTS} + tooltip={{ + label: t('action.viewPlaylists', { + postProcess: 'sentenceCase', + }), + openDelay: 500, + }} + variant="subtle" + /> + </Group> + </Group> + </Accordion.Control> + <Accordion.Panel> + {memoizedItemData?.items?.map((item, index) => ( + <PlaylistRowButton + key={index} + name={item.name} + onPlay={handlePlayPlaylist} + to={item.id} + /> + ))} + </Accordion.Panel> + </Accordion.Item> + ); +}; + +export const SidebarSharedPlaylistList = () => { + const handlePlayQueueAdd = usePlayQueueAdd(); + const { t } = useTranslation(); + const server = useCurrentServer(); + + const playlistsQuery = usePlaylistList({ + query: { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }); + + const handlePlayPlaylist = useCallback( + (id: string, playType: Play) => { + handlePlayQueueAdd?.({ + byItemType: { + id: [id], + type: LibraryItem.PLAYLIST, + }, + playType, + }); + }, + [handlePlayQueueAdd], + ); + + const data = playlistsQuery.data; + + const memoizedItemData = useMemo(() => { + const base = { handlePlay: handlePlayPlaylist }; + + if (!server?.type || !server?.username || !data?.items) { + return { ...base, items: data?.items }; + } + const shared: Playlist[] = []; for (const playlist of data.items) { if (playlist.owner && playlist.owner !== server.username) { + console.log(playlist.owner, server.username); shared.push(playlist); - } else { - owned.push(playlist); } } - if (shared.length > 0) { - owned.push([sidebarCollapseShared, toggleSidebarCollapseShare]); - } + return { ...base, items: shared }; + }, [data?.items, handlePlayPlaylist, server?.type, server?.username]); - const final = sidebarCollapseShared ? owned : owned.concat(shared); - - return { ...base, items: final }; - }, [ - data?.items, - handlePlayPlaylist, - server?.type, - server?.username, - sidebarCollapseShared, - toggleSidebarCollapseShare, - ]); + if (memoizedItemData?.items?.length === 0) { + return null; + } return ( - <Flex - h="100%" - {...hideScrollbarElementProps} - > - <AutoSizer onResize={(e) => setRect(e as { height: number; width: number })}> - {() => ( - <FixedSizeList - className={ - isScrollbarHidden - ? 'hide-scrollbar overlay-scrollbar' - : 'overlay-scrollbar' - } - height={debounced.height} - itemCount={memoizedItemData?.items?.length || 0} - itemData={memoizedItemData} - itemSize={25} - overscanCount={20} - width={debounced.width} - > - {PlaylistRow} - </FixedSizeList> - )} - </AutoSizer> - </Flex> + <Accordion.Item value="shared-playlists"> + <Accordion.Control> + <Text + fw={600} + variant="secondary" + > + {t('page.sidebar.shared', { + postProcess: 'titleCase', + })} + </Text> + </Accordion.Control> + <Accordion.Panel> + {memoizedItemData?.items?.map((item, index) => ( + <PlaylistRowButton + key={index} + name={item.name} + onPlay={handlePlayPlaylist} + to={item.id} + /> + ))} + </Accordion.Panel> + </Accordion.Item> ); }; diff --git a/src/renderer/features/sidebar/components/sidebar.module.css b/src/renderer/features/sidebar/components/sidebar.module.css new file mode 100644 index 00000000..86408fc0 --- /dev/null +++ b/src/renderer/features/sidebar/components/sidebar.module.css @@ -0,0 +1,68 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-xs); + width: 100%; + height: 100%; + max-height: calc(100vh - 90px); + background: var(--theme-colors-background-alternate); +} + +.scroll-area { + padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md); +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.image-container { + position: relative; + height: var(--sidebar-image-height); + cursor: pointer; + animation: fade-in 0.2s ease-in-out; + + button { + display: none; + } + + &:hover button { + display: block; + } +} + +.sidebar-image { + width: 100%; + height: 100%; + object-fit: var(--theme-image-fit); + background: var(--theme-colors-foreground-muted); + border-radius: 0; +} + +.accordion-root { + height: 100%; +} + +.accordion-item { + border-bottom: none; +} + +.accordion-control { + height: 2.5rem; + border-radius: var(--theme-radius-md); +} + +.accordion-content { + padding: 0; + background: var(--theme-colors-background-alternate); +} + +.accordion-content:last-child { + padding-bottom: var(--theme-spacing-md); +} diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index d8b2797e..b36f5d2d 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -1,83 +1,40 @@ -import { Box, Center, Divider, Group, Stack } from '@mantine/core'; -import { closeAllModals, openModal } from '@mantine/modals'; -import { AnimatePresence, motion } from 'framer-motion'; -import { MouseEvent, useMemo } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { CSSProperties, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri'; -import { Link, useLocation } from 'react-router-dom'; -import styled from 'styled-components'; +import { useLocation } from 'react-router-dom'; + +import styles from './sidebar.module.css'; -import { Button, MotionStack, Tooltip } from '/@/renderer/components'; -import { CreatePlaylistForm } from '/@/renderer/features/playlists'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; -import { SidebarPlaylistList } from '/@/renderer/features/sidebar/components/sidebar-playlist-list'; -import { useContainerQuery } from '/@/renderer/hooks'; -import { AppRoute } from '/@/renderer/router/routes'; +import { + SidebarPlaylistList, + SidebarSharedPlaylistList, +} from '/@/renderer/features/sidebar/components/sidebar-playlist-list'; import { useAppStoreActions, - useCurrentServer, useCurrentSong, useFullScreenPlayerStore, useSetFullScreenPlayerStore, useSidebarStore, } from '/@/renderer/store'; -import { - SidebarItemType, - useGeneralSettings, - useWindowSettings, -} from '/@/renderer/store/settings.store'; -import { fadeIn } from '/@/renderer/styles'; -import { ServerType } from '/@/shared/types/domain-types'; -import { Platform } from '/@/shared/types/types'; - -const SidebarContainer = styled.div<{ $windowBarStyle: Platform }>` - height: 100%; - max-height: ${ - (props) => - props.$windowBarStyle === Platform.WEB || props.$windowBarStyle === Platform.LINUX - ? 'calc(100vh - 160px)' // Playerbar (90px) & ActionBar (70px) - : 'calc(100vh - 190px)' // plus windowbar (30px) if the windowBarStyle is Windows/Mac - // We use the height of the SidebarContainer to keep the Stack below the ActionBar at the correct height - // ActionBar uses height: 100%; so it has the full height of its parent - }; - user-select: none; -`; - -const ImageContainer = styled(motion.div)<{ height: string }>` - position: relative; - height: ${(props) => props.height}; - cursor: pointer; - - ${fadeIn}; - animation: fadein 0.2s ease-in-out; - - button { - display: none; - } - - &:hover button { - display: block; - } -`; - -const SidebarImage = styled.img` - width: 100%; - height: 100%; - object-fit: var(--image-fit); - background: var(--placeholder-bg); -`; +import { SidebarItemType, useGeneralSettings } from '/@/renderer/store/settings.store'; +import { Accordion } from '/@/shared/components/accordion/accordion'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Group } from '/@/shared/components/group/group'; +import { Image } from '/@/shared/components/image/image'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { Text } from '/@/shared/components/text/text'; +import { Tooltip } from '/@/shared/components/tooltip/tooltip'; export const Sidebar = () => { const { t } = useTranslation(); const location = useLocation(); const sidebar = useSidebarStore(); const { setSideBar } = useAppStoreActions(); - const { windowBarStyle } = useWindowSettings(); const { sidebarPlaylistList } = useGeneralSettings(); const imageUrl = useCurrentSong()?.imageUrl; - const server = useCurrentServer(); const translatedSidebarItemMap = useMemo( () => ({ @@ -101,24 +58,12 @@ export const Sidebar = () => { const showImage = sidebar.image; - const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => { - e.stopPropagation(); - - openModal({ - children: <CreatePlaylistForm onCancel={() => closeAllModals()} />, - size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm', - title: t('form.createPlaylist.title', { postProcess: 'titleCase' }), - }); - }; - const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const expandFullScreenPlayer = () => { setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); }; - const cq = useContainerQuery({ sm: 300 }); - const { sidebarItems } = useGeneralSettings(); const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => { @@ -137,159 +82,128 @@ export const Sidebar = () => { }, [sidebarItems, translatedSidebarItemMap]); return ( - <SidebarContainer - $windowBarStyle={windowBarStyle} - ref={cq.ref} + <div + className={styles.container} + id="left-sidebar" > - <ActionBar /> - <Stack - h="100%" - justify="space-between" - spacing={0} + <Group id="global-search-container"> + <ActionBar /> + </Group> + <ScrollArea + allowDragScroll + className={styles.scrollArea} + style={{ + maxHeight: showImage ? `calc(100vh - 90px - ${sidebar.leftWidth})` : '100%', + }} > - <MotionStack - h="100%" - layout="position" - spacing={0} - sx={{ maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%' }} + <Accordion + classNames={{ + content: styles.accordionContent, + control: styles.accordionControl, + item: styles.accordionItem, + root: styles.accordionRoot, + }} + multiple > - <Stack spacing={0}> - {sidebarItemsWithRoute.map((item) => { - return ( - <SidebarItem - key={`sidebar-${item.route}`} - to={item.route} - > - <Group spacing="sm"> - <SidebarIcon - active={location.pathname === item.route} - route={item.route} - size="1.1em" - /> - {item.label} - </Group> - </SidebarItem> - ); - })} - </Stack> - <Divider - mx="1rem" - my="0.5rem" - /> + <Accordion.Item value="library"> + <Accordion.Control> + <Text + fw={600} + variant="secondary" + > + {t('page.sidebar.myLibrary', { + postProcess: 'titleCase', + })} + </Text> + </Accordion.Control> + <Accordion.Panel> + {sidebarItemsWithRoute.map((item) => { + return ( + <SidebarItem + key={`sidebar-${item.route}`} + to={item.route} + > + <Group gap="sm"> + <SidebarIcon + active={location.pathname === item.route} + route={item.route} + /> + {item.label} + </Group> + </SidebarItem> + ); + })} + </Accordion.Panel> + </Accordion.Item> {sidebarPlaylistList && ( <> - <Group - position="apart" - pt="1rem" - px="1.5rem" - > - <Group> - <Box - fw="600" - sx={{ fontSize: '1.2rem' }} - > - {t('page.sidebar.playlists', { postProcess: 'titleCase' })} - </Box> - </Group> - <Group spacing="sm"> - <Button - compact - onClick={handleCreatePlaylistModal} - size="md" - tooltip={{ - label: t('action.createPlaylist', { - postProcess: 'sentenceCase', - }), - openDelay: 500, - }} - variant="default" - > - <RiAddFill size="1em" /> - </Button> - <Button - compact - component={Link} - onClick={(e) => e.stopPropagation()} - size="md" - to={AppRoute.PLAYLISTS} - tooltip={{ - label: t('action.viewPlaylists', { - postProcess: 'sentenceCase', - }), - openDelay: 500, - }} - variant="default" - > - <RiListUnordered size="1em" /> - </Button> - </Group> - </Group> <SidebarPlaylistList /> + <SidebarSharedPlaylistList /> </> )} - </MotionStack> - <AnimatePresence - initial={false} - mode="popLayout" - > - {showImage && ( - <ImageContainer - animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, y: 200 }} - height={sidebar.leftWidth} - initial={{ opacity: 0, y: 200 }} - key="sidebar-image" - onClick={expandFullScreenPlayer} - role="button" - transition={{ duration: 0.3, ease: 'easeInOut' }} + </Accordion> + </ScrollArea> + <AnimatePresence + initial={false} + mode="popLayout" + > + {showImage && ( + <motion.div + animate={{ opacity: 1, y: 0 }} + className={styles.imageContainer} + exit={{ opacity: 0, y: 200 }} + initial={{ opacity: 0, y: 200 }} + key="sidebar-image" + onClick={expandFullScreenPlayer} + role="button" + style={ + { + '--sidebar-image-height': sidebar.leftWidth, + } as CSSProperties + } + transition={{ duration: 0.3, ease: 'easeInOut' }} + > + <Tooltip + label={t('player.toggleFullscreenPlayer', { + postProcess: 'sentenceCase', + })} + openDelay={500} > - <Tooltip - label={t('player.toggleFullscreenPlayer', { - postProcess: 'sentenceCase', - })} - openDelay={500} - > - {upsizedImageUrl ? ( - <SidebarImage - loading="eager" - src={upsizedImageUrl} - /> - ) : ( - <Center - sx={{ background: 'var(--placeholder-bg)', height: '100%' }} - > - <RiDiscLine - color="var(--placeholder-fg)" - size={50} - /> - </Center> - )} - </Tooltip> - <Button - compact - onClick={(e) => { - e.stopPropagation(); - setSideBar({ image: false }); - }} - opacity={0.8} - radius={100} - size="md" - sx={{ cursor: 'default', position: 'absolute', right: 5, top: 5 }} - tooltip={{ - label: t('common.collapse', { postProcess: 'titleCase' }), - openDelay: 500, - }} - variant="default" - > - <RiArrowDownSLine - color="white" - size={20} - /> - </Button> - </ImageContainer> - )} - </AnimatePresence> - </Stack> - </SidebarContainer> + <Image + className={styles.sidebarImage} + includeLoader={false} + includeUnloader={false} + loading="eager" + src={upsizedImageUrl || ''} + /> + </Tooltip> + <ActionIcon + icon="arrowDownS" + iconProps={{ + size: 'lg', + }} + onClick={(e) => { + e.stopPropagation(); + setSideBar({ image: false }); + }} + opacity={0.8} + radius="md" + style={{ + cursor: 'default', + position: 'absolute', + right: 5, + top: 5, + }} + tooltip={{ + label: t('common.collapse', { + postProcess: 'titleCase', + }), + openDelay: 500, + }} + /> + </motion.div> + )} + </AnimatePresence> + </div> ); }; diff --git a/src/renderer/features/similar-songs/components/similar-songs-list.tsx b/src/renderer/features/similar-songs/components/similar-songs-list.tsx index 6a5f6291..fd8663bd 100644 --- a/src/renderer/features/similar-songs/components/similar-songs-list.tsx +++ b/src/renderer/features/similar-songs/components/similar-songs-list.tsx @@ -3,7 +3,6 @@ import { AgGridReact } from '@ag-grid-community/react'; import { useMemo, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { Spinner } from '/@/renderer/components'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; import { ErrorFallback } from '/@/renderer/features/action-required'; @@ -12,6 +11,7 @@ import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/conte import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; import { useSimilarSongs } from '/@/renderer/features/similar-songs/queries/similar-song-queries'; import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { LibraryItem, Song } from '/@/shared/types/domain-types'; export type SimilarSongsListProps = { diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 6976a715..f664ee8d 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -1,13 +1,17 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, Text } from '/@/renderer/components'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { useGenreList } from '/@/renderer/features/genres'; import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; interface JellyfinSongFiltersProps { @@ -172,8 +176,8 @@ export const JellyfinSongFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`nd-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index f5bd0839..7e73e950 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,13 +1,17 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, Text } from '/@/renderer/components'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { useGenreList } from '/@/renderer/features/genres'; import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; interface NavidromeSongFiltersProps { @@ -129,8 +133,8 @@ export const NavidromeSongFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`nd-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index fc047b6e..db41b1a8 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { lazy, MutableRefObject, Suspense } from 'react'; -import { Spinner } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { ListDisplayType } from '/@/shared/types/types'; const SongListTableView = lazy(() => @@ -30,7 +30,7 @@ export const SongListContent = ({ gridRef, itemCount, tableRef }: SongListConten const { pageKey } = useListContext(); const { display } = useListStoreByKey({ key: pageKey }); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; return ( <Suspense fallback={<Spinner container />}> diff --git a/src/renderer/features/songs/components/song-list-grid-view.tsx b/src/renderer/features/songs/components/song-list-grid-view.tsx index 6f7e0bca..c19a51f3 100644 --- a/src/renderer/features/songs/components/song-list-grid-view.tsx +++ b/src/renderer/features/songs/components/song-list-grid-view.tsx @@ -6,7 +6,7 @@ import { ListOnScrollProps } from 'react-window'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { SONG_CARD_ROWS } from '/@/renderer/components'; +import { SONG_CARD_ROWS } from '/@/renderer/components/card/card-rows'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index a35629f9..f3145221 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -1,36 +1,40 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Divider, Flex, Group, Stack } from '@mantine/core'; import { openModal } from '@mantine/modals'; -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; +import debounce from 'lodash/debounce'; +import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - RiAddBoxFill, - RiAddCircleFill, - RiFilterFill, - RiFolder2Fill, - RiMoreFill, - RiPlayFill, - RiRefreshLine, - RiSettings3Fill, - RiShuffleFill, -} from 'react-icons/ri'; import i18n from '/@/i18n/i18n'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { FilterButton } from '/@/renderer/features/shared/components/filter-button'; +import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { MoreButton } from '/@/renderer/features/shared/components/more-button'; +import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters'; import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters'; import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { queryClient } from '/@/renderer/lib/react-query'; -import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; +import { + PersistedTableColumn, + SongListFilter, + useCurrentServer, + useListStoreActions, +} from '/@/renderer/store'; import { useListStoreByKey } from '/@/renderer/store/list.store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { LibraryItem, ServerType, @@ -38,7 +42,7 @@ import { SongListSort, SortOrder, } from '/@/shared/types/domain-types'; -import { ListDisplayType, Play, TableColumn } from '/@/shared/types/types'; +import { ListDisplayType, Play } from '/@/shared/types/types'; const FILTERS = { jellyfin: [ @@ -223,7 +227,7 @@ export const SongListHeaderFilters = ({ ).find((f) => f.value === filter.sortBy)?.name) || 'Unknown'; - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; const handleSetSortBy = useCallback( (e: MouseEvent<HTMLButtonElement>) => { @@ -328,11 +332,9 @@ export const SongListHeaderFilters = ({ ]); const handleSetViewType = useCallback( - (e: MouseEvent<HTMLButtonElement>) => { - if (!e.currentTarget?.value) return; - const display = e.currentTarget.value as ListDisplayType; + (displayType: ListDisplayType) => { setDisplayType({ - data: e.currentTarget.value as ListDisplayType, + data: displayType, key: pageKey, }); @@ -345,10 +347,10 @@ export const SongListHeaderFilters = ({ setTablePagination({ data: { currentPage: 0 }, key: pageKey }); } }, - [pageKey, setDisplayType, setTablePagination, tableRef], + [display, pageKey, setDisplayType, setTablePagination, tableRef], ); - const handleTableColumns = (values: TableColumn[]) => { + const handleTableColumns = (values: string[]) => { const existingColumns = table.columns; if (values.length === 0) { @@ -362,7 +364,10 @@ export const SongListHeaderFilters = ({ // If adding a column if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1], width: 100 }; + const newColumn = { + column: values[values.length - 1], + width: 100, + } as PersistedTableColumn; return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } @@ -374,10 +379,10 @@ export const SongListHeaderFilters = ({ return setTable({ data: { columns: newColumns }, key: pageKey }); }; - const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => { - setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + const handleAutoFitColumns = (autoFitColumns: boolean) => { + setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - if (e.currentTarget.checked) { + if (autoFitColumns) { tableRef.current?.api.sizeColumnsToFit(); } }; @@ -390,6 +395,8 @@ export const SongListHeaderFilters = ({ } }; + const debouncedHandleItemSize = debounce(handleItemSize, 20); + const handleItemGap = (e: number) => { setGrid({ data: { itemGap: e }, key: pageKey }); }; @@ -478,25 +485,18 @@ export const SongListHeaderFilters = ({ return ( <Flex justify="space-between"> <Group + gap="sm" ref={cq.ref} - spacing="sm" w="100%" > <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - {sortByLabel} - </Button> + <Button variant="subtle">{sortByLabel}</Button> </DropdownMenu.Target> <DropdownMenu.Dropdown> {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( <DropdownMenu.Item - $isActive={f.value === filter.sortBy} + isSelected={f.value === filter.sortBy} key={`filter-${f.name}`} onClick={handleSetSortBy} value={f.value} @@ -506,40 +506,23 @@ export const SongListHeaderFilters = ({ ))} </DropdownMenu.Dropdown> </DropdownMenu> + <Divider orientation="vertical" /> {server?.type !== ServerType.SUBSONIC && ( - <> - <Divider orientation="vertical" /> - <OrderToggleButton - onToggle={handleToggleSortOrder} - sortOrder={filter.sortOrder} - /> - </> + <OrderToggleButton + onToggle={handleToggleSortOrder} + sortOrder={filter.sortOrder} + /> )} {server?.type === ServerType.JELLYFIN && ( <> - <Divider orientation="vertical" /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - sx={{ - svg: { - fill: isFolderFilterApplied - ? 'var(--primary-color) !important' - : undefined, - }, - }} - variant="subtle" - > - <RiFolder2Fill size="1.3rem" /> - </Button> + <FolderButton isActive={!!isFolderFilterApplied} /> </DropdownMenu.Target> <DropdownMenu.Dropdown> {musicFoldersQuery.data?.items.map((folder) => ( <DropdownMenu.Item - $isActive={filter.musicFolderId === folder.id} + isSelected={filter.musicFolderId === folder.id} key={`musicFolder-${folder.id}`} onClick={handleSetMusicFolder} value={folder.id} @@ -551,71 +534,43 @@ export const SongListHeaderFilters = ({ </DropdownMenu> </> )} - <Divider orientation="vertical" /> - <Button - compact + <FilterButton + isActive={!!isFilterApplied} onClick={handleOpenFiltersModal} - size="md" - sx={{ - svg: { - fill: isFilterApplied ? 'var(--primary-color) !important' : undefined, - }, - }} - tooltip={{ label: t('common.filters', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiFilterFill size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> - <Button - compact - onClick={handleRefresh} - size="md" - tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }} - variant="subtle" - > - <RiRefreshLine size="1.3rem" /> - </Button> - <Divider orientation="vertical" /> + /> + <RefreshButton onClick={handleRefresh} /> <DropdownMenu position="bottom-start"> <DropdownMenu.Target> - <Button - compact - fw="600" - size="md" - variant="subtle" - > - <RiMoreFill size="1.3rem" /> - </Button> + <MoreButton /> </DropdownMenu.Target> <DropdownMenu.Dropdown> <DropdownMenu.Item - icon={<RiPlayFill />} + leftSection={<Icon icon="mediaPlay" />} onClick={() => handlePlay?.({ playType: Play.NOW })} > {t('player.play', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiShuffleFill />} + leftSection={<Icon icon="mediaShuffle" />} onClick={() => handlePlay?.({ playType: Play.SHUFFLE })} > {t('player.shuffle', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddBoxFill />} + leftSection={<Icon icon="mediaPlayLast" />} onClick={() => handlePlay?.({ playType: Play.LAST })} > {t('player.addLast', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiAddCircleFill />} + leftSection={<Icon icon="mediaPlayNext" />} onClick={() => handlePlay?.({ playType: Play.NEXT })} > {t('player.addNext', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Divider /> <DropdownMenu.Item - icon={<RiRefreshLine />} + leftSection={<Icon icon="refresh" />} onClick={handleRefresh} > {t('common.refresh', { postProcess: 'titleCase' })} @@ -624,116 +579,22 @@ export const SongListHeaderFilters = ({ </DropdownMenu> </Group> <Group - noWrap - spacing="sm" + gap="sm" + wrap="nowrap" > - <DropdownMenu - position="bottom-end" - width={425} - > - <DropdownMenu.Target> - <Button - compact - size="md" - variant="subtle" - > - <RiSettings3Fill size="1.3rem" /> - </Button> - </DropdownMenu.Target> - <DropdownMenu.Dropdown> - <DropdownMenu.Label> - {t('table.config.general.displayType', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item - $isActive={display === ListDisplayType.CARD} - onClick={handleSetViewType} - value={ListDisplayType.CARD} - > - {t('table.config.view.card', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.POSTER} - onClick={handleSetViewType} - value={ListDisplayType.POSTER} - > - {t('table.config.view.poster', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE} - onClick={handleSetViewType} - value={ListDisplayType.TABLE} - > - {t('table.config.view.table', { postProcess: 'sentenceCase' })} - </DropdownMenu.Item> - {/* <DropdownMenu.Item - $isActive={display === ListDisplayType.TABLE_PAGINATED} - value={ListDisplayType.TABLE_PAGINATED} - onClick={handleSetViewType} - > - Table (paginated) - </DropdownMenu.Item> */} - <DropdownMenu.Divider /> - <DropdownMenu.Label> - {t('table.config.general.size', { postProcess: 'sentenceCase' })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight} - max={isGrid ? 300 : 100} - min={isGrid ? 100 : 25} - onChangeEnd={handleItemSize} - /> - </DropdownMenu.Item> - {isGrid && ( - <> - <DropdownMenu.Label> - {t('table.config.general.gap', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item closeMenuOnClick={false}> - <Slider - defaultValue={grid?.itemGap || 0} - max={30} - min={0} - onChangeEnd={handleItemGap} - /> - </DropdownMenu.Item> - </> - )} - <DropdownMenu.Label> - {t('table.config.general.tableColumns', { - postProcess: 'sentenceCase', - })} - </DropdownMenu.Label> - <DropdownMenu.Item - closeMenuOnClick={false} - component="div" - sx={{ cursor: 'default' }} - > - <Stack> - <MultiSelect - clearable - data={SONG_TABLE_COLUMNS} - defaultValue={table?.columns.map((column) => column.column)} - onChange={handleTableColumns} - width={300} - /> - <Group position="apart"> - <Text> - {t('table.config.general.autoFitColumns', { - postProcess: 'sentenceCase', - })} - </Text> - <Switch - defaultChecked={table.autoFit} - onChange={handleAutoFitColumns} - /> - </Group> - </Stack> - </DropdownMenu.Item> - </DropdownMenu.Dropdown> - </DropdownMenu> + <ListConfigMenu + autoFitColumns={table.autoFit} + displayType={display} + itemGap={grid?.itemGap || 0} + itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight} + onChangeAutoFitColumns={handleAutoFitColumns} + onChangeDisplayType={handleSetViewType} + onChangeItemGap={handleItemGap} + onChangeItemSize={debouncedHandleItemSize} + onChangeTableColumns={handleTableColumns} + tableColumns={table?.columns.map((column) => column.column)} + tableColumnsData={SONG_TABLE_COLUMNS} + /> </Group> </Flex> ); diff --git a/src/renderer/features/songs/components/song-list-header.tsx b/src/renderer/features/songs/components/song-list-header.tsx index ec6b9242..f2abc778 100644 --- a/src/renderer/features/songs/components/song-list-header.tsx +++ b/src/renderer/features/songs/components/song-list-header.tsx @@ -1,18 +1,21 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, MutableRefObject, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { PageHeader, SearchInput } from '/@/renderer/components'; +import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters'; import { useContainerQuery } from '/@/renderer/hooks'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { SongListFilter, useCurrentServer } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; import { LibraryItem, SongListQuery } from '/@/shared/types/domain-types'; interface SongListHeaderProps { @@ -68,10 +71,10 @@ export const SongListHeader = ({ return ( <Stack + gap={0} ref={cq.ref} - spacing={0} > - <PageHeader backgroundColor="var(--titlebar-bg)"> + <PageHeader> <Flex justify="space-between" w="100%" @@ -93,7 +96,6 @@ export const SongListHeader = ({ <SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} - openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150} /> </Group> </Flex> diff --git a/src/renderer/features/songs/components/subsonic-song-filter.tsx b/src/renderer/features/songs/components/subsonic-song-filter.tsx index 9ce34807..202a2075 100644 --- a/src/renderer/features/songs/components/subsonic-song-filter.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filter.tsx @@ -1,11 +1,15 @@ -import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Select, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; interface SubsonicSongFiltersProps { @@ -81,8 +85,8 @@ export const SubsonicSongFilters = ({ <Stack p="0.8rem"> {toggleFilters.map((filter) => ( <Group + justify="space-between" key={`ss-filter-${filter.label}`} - position="apart" > <Text>{filter.label}</Text> <Switch diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 19d1fca5..ec2b41fc 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -1,27 +1,11 @@ -import { Group } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; -import { - RiArrowLeftSLine, - RiArrowRightSLine, - RiCloseCircleLine, - RiEdit2Line, - RiExternalLinkLine, - RiGithubLine, - RiLayoutLeftLine, - RiLayoutRightLine, - RiLockLine, - RiServerLine, - RiSettings3Line, - RiWindowFill, -} from 'react-icons/ri'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; import packageJson from '../../../../../package.json'; -import { DropdownMenu } from '/@/renderer/components'; import { ServerList } from '/@/renderer/features/servers'; import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form'; import { AppRoute } from '/@/renderer/router/routes'; @@ -32,6 +16,8 @@ import { useServerList, useSidebarStore, } from '/@/renderer/store'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Icon } from '/@/shared/components/icon/icon'; import { ServerListItem, ServerType } from '/@/shared/types/domain-types'; const browser = isElectron() ? window.api.browser : null; @@ -101,27 +87,27 @@ export const AppMenu = () => { return ( <> <DropdownMenu.Item - icon={<RiArrowLeftSLine />} + leftSection={<Icon icon="arrowLeftS" />} onClick={() => navigate(-1)} > {t('page.appMenu.goBack', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiArrowRightSLine />} + leftSection={<Icon icon="arrowRightS" />} onClick={() => navigate(1)} > {t('page.appMenu.goForward', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> {collapsed ? ( <DropdownMenu.Item - icon={<RiLayoutRightLine />} + leftSection={<Icon icon="panelRightOpen" />} onClick={handleExpandSidebar} > {t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> ) : ( <DropdownMenu.Item - icon={<RiLayoutLeftLine />} + leftSection={<Icon icon="panelRightClose" />} onClick={handleCollapseSidebar} > {t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' })} @@ -130,13 +116,13 @@ export const AppMenu = () => { <DropdownMenu.Divider /> <DropdownMenu.Item component={Link} - icon={<RiSettings3Line />} + leftSection={<Icon icon="settings" />} to={AppRoute.SETTINGS} > {t('page.appMenu.settings', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiEdit2Line />} + leftSection={<Icon icon="edit" />} onClick={handleManageServersModal} > {t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })} @@ -155,21 +141,26 @@ export const AppMenu = () => { return ( <DropdownMenu.Item - $isActive={server.id === currentServer?.id} - icon={ + key={`server-${server.id}`} + leftSection={ isSessionExpired ? ( - <RiLockLine color="var(--danger-color)" /> + <Icon + fill="error" + icon="lock" + /> ) : ( - <RiServerLine /> + <Icon + color={server.id === currentServer?.id ? 'primary' : undefined} + icon="server" + /> ) } - key={`server-${server.id}`} onClick={() => { if (!isSessionExpired) return handleSetCurrentServer(server); return handleCredentialsModal(server); }} > - <Group>{server.name}</Group> + {server.name} </DropdownMenu.Item> ); })} @@ -177,8 +168,8 @@ export const AppMenu = () => { <DropdownMenu.Item component="a" href="https://github.com/jeffvli/feishin/releases" - icon={<RiGithubLine />} - rightSection={<RiExternalLinkLine />} + leftSection={<Icon icon="brandGitHub" />} + rightSection={<Icon icon="externalLink" />} target="_blank" > {t('page.appMenu.version', { @@ -190,13 +181,13 @@ export const AppMenu = () => { <> <DropdownMenu.Divider /> <DropdownMenu.Item - icon={<RiWindowFill />} + leftSection={<Icon icon="appWindow" />} onClick={handleBrowserDevTools} > {t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' })} </DropdownMenu.Item> <DropdownMenu.Item - icon={<RiCloseCircleLine />} + leftSection={<Icon icon="x" />} onClick={handleQuit} > {t('page.appMenu.quit', { postProcess: 'sentenceCase' })} diff --git a/src/renderer/features/titlebar/components/titlebar.module.css b/src/renderer/features/titlebar/components/titlebar.module.css new file mode 100644 index 00000000..93a47dba --- /dev/null +++ b/src/renderer/features/titlebar/components/titlebar.module.css @@ -0,0 +1,17 @@ +.titlebar-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + button { + -webkit-app-region: no-drag; + } +} + +.right { + display: flex; + flex: 1/3; + justify-content: center; + height: 100%; +} diff --git a/src/renderer/features/titlebar/components/titlebar.tsx b/src/renderer/features/titlebar/components/titlebar.tsx index 5e710a88..39482d1a 100644 --- a/src/renderer/features/titlebar/components/titlebar.tsx +++ b/src/renderer/features/titlebar/components/titlebar.tsx @@ -1,61 +1,25 @@ import type { ReactNode } from 'react'; -import { Group } from '@mantine/core'; -import styled from 'styled-components'; +import styles from './titlebar.module.css'; import { WindowControls } from '/@/renderer/features/window-controls'; +import { Group } from '/@/shared/components/group/group'; interface TitlebarProps { children?: ReactNode; } -const TitlebarContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - color: var(--titlebar-fg); - - button { - -webkit-app-region: no-drag; - } -`; - -// const Left = styled.div` -// display: flex; -// flex: 1/3; -// justify-content: center; -// height: 100%; -// padding-left: 1rem; -// opacity: 0; -// `; - -// const Center = styled.div` -// display: flex; -// flex: 1/3; -// justify-content: center; -// height: 100%; -// opacity: 0; -// `; - -const Right = styled.div` - display: flex; - flex: 1/3; - justify-content: center; - height: 100%; -`; - export const Titlebar = ({ children }: TitlebarProps) => { return ( <> - <TitlebarContainer> - <Right> + <div className={styles.titlebarContainer}> + <div className={styles.right}> {children} - <Group spacing="xs"> + <Group gap="xs"> <WindowControls /> </Group> - </Right> - </TitlebarContainer> + </div> + </div> </> ); }; diff --git a/src/renderer/features/window-controls/components/window-controls.module.css b/src/renderer/features/window-controls/components/window-controls.module.css new file mode 100644 index 00000000..0d25094a --- /dev/null +++ b/src/renderer/features/window-controls/components/window-controls.module.css @@ -0,0 +1,29 @@ +.windows-button-group { + display: flex; + width: 130px; + height: 100%; + -webkit-app-region: no-drag; +} + +.windows-button { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + -webkit-app-region: no-drag; + width: 50px; + height: 65px; + + img { + width: 35%; + height: 50%; + } + + &:hover { + background: rgb(125 125 125 / 30%); + } +} + +.windows-button.exit-button { + background: var(--theme-colors-state-error); +} diff --git a/src/renderer/features/window-controls/components/window-controls.tsx b/src/renderer/features/window-controls/components/window-controls.tsx index 1f275b53..c6757ff7 100644 --- a/src/renderer/features/window-controls/components/window-controls.tsx +++ b/src/renderer/features/window-controls/components/window-controls.tsx @@ -1,7 +1,9 @@ +import clsx from 'clsx'; import isElectron from 'is-electron'; import { useState } from 'react'; import { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri'; -import styled from 'styled-components'; + +import styles from './window-controls.module.css'; const browser = isElectron() ? window.api.browser : null; @@ -9,32 +11,6 @@ interface WindowControlsProps { style?: 'linux' | 'macos' | 'windows'; } -const WindowsButtonGroup = styled.div` - display: flex; - width: 130px; - height: 100%; - -webkit-app-region: no-drag; -`; - -export const WindowsButton = styled.div<{ $exit?: boolean }>` - display: flex; - flex: 1; - align-items: center; - justify-content: center; - -webkit-app-region: no-drag; - width: 50px; - height: 65px; - - img { - width: 35%; - height: 50%; - } - - &:hover { - background: ${({ $exit }) => ($exit ? 'var(--danger-color)' : 'rgba(125, 125, 125, 30%)')}; - } -`; - const close = () => browser?.exit(); const minimize = () => browser?.minimize(); @@ -64,27 +40,27 @@ export const WindowControls = ({ style }: WindowControlsProps) => { {isElectron() && ( <> {style === 'windows' && ( - <WindowsButtonGroup> - <WindowsButton + <div className={styles.windowsButtonGroup}> + <div onClick={handleMinimize} role="button" > <RiSubtractLine size={19} /> - </WindowsButton> - <WindowsButton + </div> + <div onClick={handleMaximize} role="button" > <RiCheckboxBlankLine size={13} /> - </WindowsButton> - <WindowsButton - $exit + </div> + <div + className={clsx(styles.windowsButton, styles.exitButton)} onClick={handleClose} role="button" > <RiCloseLine size={19} /> - </WindowsButton> - </WindowsButtonGroup> + </div> + </div> )} </> )} diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index ded5edf5..42fc9dee 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -3,6 +3,4 @@ export * from './use-container-query'; export * from './use-fast-average-color'; export * from './use-hide-scrollbar'; export * from './use-is-mounted'; -export * from './use-is-overflow'; export * from './use-should-pad-titlebar'; -export * from './use-theme'; diff --git a/src/renderer/hooks/use-fast-average-color.tsx b/src/renderer/hooks/use-fast-average-color.tsx index 7b850cb4..9afaf6de 100644 --- a/src/renderer/hooks/use-fast-average-color.tsx +++ b/src/renderer/hooks/use-fast-average-color.tsx @@ -36,7 +36,7 @@ export const useFastAverageColor = (args: { }); } else if (srcLoaded) { idRef.current = id; - return setBackground('var(--placeholder-bg)'); + return setBackground('var(--theme-colors-foreground-muted)'); } return () => { diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index 2e106aa5..a7b081db 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -2,8 +2,8 @@ import { debounce } from 'lodash'; import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '/@/renderer/api'; -import { toast } from '/@/renderer/components'; import { useCurrentServer } from '/@/renderer/store'; +import { toast } from '/@/shared/components/toast/toast'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { AuthState, ServerListItem, ServerType } from '/@/shared/types/types'; diff --git a/src/renderer/hooks/use-theme.ts b/src/renderer/hooks/use-theme.ts deleted file mode 100644 index 1e475753..00000000 --- a/src/renderer/hooks/use-theme.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useSettingsStore } from '/@/renderer/store/settings.store'; -import { AppTheme } from '/@/shared/types/domain-types'; - -export const THEME_DATA = [ - { label: 'Default Dark', type: 'dark', value: AppTheme.DEFAULT_DARK }, - { label: 'Default Light', type: 'light', value: AppTheme.DEFAULT_LIGHT }, -]; - -export const useTheme = () => { - const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches; - const [isDarkTheme, setIsDarkTheme] = useState(getCurrentTheme()); - const { followSystemTheme, theme, themeDark, themeLight } = useSettingsStore( - (state) => state.general, - ); - - const mqListener = (e: any) => { - setIsDarkTheme(e.matches); - }; - - const getTheme = () => { - if (followSystemTheme) { - return isDarkTheme ? themeDark : themeLight; - } - - return theme; - }; - - const appTheme = getTheme(); - - useEffect(() => { - const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)'); - darkThemeMq.addListener(mqListener); - return () => darkThemeMq.removeListener(mqListener); - }, []); - - useEffect(() => { - document.body.setAttribute('data-theme', appTheme); - }, [appTheme]); - - return THEME_DATA.find((t) => t.value === appTheme)?.type || 'dark'; -}; diff --git a/src/renderer/is-updated-dialog.tsx b/src/renderer/is-updated-dialog.tsx index 934173f2..013db8e7 100644 --- a/src/renderer/is-updated-dialog.tsx +++ b/src/renderer/is-updated-dialog.tsx @@ -1,13 +1,19 @@ -import { Group, Stack } from '@mantine/core'; import { useLocalStorage } from '@mantine/hooks'; import { useCallback } from 'react'; -import { RiExternalLinkLine } from 'react-icons/ri'; +import { useTranslation } from 'react-i18next'; import packageJson from '../../package.json'; -import { Button, Dialog, Text } from './components'; + +import { Button } from '/@/shared/components/button/button'; +import { Dialog } from '/@/shared/components/dialog/dialog'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; export function IsUpdatedDialog() { const { version } = packageJson; + const { t } = useTranslation(); const [value, setValue] = useLocalStorage({ key: 'version' }); @@ -27,23 +33,23 @@ export function IsUpdatedDialog() { }} > <Stack> - <Text>A new version of Feishin has been installed ({version})</Text> - <Group noWrap> + <Text>{t('common.newVersion', { postProcess: 'sentenceCase', version })}</Text> + <Group wrap="nowrap"> <Button component="a" href={`https://github.com/jeffvli/feishin/releases/tag/v${version}`} onClick={handleDismiss} - rightIcon={<RiExternalLinkLine />} + rightSection={<Icon icon="externalLink" />} target="_blank" variant="filled" > - View release notes + {t('common.viewReleaseNotes', { postProcess: 'sentenceCase' })} </Button> <Button onClick={handleDismiss} variant="default" > - Dismiss + {t('common.dismiss', { postProcess: 'titleCase' })} </Button> </Group> </Stack> diff --git a/src/renderer/layouts/auth-layout.module.css b/src/renderer/layouts/auth-layout.module.css new file mode 100644 index 00000000..f65fe83e --- /dev/null +++ b/src/renderer/layouts/auth-layout.module.css @@ -0,0 +1,14 @@ +.window-titlebar-container { + position: absolute; + z-index: 1000; + display: flex; + width: 100%; + height: 50px; + user-select: none; + -webkit-app-region: drag; +} + +.content-container { + display: flex; + height: 100%; +} diff --git a/src/renderer/layouts/auth-layout.tsx b/src/renderer/layouts/auth-layout.tsx index 20a0d454..3348cb01 100644 --- a/src/renderer/layouts/auth-layout.tsx +++ b/src/renderer/layouts/auth-layout.tsx @@ -1,32 +1,18 @@ import { Outlet } from 'react-router-dom'; -import styled from 'styled-components'; + +import styles from './auth-layout.module.css'; import { Titlebar } from '/@/renderer/features/titlebar/components/titlebar'; -const WindowsTitlebarContainer = styled.div` - position: absolute; - z-index: 1000; - display: flex; - width: 100%; - height: 50px; - user-select: none; - -webkit-app-region: drag; -`; - -const ContentContainer = styled.div` - display: flex; - height: 100%; -`; - export const AuthLayout = () => { return ( <> - <WindowsTitlebarContainer> + <div className={styles.windowTitlebarContainer}> <Titlebar /> - </WindowsTitlebarContainer> - <ContentContainer> + </div> + <div className={styles.contentContainer}> <Outlet /> - </ContentContainer> + </div> </> ); }; diff --git a/src/renderer/layouts/default-layout.module.css b/src/renderer/layouts/default-layout.module.css new file mode 100644 index 00000000..605b728c --- /dev/null +++ b/src/renderer/layouts/default-layout.module.css @@ -0,0 +1,20 @@ +.layout { + display: grid; + grid-template-areas: + 'window-bar' + 'main-content' + 'player'; + grid-template-rows: 0 calc(100vh - 90px) 90px; + grid-template-columns: 1fr; + gap: 0; + height: 100%; + overflow: hidden; +} + +.windows { + grid-template-rows: 30px calc(100vh - 120px) 90px; +} + +.macos { + grid-template-rows: 30px calc(100vh - 120px) 90px; +} diff --git a/src/renderer/layouts/default-layout.tsx b/src/renderer/layouts/default-layout.tsx index 01f57b27..7473776d 100644 --- a/src/renderer/layouts/default-layout.tsx +++ b/src/renderer/layouts/default-layout.tsx @@ -1,8 +1,10 @@ import { HotkeyItem, useHotkeys } from '@mantine/hooks'; +import clsx from 'clsx'; import isElectron from 'is-electron'; import { lazy } from 'react'; import { useNavigate } from 'react-router'; -import styled from 'styled-components'; + +import styles from './default-layout.module.css'; import { CommandPalette } from '/@/renderer/features/search/components/command-palette'; import { MainContent } from '/@/renderer/layouts/default-layout/main-content'; @@ -26,22 +28,6 @@ if (!isElectron()) { }); } -const Layout = styled.div<{ $windowBarStyle: Platform }>` - display: grid; - grid-template-areas: - 'window-bar' - 'main-content' - 'player'; - grid-template-rows: ${(props) => - props.$windowBarStyle === Platform.WINDOWS || props.$windowBarStyle === Platform.MACOS - ? '30px calc(100vh - 120px) 90px' - : '0px calc(100vh - 90px) 90px'}; - grid-template-columns: 1fr; - gap: 0; - height: 100%; - overflow: hidden; -`; - const WindowBar = lazy(() => import('/@/renderer/layouts/window-bar').then((module) => ({ default: module.WindowBar, @@ -88,14 +74,17 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => { return ( <> - <Layout - $windowBarStyle={windowBarStyle} + <div + className={clsx(styles.layout, { + [styles.macos]: windowBarStyle === Platform.MACOS, + [styles.windows]: windowBarStyle === Platform.WINDOWS, + })} id="default-layout" > {windowBarStyle !== Platform.WEB && <WindowBar />} <MainContent shell={shell} /> <PlayerBar /> - </Layout> + </div> <CommandPalette modalProps={{ handlers, opened }} /> </> ); diff --git a/src/renderer/layouts/default-layout/full-screen-overlay.tsx b/src/renderer/layouts/default-layout/full-screen-overlay.tsx index ab281933..4322c616 100644 --- a/src/renderer/layouts/default-layout/full-screen-overlay.tsx +++ b/src/renderer/layouts/default-layout/full-screen-overlay.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence } from 'motion/react'; import { FullScreenPlayer } from '/@/renderer/features/player/components/full-screen-player'; import { useFullScreenPlayerStore } from '/@/renderer/store'; diff --git a/src/renderer/layouts/default-layout/left-sidebar.module.css b/src/renderer/layouts/default-layout/left-sidebar.module.css new file mode 100644 index 00000000..b67a5c06 --- /dev/null +++ b/src/renderer/layouts/default-layout/left-sidebar.module.css @@ -0,0 +1,5 @@ +.container { + position: relative; + grid-area: sidebar; + border-right: 1px solid alpha(var(--theme-colors-border), 0.3); +} diff --git a/src/renderer/layouts/default-layout/left-sidebar.tsx b/src/renderer/layouts/default-layout/left-sidebar.tsx index a1fd6047..49d53049 100644 --- a/src/renderer/layouts/default-layout/left-sidebar.tsx +++ b/src/renderer/layouts/default-layout/left-sidebar.tsx @@ -1,18 +1,12 @@ import { useRef } from 'react'; -import styled from 'styled-components'; + +import styles from './left-sidebar.module.css'; import { ResizeHandle } from '/@/renderer/features/shared'; import { CollapsedSidebar } from '/@/renderer/features/sidebar/components/collapsed-sidebar'; import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar'; import { useSidebarStore } from '/@/renderer/store'; -const SidebarContainer = styled.aside` - position: relative; - grid-area: sidebar; - background: var(--sidebar-bg); - border-right: var(--sidebar-border); -`; - interface LeftSidebarProps { isResizing: boolean; startResizing: (direction: 'left' | 'right') => void; @@ -23,17 +17,20 @@ export const LeftSidebar = ({ isResizing, startResizing }: LeftSidebarProps) => const { collapsed } = useSidebarStore(); return ( - <SidebarContainer id="sidebar"> + <aside + className={styles.container} + id="sidebar" + > <ResizeHandle - $isResizing={isResizing} - $placement="right" + isResizing={isResizing} onMouseDown={(e) => { e.preventDefault(); startResizing('left'); }} + placement="right" ref={sidebarRef} /> {collapsed ? <CollapsedSidebar /> : <Sidebar />} - </SidebarContainer> + </aside> ); }; diff --git a/src/renderer/layouts/default-layout/main-content.module.css b/src/renderer/layouts/default-layout/main-content.module.css new file mode 100644 index 00000000..79d038fe --- /dev/null +++ b/src/renderer/layouts/default-layout/main-content.module.css @@ -0,0 +1,28 @@ +.main-content-container { + position: relative; + display: grid; + grid-area: main-content; + grid-template-areas: 'sidebar . right-sidebar'; + grid-template-rows: 1fr; + gap: 0; +} + +.main-content-container.shell { + display: flex; +} + +.main-content-container.sidebar-collapsed { + grid-template-columns: 80px 1fr; +} + +.main-content-container.sidebar-expanded { + grid-template-columns: var(--sidebar-width) 1fr; +} + +.main-content-container.right-expanded { + grid-template-columns: var(--sidebar-width) 1fr var(--right-sidebar-width); +} + +.main-content-container.sidebar-collapsed.right-expanded { + grid-template-columns: 80px 1fr var(--right-sidebar-width); +} diff --git a/src/renderer/layouts/default-layout/main-content.tsx b/src/renderer/layouts/default-layout/main-content.tsx index 0857d3ac..d1d0abd9 100644 --- a/src/renderer/layouts/default-layout/main-content.tsx +++ b/src/renderer/layouts/default-layout/main-content.tsx @@ -1,9 +1,20 @@ -import { throttle } from 'lodash'; -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import throttle from 'lodash/throttle'; +import { motion } from 'motion/react'; +import { + CSSProperties, + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Outlet, useLocation } from 'react-router'; -import styled from 'styled-components'; -import { Spinner } from '/@/renderer/components'; +import styles from './main-content.module.css'; + import { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay'; import { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar'; import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar'; @@ -11,6 +22,7 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useAppStoreActions, useSidebarStore } from '/@/renderer/store'; import { useGeneralSettings } from '/@/renderer/store/settings.store'; import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils'; +import { Spinner } from '/@/shared/components/spinner/spinner'; const SideDrawerQueue = lazy(() => import('/@/renderer/layouts/default-layout/side-drawer-queue').then((module) => ({ @@ -20,26 +32,6 @@ const SideDrawerQueue = lazy(() => const MINIMUM_SIDEBAR_WIDTH = 260; -const MainContentContainer = styled.div<{ - $leftSidebarWidth: string; - $rightExpanded?: boolean; - $rightSidebarWidth?: string; - $shell?: boolean; - $sidebarCollapsed?: boolean; -}>` - position: relative; - display: ${(props) => (props.$shell ? 'flex' : 'grid')}; - grid-area: main-content; - grid-template-areas: 'sidebar . right-sidebar'; - grid-template-rows: 1fr; - grid-template-columns: ${(props) => - props.$sidebarCollapsed ? '80px' : props.$leftSidebarWidth} 1fr ${(props) => - props.$rightExpanded && props.$rightSidebarWidth}; - - gap: 0; - background: var(--main-bg); -`; - export const MainContent = ({ shell }: { shell?: boolean }) => { const location = useLocation(); const { collapsed, leftWidth, rightExpanded, rightWidth } = useSidebarStore(); @@ -96,13 +88,20 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { }, [throttledResize, stopResizing]); return ( - <MainContentContainer - $leftSidebarWidth={leftWidth} - $rightExpanded={showSideQueue && sideQueueType === 'sideQueue'} - $rightSidebarWidth={rightWidth} - $shell={shell} - $sidebarCollapsed={collapsed} + <motion.div + className={clsx(styles.mainContentContainer, { + [styles.rightExpanded]: showSideQueue && sideQueueType === 'sideQueue', + [styles.shell]: shell, + [styles.sidebarCollapsed]: collapsed, + [styles.sidebarExpanded]: !collapsed, + })} id="main-content" + style={ + { + '--right-sidebar-width': rightWidth, + '--sidebar-width': leftWidth, + } as CSSProperties + } > {!shell && ( <> @@ -124,6 +123,6 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { <Suspense fallback={<Spinner container />}> <Outlet /> </Suspense> - </MainContentContainer> + </motion.div> ); }; diff --git a/src/renderer/layouts/default-layout/player-bar.module.css b/src/renderer/layouts/default-layout/player-bar.module.css new file mode 100644 index 00000000..ae01b63f --- /dev/null +++ b/src/renderer/layouts/default-layout/player-bar.module.css @@ -0,0 +1,12 @@ +.container { + z-index: 200; + grid-area: player; + background: darken(var(--theme-colors-background), 10%); + transition: background 0.5s; +} + +.open-drawer { + &:hover { + background: darken(var(--theme-colors-background), 20%); + } +} diff --git a/src/renderer/layouts/default-layout/player-bar.tsx b/src/renderer/layouts/default-layout/player-bar.tsx index daed625e..3b5aa9fd 100644 --- a/src/renderer/layouts/default-layout/player-bar.tsx +++ b/src/renderer/layouts/default-layout/player-bar.tsx @@ -1,36 +1,22 @@ -import styled from 'styled-components'; +import clsx from 'clsx'; + +import styles from './player-bar.module.css'; import { Playerbar } from '/@/renderer/features/player'; import { useGeneralSettings } from '/@/renderer/store/settings.store'; -interface PlayerbarContainerProps { - $drawerEffect: boolean; -} - -const PlayerbarContainer = styled.footer<PlayerbarContainerProps>` - z-index: 200; - grid-area: player; - background: var(--playerbar-bg); - transition: background 0.5s; - - ${(props) => - props.$drawerEffect && - ` - &:hover { - background: var(--playerbar-bg-active); - } - `} -`; - export const PlayerBar = () => { const { playerbarOpenDrawer } = useGeneralSettings(); return ( - <PlayerbarContainer - $drawerEffect={playerbarOpenDrawer} + <div + className={clsx({ + [styles.container]: true, + [styles.openDrawer]: playerbarOpenDrawer, + })} id="player-bar" > <Playerbar /> - </PlayerbarContainer> + </div> ); }; diff --git a/src/renderer/layouts/default-layout/right-sidebar.module.css b/src/renderer/layouts/default-layout/right-sidebar.module.css new file mode 100644 index 00000000..153ef8f6 --- /dev/null +++ b/src/renderer/layouts/default-layout/right-sidebar.module.css @@ -0,0 +1,15 @@ +.right-sidebar-container { + position: relative; + grid-area: right-sidebar; + height: 100%; + border-left: 1px solid alpha(var(--theme-colors-border), 0.3); + + .current-song-cell:not(.current-playlist-song-cell) svg { + display: none; + } +} + +.queue-drawer { + background: var(--theme-colors-background); + border-radius: var(--theme-radius-lg); +} diff --git a/src/renderer/layouts/default-layout/right-sidebar.tsx b/src/renderer/layouts/default-layout/right-sidebar.tsx index 5a4c6d04..5674b577 100644 --- a/src/renderer/layouts/default-layout/right-sidebar.tsx +++ b/src/renderer/layouts/default-layout/right-sidebar.tsx @@ -1,7 +1,8 @@ -import { AnimatePresence, motion, Variants } from 'framer-motion'; +import { AnimatePresence, motion, Variants } from 'motion/react'; import { forwardRef, Ref } from 'react'; import { useLocation } from 'react-router'; -import styled from 'styled-components'; + +import styles from './right-sidebar.module.css'; import { DrawerPlayQueue, SidebarPlayQueue } from '/@/renderer/features/now-playing'; import { ResizeHandle } from '/@/renderer/features/shared'; @@ -9,18 +10,6 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useGeneralSettings, useSidebarStore, useWindowSettings } from '/@/renderer/store'; import { Platform } from '/@/shared/types/types'; -const RightSidebarContainer = styled(motion.aside)` - position: relative; - grid-area: right-sidebar; - height: 100%; - background: var(--sidebar-bg); - border-left: var(--sidebar-border); - - .current-song-cell:not(.current-playlist-song-cell) svg { - display: none; - } -`; - const queueSidebarVariants: Variants = { closed: (rightWidth) => ({ transition: { duration: 0.5 }, @@ -39,12 +28,6 @@ const queueSidebarVariants: Variants = { }), }; -const QueueDrawer = styled(motion.div)` - background: var(--main-bg); - border: 3px solid var(--generic-border-color); - border-radius: 10px; -`; - const queueDrawerVariants: Variants = { closed: (windowBarStyle) => ({ height: @@ -109,8 +92,9 @@ export const RightSidebar = forwardRef( {showSideQueue && ( <> {sideQueueType === 'sideQueue' ? ( - <RightSidebarContainer + <motion.aside animate="open" + className={styles.rightSidebarContainer} custom={rightWidth} exit="closed" id="sidebar-queue" @@ -119,19 +103,20 @@ export const RightSidebar = forwardRef( variants={queueSidebarVariants} > <ResizeHandle - $isResizing={isResizingRight} - $placement="left" + isResizing={isResizingRight} onMouseDown={(e) => { e.preventDefault(); startResizing('right'); }} + placement="left" ref={ref} /> <SidebarPlayQueue /> - </RightSidebarContainer> + </motion.aside> ) : ( - <QueueDrawer + <motion.div animate="open" + className={styles.queueDrawer} custom={windowBarStyle} exit="closed" id="drawer-queue" @@ -140,7 +125,7 @@ export const RightSidebar = forwardRef( variants={queueDrawerVariants} > <DrawerPlayQueue /> - </QueueDrawer> + </motion.div> )} </> )} diff --git a/src/renderer/layouts/default-layout/side-drawer-queue.module.css b/src/renderer/layouts/default-layout/side-drawer-queue.module.css new file mode 100644 index 00000000..fb37aaa5 --- /dev/null +++ b/src/renderer/layouts/default-layout/side-drawer-queue.module.css @@ -0,0 +1,17 @@ +.queue-drawer-area { + position: absolute; + top: 50%; + right: 25px; + z-index: 100; + display: flex; + align-items: center; + width: 20px; + height: 30px; + user-select: none; +} + +.queue-drawer { + background: var(--theme-colors-background); + border: 3px solid var(--theme-generic-border-color); + border-radius: var(--theme-radius-lg); +} diff --git a/src/renderer/layouts/default-layout/side-drawer-queue.tsx b/src/renderer/layouts/default-layout/side-drawer-queue.tsx index 2c684e9f..a0e27d28 100644 --- a/src/renderer/layouts/default-layout/side-drawer-queue.tsx +++ b/src/renderer/layouts/default-layout/side-drawer-queue.tsx @@ -1,33 +1,16 @@ import { useDisclosure, useTimeout } from '@mantine/hooks'; -import { AnimatePresence, motion, Variants } from 'framer-motion'; +import { AnimatePresence, motion, Variants } from 'motion/react'; import { useCallback } from 'react'; -import { TbArrowBarLeft } from 'react-icons/tb'; import { useLocation } from 'react-router'; -import styled from 'styled-components'; + +import styles from './side-drawer-queue.module.css'; import { DrawerPlayQueue } from '/@/renderer/features/now-playing'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStore, useSidebarStore } from '/@/renderer/store'; +import { Icon } from '/@/shared/components/icon/icon'; import { Platform } from '/@/shared/types/types'; -const QueueDrawerArea = styled(motion.div)` - position: absolute; - top: 50%; - right: 25px; - z-index: 100; - display: flex; - align-items: center; - width: 20px; - height: 30px; - user-select: none; -`; - -const QueueDrawer = styled(motion.div)` - background: var(--main-bg); - border: 3px solid var(--generic-border-color); - border-radius: 10px; -`; - const queueDrawerVariants: Variants = { closed: (windowBarStyle) => ({ height: @@ -98,45 +81,48 @@ export const SideDrawerQueue = () => { !rightExpanded && !drawer && location.pathname !== AppRoute.NOW_PLAYING; return ( - <> - <AnimatePresence - initial={false} - mode="wait" - > - {isQueueDrawerButtonVisible && ( - <QueueDrawerArea - animate="visible" - exit="hidden" - initial="hidden" - key="queue-drawer-button" - onMouseEnter={handleEnterDrawerButton} - onMouseLeave={handleLeaveDrawerButton} - variants={queueDrawerButtonVariants} - whileHover={{ opacity: 1, scale: 2, transition: { duration: 0.5 } }} - > - <TbArrowBarLeft size={12} /> - </QueueDrawerArea> - )} + <AnimatePresence + initial={false} + mode="wait" + > + {isQueueDrawerButtonVisible && ( + <motion.div + animate="visible" + className={styles.queueDrawerArea} + exit="hidden" + initial="hidden" + key="queue-drawer-button" + onMouseEnter={handleEnterDrawerButton} + onMouseLeave={handleLeaveDrawerButton} + variants={queueDrawerButtonVariants} + whileHover={{ opacity: 1, scale: 2, transition: { duration: 0.5 } }} + > + <Icon + icon="arrowLeftToLine" + size="lg" + /> + </motion.div> + )} - {drawer && ( - <QueueDrawer - animate="open" - exit="closed" - initial="closed" - key="queue-drawer" - onMouseLeave={() => { - // The drawer will close due to the delay when setting isReorderingQueue - setTimeout(() => { - if (useAppStore.getState().isReorderingQueue) return; - drawerHandler.close(); - }, 50); - }} - variants={queueDrawerVariants} - > - <DrawerPlayQueue /> - </QueueDrawer> - )} - </AnimatePresence> - </> + {drawer && ( + <motion.div + animate="open" + className={styles.queueDrawer} + exit="closed" + initial="closed" + key="queue-drawer" + onMouseLeave={() => { + // The drawer will close due to the delay when setting isReorderingQueue + setTimeout(() => { + if (useAppStore.getState().isReorderingQueue) return; + drawerHandler.close(); + }, 50); + }} + variants={queueDrawerVariants} + > + <DrawerPlayQueue /> + </motion.div> + )} + </AnimatePresence> ); }; diff --git a/src/renderer/layouts/index.ts b/src/renderer/layouts/index.ts deleted file mode 100644 index 6311d3c6..00000000 --- a/src/renderer/layouts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './auth-layout'; -export * from './default-layout'; diff --git a/src/renderer/layouts/window-bar.module.css b/src/renderer/layouts/window-bar.module.css new file mode 100644 index 00000000..8a907a8f --- /dev/null +++ b/src/renderer/layouts/window-bar.module.css @@ -0,0 +1,91 @@ +.windows-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100vw; + -webkit-app-region: drag; +} + +.windows-button-group { + display: flex; + width: 130px; + height: 100%; + -webkit-app-region: no-drag; +} + +.windows-button { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + -webkit-app-region: no-drag; + width: 50px; + height: 30px; + + img { + width: 35%; + height: 50%; + } + + &:hover { + background: rgb(125 125 125 / 30%); + } +} + +.windows-button.exit { + &:hover { + background: var(--theme-colors-state-error); + } +} + +.player-status-container { + display: flex; + gap: 0.5rem; + max-width: 45vw; + padding-left: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.macos-container { + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + -webkit-app-region: drag; +} + +.macos-button-group { + position: absolute; + top: 5px; + left: 0.5rem; + display: grid; + grid-template-columns: repeat(3, 20px); + height: 100%; + -webkit-app-region: no-drag; +} + +.macos-button { + grid-row: 1 / span 1; + grid-column: 1; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + user-select: none; + + img { + width: 18px; + height: 18px; + } +} + +.macos-button.min-button { + grid-column: 2; +} + +.macos-button.max-button, +.macos-button.restore-button { + grid-column: 3; +} diff --git a/src/renderer/layouts/window-bar.tsx b/src/renderer/layouts/window-bar.tsx index 579a10e4..4fa5ac74 100644 --- a/src/renderer/layouts/window-bar.tsx +++ b/src/renderer/layouts/window-bar.tsx @@ -1,7 +1,7 @@ +import clsx from 'clsx'; import isElectron from 'is-electron'; import { useCallback, useState } from 'react'; import { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri'; -import styled from 'styled-components'; import appIcon from '../../../assets/icons/32x32.png'; import macCloseHover from './assets/close-mac-hover.png'; @@ -10,59 +10,15 @@ import macMaxHover from './assets/max-mac-hover.png'; import macMax from './assets/max-mac.png'; import macMinHover from './assets/min-mac-hover.png'; import macMin from './assets/min-mac.png'; +import styles from './window-bar.module.css'; import { useCurrentStatus, useQueueStatus } from '/@/renderer/store'; import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { Text } from '/@/shared/components/text/text'; import { Platform, PlayerStatus } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; -const WindowsContainer = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - width: 100vw; - color: var(--window-bar-fg); - background-color: var(--window-bar-bg); - -webkit-app-region: drag; -`; - -const WindowsButtonGroup = styled.div` - display: flex; - width: 130px; - height: 100%; - -webkit-app-region: no-drag; -`; - -const WindowsButton = styled.div<{ $exit?: boolean }>` - display: flex; - flex: 1; - align-items: center; - justify-content: center; - -webkit-app-region: no-drag; - width: 50px; - height: 30px; - - img { - width: 35%; - height: 50%; - } - - &:hover { - background: ${({ $exit }) => ($exit ? 'var(--danger-color)' : 'rgba(125, 125, 125, 30%)')}; - } -`; - -const PlayerStatusContainer = styled.div` - display: flex; - gap: 0.5rem; - max-width: 45vw; - padding-left: 1rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - const browser = isElectron() ? window.api.browser : null; const close = () => browser?.exit(); const minimize = () => browser?.minimize(); @@ -82,82 +38,43 @@ const WindowsControls = ({ controls, title }: WindowBarControlsProps) => { const { handleClose, handleMaximize, handleMinimize } = controls; return ( - <WindowsContainer> - <PlayerStatusContainer> + <div className={styles.windowsContainer}> + <div className={styles.playerStatusContainer}> <img alt="" height={18} src={appIcon} width={18} /> - {title} - </PlayerStatusContainer> - <WindowsButtonGroup> - <WindowsButton + <Text>{title}</Text> + </div> + <div className={styles.windowsButtonGroup}> + <div + className={styles.windowsButton} onClick={handleMinimize} role="button" > <RiSubtractLine size={19} /> - </WindowsButton> - <WindowsButton + </div> + <div + className={styles.windowsButton} onClick={handleMaximize} role="button" > <RiCheckboxBlankLine size={13} /> - </WindowsButton> - <WindowsButton - $exit + </div> + <div + className={clsx(styles.windowsButton, styles.exit)} onClick={handleClose} role="button" > <RiCloseLine size={19} /> - </WindowsButton> - </WindowsButtonGroup> - </WindowsContainer> + </div> + </div> + </div> ); }; -const MacOsContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 100vw; - -webkit-app-region: drag; - color: var(--window-bar-fg); - background-color: var(--window-bar-bg); -`; - -const MacOsButtonGroup = styled.div` - position: absolute; - top: 5px; - left: 0.5rem; - display: grid; - grid-template-columns: repeat(3, 20px); - height: 100%; - - -webkit-app-region: no-drag; -`; - -export const MacOsButton = styled.div<{ - $maxButton?: boolean; - $minButton?: boolean; - $restoreButton?: boolean; -}>` - grid-row: 1 / span 1; - grid-column: ${(props) => - props.$minButton ? 2 : props.$maxButton || props.$restoreButton ? 3 : 1}; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - user-select: none; - - img { - width: 18px; - height: 18px; - } -`; - const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { const { handleClose, handleMaximize, handleMinimize } = controls; @@ -166,11 +83,10 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { const [hoverClose, setHoverClose] = useState(false); return ( - <MacOsContainer> - <MacOsButtonGroup> - <MacOsButton - $minButton - className="button" + <div className={styles.macosContainer}> + <div className={styles.macosButtonGroup}> + <div + className={clsx(styles.macosButton, styles.minButton)} id="min-button" onClick={handleMinimize} onMouseLeave={() => setHoverMin(false)} @@ -182,10 +98,9 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { draggable="false" src={hoverMin ? macMinHover : macMin} /> - </MacOsButton> - <MacOsButton - $maxButton - className="button" + </div> + <div + className={clsx(styles.macosButton, styles.maxButton)} id="max-button" onClick={handleMaximize} onMouseLeave={() => setHoverMax(false)} @@ -197,9 +112,9 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { draggable="false" src={hoverMax ? macMaxHover : macMax} /> - </MacOsButton> - <MacOsButton - className="button" + </div> + <div + className={clsx(styles.macosButton)} id="close-button" onClick={handleClose} onMouseLeave={() => setHoverClose(false)} @@ -211,10 +126,12 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { draggable="false" src={hoverClose ? macCloseHover : macClose} /> - </MacOsButton> - </MacOsButtonGroup> - <PlayerStatusContainer>{title}</PlayerStatusContainer> - </MacOsContainer> + </div> + </div> + <div className={styles.playerStatusContainer}> + <Text>{title}</Text> + </div> + </div> ); }; diff --git a/src/renderer/lib/react-query.ts b/src/renderer/lib/react-query.ts index 08205543..0f58ae04 100644 --- a/src/renderer/lib/react-query.ts +++ b/src/renderer/lib/react-query.ts @@ -7,7 +7,7 @@ import type { import { QueryCache, QueryClient } from '@tanstack/react-query'; -import { toast } from '/@/renderer/components/toast/index'; +import { toast } from '/@/shared/components/toast/toast'; const queryCache = new QueryCache({ onError: (error: any, query) => { diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index d1846af9..a7579954 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,4 +1,3 @@ -import { Notifications } from '@mantine/notifications'; import { PersistedClient, Persister, @@ -6,9 +5,6 @@ import { } from '@tanstack/react-query-persist-client'; import { del, get, set } from 'idb-keyval'; import { createRoot } from 'react-dom/client'; -import 'overlayscrollbars/overlayscrollbars.css'; - -import './styles/overlayscrollbars.css'; import { App } from '/@/renderer/app'; import { queryClient } from '/@/renderer/lib/react-query'; @@ -57,11 +53,6 @@ createRoot(document.getElementById('root')!).render( persister: indexedDbPersister, }} > - <Notifications - containerWidth="300px" - position="bottom-center" - zIndex={5} - /> <App /> </PersistQueryClientProvider>, ); diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index 69994a42..be1e31a9 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -1,12 +1,13 @@ -import { Center } from '@mantine/core'; import isElectron from 'is-electron'; import { useEffect, useMemo } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; -import { Spinner, toast } from '/@/renderer/components'; import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useSetPlayerFallback } from '/@/renderer/store'; +import { Center } from '/@/shared/components/center/center'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { toast } from '/@/shared/components/toast/toast'; import { AuthState } from '/@/shared/types/types'; const ipc = isElectron() ? window.api.ipc : null; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index bf9e0afa..bfd70abe 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -1,16 +1,15 @@ -import { ModalsProvider } from '@mantine/modals'; import { lazy, Suspense } from 'react'; import { HashRouter, Route, Routes } from 'react-router-dom'; import { AppRoute } from './routes'; -import { BaseContextModal } from '/@/renderer/components'; import ArtistListRoute from '/@/renderer/features/artists/routes/artist-list-route'; import { AddToPlaylistContextModal } from '/@/renderer/features/playlists'; import { ShareItemContextModal } from '/@/renderer/features/sharing'; -import { DefaultLayout } from '/@/renderer/layouts'; +import { DefaultLayout } from '/@/renderer/layouts/default-layout'; import { AppOutlet } from '/@/renderer/router/app-outlet'; import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet'; +import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal'; const NowPlayingRoute = lazy( () => import('/@/renderer/features/now-playing/routes/now-playing-route'), @@ -72,18 +71,6 @@ export const AppRouter = () => { const router = ( <HashRouter future={{ v7_startTransition: true }}> <ModalsProvider - modalProps={{ - centered: true, - styles: { - body: { position: 'relative' }, - content: { overflow: 'auto' }, - }, - transitionProps: { - duration: 300, - exitDuration: 300, - transition: 'fade', - }, - }} modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal, diff --git a/src/renderer/router/titlebar-outlet.module.css b/src/renderer/router/titlebar-outlet.module.css new file mode 100644 index 00000000..5f121767 --- /dev/null +++ b/src/renderer/router/titlebar-outlet.module.css @@ -0,0 +1,8 @@ +.container { + position: absolute; + top: 0; + right: 0; + z-index: 5000; + height: 65px; + -webkit-app-region: drag; +} diff --git a/src/renderer/router/titlebar-outlet.tsx b/src/renderer/router/titlebar-outlet.tsx index ae8abfdf..9a847ef0 100644 --- a/src/renderer/router/titlebar-outlet.tsx +++ b/src/renderer/router/titlebar-outlet.tsx @@ -1,29 +1,20 @@ import { Outlet } from 'react-router'; -import styled from 'styled-components'; + +import styles from './titlebar-outlet.module.css'; import { Titlebar } from '/@/renderer/features/titlebar/components/titlebar'; import { useWindowSettings } from '/@/renderer/store/settings.store'; import { Platform } from '/@/shared/types/types'; -const TitlebarContainer = styled.header` - position: absolute; - top: 0; - right: 0; - z-index: 5000; - height: 65px; - background: var(--titlebar-controls-bg); - -webkit-app-region: drag; -`; - export const TitlebarOutlet = () => { const { windowBarStyle } = useWindowSettings(); return ( <> {windowBarStyle === Platform.WEB && ( - <TitlebarContainer> + <header className={styles.container}> <Titlebar /> - </TitlebarContainer> + </header> )} <Outlet /> </> diff --git a/src/renderer/store/album-artist-list-data.store.ts b/src/renderer/store/album-artist-list-data.store.ts index 098ad9a7..848fd261 100644 --- a/src/renderer/store/album-artist-list-data.store.ts +++ b/src/renderer/store/album-artist-list-data.store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; export interface AlbumArtistListDataSlice extends AlbumArtistListDataState { actions: { @@ -12,7 +12,7 @@ export interface AlbumArtistListDataState { itemData: any[]; } -export const useAlbumArtistListDataStore = create<AlbumArtistListDataSlice>()( +export const useAlbumArtistListDataStore = createWithEqualityFn<AlbumArtistListDataSlice>()( devtools( immer((set) => ({ actions: { diff --git a/src/renderer/store/album-artist.store.ts b/src/renderer/store/album-artist.store.ts index b6f8fbe2..6627988a 100644 --- a/src/renderer/store/album-artist.store.ts +++ b/src/renderer/store/album-artist.store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; import { DataTableProps } from '/@/renderer/store/settings.store'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; @@ -37,7 +37,7 @@ type TableProps = DataTableProps & { scrollOffset: number; }; -export const useAlbumArtistStore = create<AlbumArtistSlice>()( +export const useAlbumArtistStore = createWithEqualityFn<AlbumArtistSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/store/album-list-data.store.ts b/src/renderer/store/album-list-data.store.ts index b791684f..8ad95efd 100644 --- a/src/renderer/store/album-list-data.store.ts +++ b/src/renderer/store/album-list-data.store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; export interface AlbumListDataSlice extends AlbumListDataState { actions: { @@ -13,7 +13,7 @@ export interface AlbumListDataState { itemData: any[]; } -export const useAlbumListDataStore = create<AlbumListDataSlice>()( +export const useAlbumListDataStore = createWithEqualityFn<AlbumListDataSlice>()( devtools( immer((set) => ({ actions: { diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 67660c9d..83074f5d 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -1,7 +1,7 @@ import merge from 'lodash/merge'; -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; import { Platform } from '/@/shared/types/types'; @@ -42,7 +42,7 @@ type TitlebarProps = { outOfView: boolean; }; -export const useAppStore = create<AppSlice>()( +export const useAppStore = createWithEqualityFn<AppSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 36e9c292..80ad00a1 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -1,8 +1,8 @@ import merge from 'lodash/merge'; import { nanoid } from 'nanoid/non-secure'; -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; import { useAlbumArtistListDataStore } from '/@/renderer/store/album-artist-list-data.store'; import { useAlbumListDataStore } from '/@/renderer/store/album-list-data.store'; @@ -25,7 +25,7 @@ export interface AuthState { serverList: Record<string, ServerListItem>; } -export const useAuthStore = create<AuthSlice>()( +export const useAuthStore = createWithEqualityFn<AuthSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/store/event.store.ts b/src/renderer/store/event.store.ts index 42f030b8..9bd089d2 100644 --- a/src/renderer/store/event.store.ts +++ b/src/renderer/store/event.store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; import { devtools, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; export interface EventSlice extends EventState { actions: { @@ -32,7 +32,7 @@ export type RatingEvent = { export type UserEvent = FavoriteEvent | PlayEvent | RatingEvent; -export const useEventStore = create<EventSlice>()( +export const useEventStore = createWithEqualityFn<EventSlice>()( subscribeWithSelector( devtools( immer((set) => ({ diff --git a/src/renderer/store/full-screen-player.store.ts b/src/renderer/store/full-screen-player.store.ts index 3aa4eb4a..4955ac9f 100644 --- a/src/renderer/store/full-screen-player.store.ts +++ b/src/renderer/store/full-screen-player.store.ts @@ -1,7 +1,7 @@ import merge from 'lodash/merge'; -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; export interface FullScreenPlayerSlice extends FullScreenPlayerState { actions: { @@ -19,7 +19,7 @@ interface FullScreenPlayerState { useImageAspectRatio: boolean; } -export const useFullScreenPlayerStore = create<FullScreenPlayerSlice>()( +export const useFullScreenPlayerStore = createWithEqualityFn<FullScreenPlayerSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/store/list.store.ts b/src/renderer/store/list.store.ts index 16442a47..a6c75aa9 100644 --- a/src/renderer/store/list.store.ts +++ b/src/renderer/store/list.store.ts @@ -1,7 +1,7 @@ -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; import { DataTableProps, PersistedTableColumn } from '/@/renderer/store/settings.store'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; @@ -107,7 +107,7 @@ type FilterType = | PlaylistListFilter | SongListFilter; -export const useListStore = create<ListSlice>()( +export const useListStore = createWithEqualityFn<ListSlice>()( persist( devtools( immer((set, get) => ({ @@ -346,7 +346,7 @@ export const useListStore = create<ListSlice>()( detail: {}, item: { album: { - display: ListDisplayType.POSTER, + display: ListDisplayType.GRID, filter: { sortBy: AlbumListSort.RECENTLY_ADDED, sortOrder: SortOrder.DESC, @@ -387,7 +387,7 @@ export const useListStore = create<ListSlice>()( }, }, albumArtist: { - display: ListDisplayType.POSTER, + display: ListDisplayType.GRID, filter: { sortBy: AlbumArtistListSort.NAME, sortOrder: SortOrder.DESC, @@ -416,7 +416,7 @@ export const useListStore = create<ListSlice>()( }, }, albumArtistAlbum: { - display: ListDisplayType.POSTER, + display: ListDisplayType.GRID, filter: { sortBy: AlbumListSort.RECENTLY_ADDED, sortOrder: SortOrder.DESC, @@ -514,7 +514,7 @@ export const useListStore = create<ListSlice>()( }, }, artist: { - display: ListDisplayType.POSTER, + display: ListDisplayType.GRID, filter: { role: '', sortBy: AlbumArtistListSort.NAME, @@ -573,7 +573,7 @@ export const useListStore = create<ListSlice>()( }, }, playlist: { - display: ListDisplayType.POSTER, + display: ListDisplayType.GRID, filter: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.DESC, diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index f51757fb..52732462 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -2,10 +2,10 @@ import map from 'lodash/map'; import merge from 'lodash/merge'; import shuffle from 'lodash/shuffle'; import { nanoid } from 'nanoid/non-secure'; -import { create } from 'zustand'; import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; import { PlayerData, QueueData, QueueSong } from '/@/shared/types/domain-types'; import { Play, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; @@ -79,7 +79,7 @@ export interface PlayerState { volume: number; } -export const usePlayerStore = create<PlayerSlice>()( +export const usePlayerStore = createWithEqualityFn<PlayerSlice>()( subscribeWithSelector( persist( devtools( diff --git a/src/renderer/store/playlist.store.ts b/src/renderer/store/playlist.store.ts index 659241b6..a5396152 100644 --- a/src/renderer/store/playlist.store.ts +++ b/src/renderer/store/playlist.store.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; import { PlaylistListFilter, SongListFilter } from '/@/renderer/store/list.store'; import { DataTableProps } from '/@/renderer/store/settings.store'; @@ -58,7 +58,7 @@ type TableProps = DataTableProps & { scrollOffset: number; }; -export const usePlaylistStore = create<PlaylistSlice>()( +export const usePlaylistStore = createWithEqualityFn<PlaylistSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index ee696541..9390ab03 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -3,17 +3,17 @@ import type { ContextMenuItemType } from '/@/renderer/features/context-menu'; import { ColDef } from '@ag-grid-community/core'; import isElectron from 'is-electron'; import { generatePath } from 'react-router'; -import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; import i18n from '/@/i18n/i18n'; import { AppRoute } from '/@/renderer/router/routes'; import { usePlayerStore } from '/@/renderer/store/player.store'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; import { randomString } from '/@/renderer/utils'; -import { AppTheme } from '/@/shared/types/domain-types'; +import { AppTheme } from '/@/shared/themes/app-theme-types'; import { LibraryItem, LyricSource } from '/@/shared/types/domain-types'; import { CrossfadeStyle, @@ -26,8 +26,6 @@ import { TableType, } from '/@/shared/types/types'; -const utils = isElectron() ? window.api.utils : null; - export type SidebarItemType = { disabled: boolean; id: string; @@ -338,7 +336,8 @@ type MpvSettings = { // Determines the default/initial windowBarStyle value based on the current platform. const getPlatformDefaultWindowBarStyle = (): Platform => { - return utils ? (utils.isMacOS() ? Platform.MACOS : Platform.WINDOWS) : Platform.WEB; + // Prefer native window bar + return Platform.LINUX; }; const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle(); @@ -355,7 +354,7 @@ const initialState: SettingsState = { showServerImage: false, }, font: { - builtIn: 'Inter', + builtIn: 'Poppins', custom: null, system: null, type: FontType.BUILT_IN, @@ -366,7 +365,7 @@ const initialState: SettingsState = { albumBackground: false, albumBackgroundBlur: 6, artistItems, - buttonSize: 20, + buttonSize: 15, disabledContextMenu: {}, doubleClickQueueAll: true, externalLinks: true, @@ -382,7 +381,7 @@ const initialState: SettingsState = { passwordStore: undefined, playButtonBehavior: Play.NOW, playerbarOpenDrawer: false, - resume: false, + resume: true, showQueueDrawerButton: false, sidebarCollapsedNavigation: true, sidebarCollapseShared: false, @@ -398,7 +397,7 @@ const initialState: SettingsState = { themeDark: AppTheme.DEFAULT_DARK, themeLight: AppTheme.DEFAULT_LIGHT, volumeWheelStep: 5, - volumeWidth: 60, + volumeWidth: 70, zoomFactor: 100, }, hotkeys: { @@ -437,7 +436,7 @@ const initialState: SettingsState = { zoomIn: { allowGlobal: true, hotkey: '', isGlobal: false }, zoomOut: { allowGlobal: true, hotkey: '', isGlobal: false }, }, - globalMediaHotkeys: true, + globalMediaHotkeys: false, }, lyrics: { alignment: 'center', @@ -445,13 +444,13 @@ const initialState: SettingsState = { enableNeteaseTranslation: false, fetch: false, follow: true, - fontSize: 46, - fontSizeUnsync: 20, - gap: 5, - gapUnsync: 0, + fontSize: 24, + fontSizeUnsync: 24, + gap: 24, + gapUnsync: 24, showMatch: true, showProvider: true, - sources: [], + sources: [LyricSource.NETEASE, LyricSource.LRCLIB], translationApiKey: '', translationApiProvider: '', translationTargetLanguage: 'en', @@ -507,10 +506,6 @@ const initialState: SettingsState = { column: TableColumn.DURATION, width: 100, }, - { - column: TableColumn.BIT_RATE, - width: 300, - }, { column: TableColumn.PLAY_COUNT, width: 100, @@ -659,7 +654,7 @@ const initialState: SettingsState = { }, }; -export const useSettingsStore = create<SettingsSlice>()( +export const useSettingsStore = createWithEqualityFn<SettingsSlice>()( persist( devtools( immer((set, get) => ({ diff --git a/src/renderer/styles/ag-grid.scss b/src/renderer/styles/ag-grid.css similarity index 78% rename from src/renderer/styles/ag-grid.scss rename to src/renderer/styles/ag-grid.css index 4ceb8451..7608f346 100644 --- a/src/renderer/styles/ag-grid.scss +++ b/src/renderer/styles/ag-grid.css @@ -2,10 +2,8 @@ position: fixed !important; top: 65px; z-index: 15; - background: var(--table-header-bg) !important; - margin: 0 -2rem; padding: 0 2rem; - border-bottom: 1px solid var(--table-border-color); + margin: 0 -2rem; box-shadow: 0 -1px 0 0 #181818; transition: position 0.2s ease-in-out; } @@ -23,7 +21,7 @@ } .ag-header-transparent { - --ag-header-background-color: rgba(0, 0, 0, 0%) !important; + --ag-header-background-color: rgb(0 0 0 / 0%) !important; } .ag-header-fixed-margin { @@ -36,8 +34,8 @@ .ag-header-cell, .ag-header-group-cell { - padding-left: 0.5rem; padding-right: 0.5rem; + padding-left: 0.5rem; } .ag-header-cell-resize { diff --git a/src/renderer/styles/base.scss b/src/renderer/styles/base.scss deleted file mode 100644 index 42c72fa6..00000000 --- a/src/renderer/styles/base.scss +++ /dev/null @@ -1,2 +0,0 @@ -@forward './mixins.scss'; -@forward './fonts.scss'; diff --git a/src/renderer/styles/fonts.ts b/src/renderer/styles/fonts.ts deleted file mode 100644 index 5fb8f32e..00000000 --- a/src/renderer/styles/fonts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { css } from 'styled-components'; - -export enum Font { - EPILOGUE = 'Epilogue', - GOTHAM = 'Gotham', - INTER = 'Inter', - POPPINS = 'Poppins', -} - -export const fontGotham = (weight?: number) => css` - font-weight: ${weight || 400}; - font-family: Gotham, sans-serif; -`; - -export const fontPoppins = (weight?: number) => css` - font-weight: ${weight || 400}; - font-family: Poppins, sans-serif; -`; - -export const fontInter = (weight?: number) => css` - font-weight: ${weight || 400}; - font-family: Inter, sans-serif; -`; - -export const fontEpilogue = (weight?: number) => css` - font-weight: ${weight || 400}; - font-family: Epilogue, sans-serif; -`; - -export const fontRoboto = (weight?: number) => css` - font-weight: ${weight || 400}; - font-family: Roboto, sans-serif; -`; diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css new file mode 100644 index 00000000..f9efb54c --- /dev/null +++ b/src/renderer/styles/global.css @@ -0,0 +1,395 @@ +@import url('./ag-grid.css'); + +* { + box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: border-box; + text-rendering: optimizelegibility; + -webkit-tap-highlight-color: rgb(0 0 0 / 0%); + text-size-adjust: none; + outline: none; +} + +body { + margin: 0; +} + +body, +html { + position: absolute; + display: block; + width: 100%; + height: 100%; + overflow: hidden; + font-family: var(--theme-content-font-family); + font-size: var(--theme-root-font-size); + font-variant-numeric: tabular-nums; + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); +} + +input, +button, +textarea, +select { + font: inherit; +} + +button, +select { + text-transform: none; +} + +img { + user-select: none; + -webkit-user-drag: none; +} + +@media only screen and (width < 640px) { + body, + html { + overflow-x: auto; + } +} + +#app { + height: inherit; +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-corner { + background: var(--theme-scrollbar-track-background); +} + +::-webkit-scrollbar-track { + background: var(--theme-scrollbar-track-background); +} + +::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-handle-background); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--theme-scrollbar-handle-hover-background); +} + +a { + text-decoration: none; +} + +button { + -webkit-app-region: no-drag; +} + +.hide-scrollbar { + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@font-face { + font-family: Archivo; + font-weight: 100 1000; + src: url('../fonts/Archivo-VariableFont_wdth,wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Raleway; + font-weight: 100 1000; + src: url('../fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Fredoka; + font-weight: 100 1000; + src: url('../fonts/Fredoka-VariableFont_wdth,wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: 'League Spartan'; + font-weight: 100 1000; + src: url('../fonts/LeagueSpartan-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Lexend; + font-weight: 100 1000; + src: url('../fonts/Lexend-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Inter; + font-weight: 100 1000; + src: url('../fonts/Inter-VariableFont_slnt,wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Sora; + font-weight: 100 1000; + src: url('../fonts/Sora-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: 'Work Sans'; + font-weight: 100 1000; + src: url('../fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 400; + src: url('../fonts/Poppins-Regular.ttf') format('truetype'); + font-display: swap; +} + +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 600; + src: url('../fonts/Poppins-SemiBold.ttf') format('truetype'); + font-display: swap; +} + +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 700; + src: url('../fonts/Poppins-Bold.ttf') format('truetype'); + font-display: swap; +} + +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 800; + src: url('../fonts/Poppins-ExtraBold.ttf') format('truetype'); + font-display: swap; +} + +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 900; + src: url('../fonts/Poppins-Black.ttf') format('truetype'); + font-display: swap; +} + +@font-face { + font-family: Raleway; + font-weight: 100 1000; + src: url('../fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); +} + +@font-face { + font-family: DroidSerif; + src: url('https://rawgit.com/google/fonts/master/ufl/ubuntumono/UbuntuMono-Italic.ttf') + format('truetype'); + unicode-range: U+000-5FF; /* Latin glyphs */ +} + +@font-face { + font-family: DroidSerif; + src: url('https://fonts.gstatic.com/ea/notosansjp/v5/NotoSansJP-Regular.woff2') + format('truetype'); + unicode-range: U+3000-9FFF, U+ff??; /* Japanese glyphs */ +} + +:root { + --theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4='); + --theme-fullscreen-player-text-shadow: black 0px 0px 10px; + --theme-font-size-xs: var(--mantine-font-size-xs); + --theme-font-size-sm: var(--mantine-font-size-sm); + --theme-font-size-md: var(--mantine-font-size-md); + --theme-font-size-lg: var(--mantine-font-size-lg); + --theme-font-size-xl: var(--mantine-font-size-xl); + --theme-font-size-2xl: var(--mantine-font-size-2xl); + --theme-font-size-3xl: var(--mantine-font-size-3xl); + --theme-font-size-4xl: var(--mantine-font-size-4xl); + --theme-font-size-5xl: var(--mantine-font-size-5xl); + --theme-breakpoint-xs: var(--mantine-breakpoint-xs); + --theme-breakpoint-sm: var(--mantine-breakpoint-sm); + --theme-breakpoint-md: var(--mantine-breakpoint-md); + --theme-breakpoint-lg: var(--mantine-breakpoint-lg); + --theme-breakpoint-xl: var(--mantine-breakpoint-xl); + --theme-breakpoint-2xl: var(--mantine-breakpoint-2xl); + --theme-breakpoint-3xl: var(--mantine-breakpoint-3xl); + --theme-spacing-xs: var(--mantine-spacing-xs); + --theme-spacing-sm: var(--mantine-spacing-sm); + --theme-spacing-md: var(--mantine-spacing-md); + --theme-spacing-lg: var(--mantine-spacing-lg); + --theme-spacing-xl: var(--mantine-spacing-xl); + --theme-spacing-2xl: var(--mantine-spacing-2xl); + --theme-spacing-3xl: var(--mantine-spacing-3xl); + --theme-spacing-4xl: var(--mantine-spacing-4xl); + --theme-shadow-xs: var(--mantine-shadow-xs); + --theme-shadow-sm: var(--mantine-shadow-sm); + --theme-shadow-md: var(--mantine-shadow-md); + --theme-shadow-lg: var(--mantine-shadow-lg); + --theme-shadow-xl: var(--mantine-shadow-xl); + --theme-radius-xs: var(--mantine-radius-xs); + --theme-radius-sm: var(--mantine-radius-sm); + --theme-radius-md: var(--mantine-radius-md); + --theme-radius-lg: var(--mantine-radius-lg); + --theme-radius-xl: var(--mantine-radius-xl); + --theme-line-height-xs: var(--mantine-line-height-xs); + --theme-line-height-sm: var(--mantine-line-height-sm); + --theme-line-height-md: var(--mantine-line-height-md); + --theme-line-height-lg: var(--mantine-line-height-lg); + --theme-line-height-xl: var(--mantine-line-height-xl); + --theme-colors-dark-1: var(--mantine-color-dark-1); + --theme-colors-dark-2: var(--mantine-color-dark-2); + --theme-colors-dark-3: var(--mantine-color-dark-3); + --theme-colors-dark-4: var(--mantine-color-dark-4); + --theme-colors-dark-5: var(--mantine-color-dark-5); + --theme-colors-dark-6: var(--mantine-color-dark-6); + --theme-colors-dark-7: var(--mantine-color-dark-7); + --theme-colors-dark-8: var(--mantine-color-dark-8); + --theme-colors-dark-9: var(--mantine-color-dark-9); + --theme-colors-dark-10: var(--mantine-color-dark-10); + --theme-colors-light-1: var(--mantine-color-light-1); + --theme-colors-light-2: var(--mantine-color-light-2); + --theme-colors-light-3: var(--mantine-color-light-3); + --theme-colors-light-4: var(--mantine-color-light-4); + --theme-colors-light-5: var(--mantine-color-light-5); + --theme-colors-light-6: var(--mantine-color-light-6); + --theme-colors-light-7: var(--mantine-color-light-7); + --theme-colors-light-8: var(--mantine-color-light-8); + --theme-colors-light-9: var(--mantine-color-light-9); + --theme-colors-light-10: var(--mantine-color-light-10); + --theme-colors-background: var(--mantine-color-body); + --theme-colors-foreground: var(--mantine-color-text); + --theme-colors-primary-filled: var(--mantine-primary-color-filled); + --theme-colors-primary-contrast: var(--mantine-primary-color-contrast); + + @mixin light-root { + --theme-colors-border: rgb(0 0 0 / 5%); + } + + @mixin dark-root { + --theme-colors-border: rgb(255 255 255 / 10%); + } + + .ag-theme-alpine-dark { + --ag-font-family: var(--theme-content-font-family); + --ag-borders: none; + --ag-border-color: var(--theme-colors-border); + --ag-header-background-color: var(--theme-colors-background); + --ag-header-foreground-color: var(--theme-colors-foreground); + --ag-background-color: var(--theme-colors-background); + --ag-odd-row-background-color: var(--theme-colors-background); + --ag-foreground-color: var(--theme-colors-foreground); + --ag-cell-horizontal-padding: var(--theme-spacing-sm); + --ag-row-hover-color: lighten(var(--theme-colors-background), 5%); + --ag-selected-row-background-color: lighten(var(--theme-colors-background), 10%); + --ag-header-column-resize-handle-width: 0; + } + + .ag-header { + border-bottom: 2px solid var(--theme-colors-border); + } + + .ag-ltr .ag-header-cell-resize { + right: 0; + } + + .ag-header:hover .ag-header-cell-resize { + position: absolute; + top: 25%; + width: 0.2em; + height: 50%; + border: 1px var(--theme-colors-border) solid; + } + + .ag-header-cell-label { + font-family: var(--theme-content-font-family); + font-weight: 500; + text-transform: uppercase; + } + + .ag-cell-rating, + .ag-cell-favorite { + display: none; + } + + .ag-cell-transparent { + opacity: 0; + } + + .ag-cell-rating.visible { + display: block; + } + + .ag-cell-favorite.visible { + display: block; + } + + .ag-row-hover { + .ag-cell-transparent { + opacity: 1; + } + + .ag-cell-rating, + .ag-cell-favorite { + display: block; + } + } + + .ag-cell-focus { + border: 1px var(--theme-colors-border) solid !important; + } + + @mixin dark-root { + .current-song { + .current-song-child { + font-weight: 500; + color: var(--theme-colors-primary-filled); + } + } + } + + @mixin light-root { + .current-song { + .current-song-child { + font-weight: 500; + color: var(--theme-colors-primary-filled); + } + } + } + + .current-song > .row-index.playing .current-song-index { + display: none; + } +} diff --git a/src/renderer/styles/global.scss b/src/renderer/styles/global.scss deleted file mode 100644 index d754dd50..00000000 --- a/src/renderer/styles/global.scss +++ /dev/null @@ -1,217 +0,0 @@ -@use '../themes/default.scss'; -@use '../themes/dark.scss'; -@use '../themes/light.scss'; -@use './ag-grid.scss'; - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body, -html { - position: absolute; - display: block; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: hidden; - color: var(--content-text-color); - background: var(--content-bg); - font-family: var(--content-font-family); - font-size: var(--root-font-size); -} - -@media only screen and (max-width: 639px) { - body, - html { - overflow-x: auto; - } -} - -#app { - height: inherit; -} - -*, -*:before, -*:after { - box-sizing: border-box; - text-rendering: optimizeLegibility; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - -webkit-text-size-adjust: none; - outline: none; -} - -::-webkit-scrollbar { - width: 12px; - height: 12px; -} - -::-webkit-scrollbar-corner { - background: var(--scrollbar-track-bg); -} - -::-webkit-scrollbar-track { - background: var(--scrollbar-track-bg); -} - -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb-bg); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-bg-hover); -} - -a { - text-decoration: none; -} - -button { - -webkit-app-region: no-drag; -} - -.hide-scrollbar { - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background-color: transparent; - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -.mantine-ScrollArea-thumb[data-state='visible'] { - animation: fadeIn 0.3s forwards; -} - -.mantine-ScrollArea-scrollbar[data-state='hidden'] { - animation: fadeOut 0.2s forwards; -} - -@font-face { - font-family: 'Archivo'; - src: url('../fonts/Archivo-VariableFont_wdth,wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Raleway'; - src: url('../fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Fredoka'; - src: url('../fonts/Fredoka-VariableFont_wdth,wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'League Spartan'; - src: url('../fonts/LeagueSpartan-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Lexend'; - src: url('../fonts/Lexend-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Inter'; - src: url('../fonts/Inter-VariableFont_slnt,wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Sora'; - src: url('../fonts/Sora-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Work Sans'; - src: url('../fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url('../fonts/Poppins-Regular.ttf') format('truetype'); -} - -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url('../fonts/Poppins-SemiBold.ttf') format('truetype'); -} - -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url('../fonts/Poppins-Bold.ttf') format('truetype'); -} - -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 800; - font-display: swap; - src: url('../fonts/Poppins-ExtraBold.ttf') format('truetype'); -} - -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: url('../fonts/Poppins-Black.ttf') format('truetype'); -} - -@font-face { - font-family: 'Raleway'; - src: url('../fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); - font-weight: 100 1000; -} - -@font-face { - font-family: 'DroidSerif'; - src: url('https://rawgit.com/google/fonts/master/ufl/ubuntumono/UbuntuMono-Italic.ttf') - format('truetype'); - unicode-range: U+000-5FF; /* Latin glyphs */ -} - -@font-face { - font-family: 'DroidSerif'; - src: url('https://fonts.gstatic.com/ea/notosansjp/v5/NotoSansJP-Regular.woff2') format('truetype'); - unicode-range: U+3000-9FFF, U+ff??; /* Japanese glyphs */ -} diff --git a/src/renderer/styles/index.ts b/src/renderer/styles/index.ts deleted file mode 100644 index 875aca57..00000000 --- a/src/renderer/styles/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './fonts'; -export * from './mixins'; diff --git a/src/renderer/styles/mixins.scss b/src/renderer/styles/mixins.scss deleted file mode 100644 index 40a9712b..00000000 --- a/src/renderer/styles/mixins.scss +++ /dev/null @@ -1,37 +0,0 @@ -@mixin hidden-text-overflow { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -@mixin flex-column { - display: flex; - flex-direction: column; -} - -@mixin flex-center { - display: flex; - align-items: center; - justify-content: center; -} - -@mixin flex-center-column { - @include flex-center; - flex-direction: column; -} - -@mixin flex-center-vertical { - display: flex; - align-items: center; -} - -@mixin flex-center-horizontal { - display: flex; - justify-content: center; -} - -@mixin cover-background { - background-repeat: no-repeat; - background-position: center; - background-size: cover; -} diff --git a/src/renderer/styles/mixins.ts b/src/renderer/styles/mixins.ts deleted file mode 100644 index 51230fdf..00000000 --- a/src/renderer/styles/mixins.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { css } from 'styled-components'; - -export const textEllipsis = css` - white-space: nowrap; - text-overflow: ellipsis; -`; - -export const flexBetween = css` - display: flex; - flex-direction: row; - justify-content: space-between; -`; - -export const flexCenter = css` - display: flex; - align-items: center; - justify-content: center; -`; - -export const flexCenterColumn = css` - ${flexCenter} - flex-direction: column; -`; - -export const coverBackground = css` - background-repeat: no-repeat; - background-position: center; - background-size: cover; -`; - -export const fadeIn = css` - @keyframes fadein { - from { - opacity: 0; - } - - to { - opacity: 1; - } - } -`; - -export const rotating = css` - @keyframes rotating { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } - } -`; diff --git a/src/renderer/styles/overlayscrollbars.css b/src/renderer/styles/overlayscrollbars.css index 0559ffae..d86ab92c 100644 --- a/src/renderer/styles/overlayscrollbars.css +++ b/src/renderer/styles/overlayscrollbars.css @@ -3,11 +3,9 @@ --os-handle-bg: var(--scrollbar-thumb-bg); --os-handle-bg-hover: var(--scrollbar-thumb-bg-hover); --os-handle-bg-active: var(--scrollbar-thumb-bg-hover); - --os-track-bg: var(--scrollbar-track-bg); --os-track-bg-hover: var(--scrollbar-track-bg); --os-track-bg-active: var(--scrollbar-track-bg); - --os-padding-perpendicular: 0; --os-padding-axis: 0; --os-track-border-radius: 0; diff --git a/src/renderer/themes/dark.scss b/src/renderer/themes/dark.scss deleted file mode 100644 index 97296df2..00000000 --- a/src/renderer/themes/dark.scss +++ /dev/null @@ -1,2 +0,0 @@ -body[data-theme='defaultDark'] { -} diff --git a/src/renderer/themes/default.scss b/src/renderer/themes/default.scss deleted file mode 100644 index 2248fc7f..00000000 --- a/src/renderer/themes/default.scss +++ /dev/null @@ -1,203 +0,0 @@ -:root { - --root-font-size: 13px; - --icon-color: rgb(255, 255, 255); - --primary-color: rgb(53, 116, 252); - --secondary-color: rgb(255, 120, 120); - --success-color: green; - --warning-color: orange; - --danger-color: rgb(204, 50, 50); - --generic-border-color: rgba(50, 50, 50, 30%); - --main-bg: rgb(18, 18, 18); - --main-bg-transparent: 18, 18, 18; - --main-fg: rgb(225, 225, 225); - --main-fg-secondary: rgb(150, 150, 150); - --window-bar-bg: rgb(16, 16, 16); - --window-bar-fg: rgb(255, 255, 255); - --titlebar-fg: rgb(255, 255, 255); - --titlebar-bg: rgb(12, 12, 12); - --titlebar-controls-bg: rgba(0, 0, 0, 0%); - --sidebar-bg: rgb(0, 0, 0); - --sidebar-bg-hover: rgb(50, 50, 50); - --sidebar-fg: rgb(230, 230, 230); - --sidebar-fg-hover: rgb(255, 255, 255); - --sidebar-handle-bg: #4d4d4d; - --sidebar-border: 2px rgba(18, 18, 18, 70%) solid; - --playerbar-bg: rgb(16, 16, 16); - --playerbar-bg-active: rgb(11, 11, 11); - --playerbar-btn-main-fg: rgb(0, 0, 0); - --playerbar-btn-main-fg-hover: rgb(0, 0, 0); - --playerbar-btn-main-bg: rgb(230, 230, 230); - --playerbar-btn-main-bg-hover: rgb(255, 255, 255); - --playerbar-btn-fg: rgba(200, 200, 200, 80%); - --playerbar-btn-fg-hover: rgba(255, 255, 255, 100%); - --playerbar-btn-bg: #c5c5c5; - --playerbar-btn-bg-hover: transparent; - --playerbar-border-top: 1px rgba(50, 50, 50, 30%) solid; - --playerbar-slider-track-bg: #3c3f43; - --playerbar-slider-track-progress-bg: #ccc; - --tooltip-bg: #fff; - --tooltip-fg: #000; - --scrollbar-size: 12px; - --scrollbar-track-bg: transparent; - --scrollbar-thumb-bg: rgba(160, 160, 160, 30%); - --scrollbar-thumb-bg-hover: rgba(160, 160, 160, 60%); - --btn-filled-bg: var(--primary-color); - --btn-filled-bg-hover: rgb(34, 96, 255); - --btn-filled-fg: #fff; - --btn-filled-fg-hover: #fff; - --btn-filled-border: none; - --btn-filled-radius: 4px; - --btn-default-bg: rgb(31, 31, 32); - --btn-default-bg-hover: rgb(63, 63, 63); - --btn-default-fg: rgb(193, 193, 193); - --btn-default-fg-hover: rgb(193, 193, 193); - --btn-default-border: none; - --btn-default-radius: 2px; - --btn-subtle-bg: transparent; - --btn-subtle-bg-hover: transparent; - --btn-subtle-fg: rgb(220, 220, 220); - --btn-subtle-fg-hover: rgb(255, 255, 255); - --btn-subtle-border: none; - --btn-subtle-radius: 4px; - --btn-outline-bg: transparent; - --btn-outline-bg-hover: transparent; - --btn-outline-fg: rgb(220, 220, 220); - --btn-outline-fg-hover: rgb(255, 255, 255); - --btn-outline-border: 1px rgba(140, 140, 140, 50%) solid; - --btn-outline-border-hover: 1px rgba(255, 255, 255, 50%) solid; - --btn-outline-radius: 5rem; - --input-bg: rgb(35, 35, 35); - --input-fg: rgb(193, 193, 193); - --input-placeholder-fg: rgb(107, 108, 109); - --input-active-fg: rgb(193, 193, 193); - --input-active-bg: rgba(255, 255, 255, 10%); - --dropdown-menu-bg: rgba(32, 32, 32, 95%); - --dropdown-menu-fg: rgb(235, 235, 235); - --dropdown-menu-item-padding: 0.8rem; - --dropdown-menu-item-font-size: 1rem; - --dropdown-menu-bg-hover: rgb(62, 62, 62); - --dropdown-menu-border: 1px var(--generic-border-color) solid; - --dropdown-menu-border-radius: 4px; - --switch-track-bg: rgb(50, 50, 50); - --switch-track-enabled-bg: var(--primary-color); - --switch-thumb-bg: rgb(255, 255, 255); - --slider-track-bg: rgb(50, 50, 50); - --slider-thumb-bg: rgb(255, 255, 255); - --skeleton-bg: rgba(255, 255, 255, 8%); - --toast-title-fg: rgb(255, 255, 255); - --toast-description-fg: rgb(193, 194, 197); - --toast-bg: rgb(16, 16, 16); - --modal-bg: var(--main-bg); - --modal-header-bg: var(--paper-bg); - --badge-bg: rgb(80, 80, 80); - --badge-fg: rgb(255, 255, 255); - --badge-radius: 12px; - --paper-bg: rgb(20, 20, 20); - --placeholder-bg: rgba(53, 53, 53, 100%); - --placeholder-fg: rgba(126, 126, 126); - --card-default-bg: rgb(32, 32, 32); - --card-default-bg-hover: rgb(44, 44, 44); - --card-default-radius: 5px; - --card-poster-bg: transparent; - --card-poster-bg-hover: transparent; - --card-poster-radius: 3px; - --background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4='); - --current-song-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2aWV3Qm94PSIxMDAgMTMwIDU3IDgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxyZWN0IHg9IjEwMCIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iMjAiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxMDZweCAxNDBweDsiLz4KICA8cmVjdCB4PSIxMTUiIHk9IjEzMCIgd2lkdGg9IjEyIiBoZWlnaHQ9IjYwIiBzdHlsZT0iZmlsbDogcmdiKDIxNiwgMjE2LCAyMTYpOyBzdHJva2U6IHJnYigwLCAwLCAwKTsgdHJhbnNmb3JtLW9yaWdpbjogMTIxcHggMTYwcHg7Ii8+CiAgPHJlY3QgeD0iMTMwIiB5PSIxMzAiIHdpZHRoPSIxMiIgaGVpZ2h0PSI4MCIgc3R5bGU9ImZpbGw6IHJnYigyMTYsIDIxNiwgMjE2KTsgc3Ryb2tlOiByZ2IoMCwgMCwgMCk7IHRyYW5zZm9ybS1vcmlnaW46IDEzNnB4IDE3MHB4OyIvPgogIDxyZWN0IHg9IjE0NSIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iNDUiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxNTFweCAxNTIuNXB4OyIvPgo8L3N2Zz4='); - --current-song-image-animated: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2aWV3Qm94PSIxMDAgMTMwIDU3IDgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxyZWN0IHg9IjEwMCIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iODAiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxMDZweCAxNzBweDsiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iaGVpZ2h0IiB2YWx1ZXM9IjgwOzE1OzgwIiBiZWdpbj0iMC4xcyIgZHVyPSIwLjk1cyIga2V5VGltZXM9IjA7IDAuNDczNjg7IDEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiBjYWxjTW9kZT0ic3BsaW5lIiBrZXlTcGxpbmVzPSIwLjQyIDAgMC41OCAxOyAwLjQyIDAgMC41OCAxIi8+CiAgPC9yZWN0PgogIDxyZWN0IHg9IjExNSIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iODAiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxMjFweCAxNzBweDsiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iaGVpZ2h0IiB2YWx1ZXM9IjI1OzgwOzI1IiBiZWdpbj0iMC4xcyIgZHVyPSIwLjk1cyIga2V5VGltZXM9IjA7IDAuNDQ0NDQ7IDEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiBjYWxjTW9kZT0ic3BsaW5lIiBrZXlTcGxpbmVzPSIwLjQ1IDAgMC41NSAxOyAwLjQ1IDAgMC41NSAxIi8+CiAgPC9yZWN0PgogIDxyZWN0IHg9IjEzMCIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iODAiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxMzZweCAxNzBweDsiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iaGVpZ2h0IiB2YWx1ZXM9IjgwOzEwOzgwIiBiZWdpbj0iMC4xcyIgZHVyPSIwLjg1cyIga2V5VGltZXM9IjA7IDAuNDIxMDU7IDEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiBjYWxjTW9kZT0ic3BsaW5lIiBrZXlTcGxpbmVzPSIwLjY1IDAgMC4zNSAxOyAwLjY1IDAgMC4zNSAxIi8+CiAgPC9yZWN0PgogIDxyZWN0IHg9IjE0NSIgeT0iMTMwIiB3aWR0aD0iMTIiIGhlaWdodD0iODAiIHN0eWxlPSJmaWxsOiByZ2IoMjE2LCAyMTYsIDIxNik7IHN0cm9rZTogcmdiKDAsIDAsIDApOyB0cmFuc2Zvcm0tb3JpZ2luOiAxNTFweCAxNzBweDsiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iaGVpZ2h0IiB2YWx1ZXM9IjMwOzgwOzMwIiBiZWdpbj0iMC4xcyIgZHVyPSIxLjA1cyIga2V5VGltZXM9IjA7IDAuMzE1Nzk7IDEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiBjYWxjTW9kZT0ic3BsaW5lIiBrZXlTcGxpbmVzPSIwLjQyIDAgMC41OCAxOyAwLjQyIDAgMC41OCAxIi8+CiAgPC9yZWN0Pgo8L3N2Zz4='); - --bg-header-overlay: linear-gradient(transparent 0%, rgba(0, 0, 0, 75%) 100%), - var(--background-noise); - --bg-subheader-overlay: linear-gradient(180deg, rgba(0, 0, 0, 5%) 0%, var(--main-bg) 100%), - var(--background-noise); - --table-header-bg: rgb(24, 24, 24); - --table-header-fg: rgb(179, 179, 179); - --table-border: none; - --table-border-color: hsla(0deg, 0%, 100%, 10%); - --table-bg: var(--main-bg); - --table-alt-bg: var(--main-bg); - --table-fg: rgb(179, 179, 179); - --table-row-hover-bg: rgba(100, 100, 100, 20%); - --table-row-selected-bg: rgba(100, 100, 100, 40%); - --fullscreen-player-text-shadow: black 0px 0px 10px; - - .ag-theme-alpine-dark { - --ag-font-family: var(--content-font-family); - --ag-borders: var(--table-border); - --ag-border-color: var(--table-border-color); - --ag-header-background-color: var(--table-header-bg); - --ag-header-foreground-color: var(--table-header-fg); - --ag-background-color: var(--table-bg); - --ag-odd-row-background-color: var(--table-alt-bg); - --ag-foreground-color: var(--table-fg); - --ag-row-hover-color: var(--table-row-hover-bg); - --ag-selected-row-background-color: var(--table-row-selected-bg); - --ag-cell-horizontal-padding: 0.5rem; - } - - .ag-header { - border-bottom: 2px solid var(--table-border-color); - } - - .ag-ltr .ag-header-cell-resize { - right: 0; - } - - .ag-header:hover .ag-header-cell-resize { - position: absolute; - top: 25%; - width: 0.2em; - height: 50%; - border: 1px var(--table-border-color) solid; - } - - .ag-header-cell-label { - font-weight: 500; - font-family: var(--content-font-family); - text-transform: uppercase; - } - - .ag-cell-rating, - .ag-cell-favorite { - display: none; - } - - .ag-cell-transparent { - opacity: 0; - } - - .ag-cell-rating.visible { - display: block; - } - - .ag-cell-favorite.visible { - display: block; - } - - .ag-row-hover { - .ag-cell-transparent { - opacity: 1; - } - - .ag-cell-rating, - .ag-cell-favorite { - display: block; - } - } - - .ag-cell-focus { - border: 1px var(--table-border-color) solid !important; - } - - .current-song { - background: var(--table-row-hover-bg); - box-shadow: inset 0 0 0 100vmax rgba(0, 0, 0, 0.3); - - .current-song-child { - color: var(--primary-color) !important; - font-weight: 800; - } - } - - .current-song > .row-index.playing .current-song-index { - display: none; - } -} diff --git a/src/renderer/themes/light.scss b/src/renderer/themes/light.scss deleted file mode 100644 index c1d589a1..00000000 --- a/src/renderer/themes/light.scss +++ /dev/null @@ -1,133 +0,0 @@ -body[data-theme='defaultLight'] { - --icon-color: #fff; - --main-bg: rgb(255, 255, 255); - --main-bg-transparent: 255, 255, 255; - --main-fg: rgb(25, 25, 25); - --main-fg-secondary: rgb(80, 80, 80); - --window-bar-bg: rgb(255, 255, 255); - --window-bar-fg: rgb(16, 16, 16); - --titlebar-fg: rgb(25, 25, 25); - --titlebar-bg: rgb(240, 241, 242); - --titlebar-controls-bg: rgba(0, 0, 0, 0%); - --sidebar-bg: rgb(240, 241, 242); - --sidebar-bg-hover: rgb(200, 200, 200); - --sidebar-fg: rgb(0, 0, 0); - --sidebar-fg-hover: rgb(85, 85, 85); - --sidebar-handle-bg: rgb(220, 220, 220); - --sidebar-border: 1px rgba(220, 220, 220, 70%) solid; - --playerbar-bg: rgb(220, 220, 220); - --playerbar-bg-active: rgb(175, 175, 175); - --playerbar-btn-main-fg: rgb(0, 0, 0); - --playerbar-btn-main-fg-hover: rgb(0, 0, 0); - --playerbar-btn-main-bg: transparent; - --playerbar-btn-main-bg-hover: transparent; - --playerbar-btn-fg: #000; - --playerbar-btn-fg-hover: #000; - --playerbar-btn-bg: transparent; - --playerbar-btn-bg-hover: transparent; - --playerbar-border-top: 1px rgba(200, 200, 200, 70%) solid; - --playerbar-slider-track-bg: rgba(50, 50, 50, 20%); - --playerbar-slider-track-progress-bg: rgb(50, 50, 50); - --tooltip-bg: rgb(255, 255, 255); - --tooltip-fg: rgb(0, 0, 0); - --scrollbar-track-bg: transparent; - --scrollbar-thumb-bg: rgba(140, 140, 140, 30%); - --scrollbar-thumb-bg-hover: rgba(140, 140, 140, 60%); - --btn-filled-bg: var(--primary-color); - --btn-filled-fg: #fff; - --btn-filled-fg-hover: #fff; - --btn-default-bg: rgb(220, 220, 220); - --btn-default-bg-hover: rgb(210, 210, 210); - --btn-default-fg: rgb(50, 50, 50); - --btn-default-fg-hover: rgb(50, 50, 50); - --btn-subtle-bg: transparent; - --btn-subtle-bg-hover: transparent; - --btn-subtle-fg: rgb(80, 80, 80); - --btn-subtle-fg-hover: rgb(0, 0, 0); - --btn-outline-bg: transparent; - --btn-outline-bg-hover: transparent; - --btn-outline-fg: rgb(60, 60, 60); - --btn-outline-fg-hover: rgb(0, 0, 0); - --btn-outline-border: 1px rgba(140, 140, 140, 50%) solid; - --btn-outline-border-hover: 1px rgba(255, 255, 255, 50%) solid; - --btn-outline-radius: 5rem; - --input-bg: rgb(240, 241, 242); - --input-fg: rgb(0, 0, 0); - --input-placeholder-fg: rgb(119, 126, 139); - --input-active-fg: rgb(193, 193, 193); - --input-active-bg: rgba(37, 38, 43, 30%); - --dropdown-menu-bg: rgb(255, 255, 255); - --dropdown-menu-fg: rgb(0, 0, 0); - --dropdown-menu-bg-hover: rgba(0, 0, 0, 10%); - --dropdown-menu-border: 1px rgba(150, 150, 150, 70%) solid; - --dropdown-menu-border-radius: 4px; - --switch-track-bg: rgb(114, 118, 125); - --switch-track-enabled-bg: var(--primary-color); - --switch-thumb-bg: rgb(255, 255, 255); - --slider-track-bg: rgba(50, 50, 50, 10%); - --slider-thumb-bg: rgb(100, 100, 100); - --skeleton-bg: rgba(50, 50, 50, 8%); - --toast-title-fg: rgb(255, 255, 255); - --toast-description-fg: rgb(193, 194, 197); - --toast-bg: rgb(16, 16, 16); - --modal-bg: rgb(255, 255, 255); - --modal-header-bg: var(--paper-bg); - --paper-bg: rgb(240, 240, 240); - --placeholder-bg: rgba(204, 204, 204, 50%); - --placeholder-fg: rgb(126, 126, 126); - --card-default-bg: rgba(235, 235, 235, 50%); - --card-default-bg-hover: rgba(200, 200, 200, 80%); - --card-default-radius: 10px; - --card-poster-bg: transparent; - --card-poster-bg-hover: transparent; - --card-poster-radius: 5px; - --bg-header-overlay: linear-gradient(rgba(255, 255, 255, 50%) 0%, rgba(255, 255, 255, 20%)), - var(--background-noise); - --bg-subheader-overlay: linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--main-bg)), - var(--background-noise); - --table-header-bg: rgb(245, 245, 245); - --table-header-fg: rgb(40, 40, 40); - --table-border: none; - --table-border-color: rgba(50, 50, 50, 30%); - --table-bg: var(--main-bg); - --table-alt-bg: var(--main-bg); - --table-fg: rgb(179, 179, 179); - --table-row-hover-bg: rgba(150, 150, 150, 20%); - --table-row-selected-bg: rgba(150, 150, 150, 30%); - --fullscreen-player-text-shadow: 0 0 5px rgba(255, 255, 255, 0.5); - - .ag-theme-alpine-dark { - --ag-font-family: var(--content-font-family); - --ag-borders: var(--table-border); - --ag-border-color: rgb(50, 50, 50); - --ag-header-background-color: var(--table-header-bg); - --ag-header-foreground-color: var(--table-header-fg); - --ag-background-color: var(--table-bg); - --ag-odd-row-background-color: var(--table-alt-bg); - --ag-foreground-color: var(--table-fg); - --ag-row-hover-color: var(--table-row-hover-bg); - --ag-selected-row-background-color: var(--table-row-selected-bg); - } - - .ag-root ::-webkit-scrollbar-corner { - background: var(--scrollbar-track-bg); - } - - .ag-root ::-webkit-scrollbar-track-piece { - background: var(--scrollbar-track-bg); - } - - .ag-root ::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb-bg); - } - - .current-song { - background: var(--table-row-hover-bg); - box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.3); - - .current-song-child { - color: var(--primary-color) !important; - font-weight: 800; - } - } -} diff --git a/src/renderer/themes/mantine-theme.tsx b/src/renderer/themes/mantine-theme.tsx new file mode 100644 index 00000000..0210b877 --- /dev/null +++ b/src/renderer/themes/mantine-theme.tsx @@ -0,0 +1,143 @@ +import type { MantineColorsTuple, MantineThemeOverride } from '@mantine/core'; + +import { generateColors } from '@mantine/colors-generator'; +import { createTheme, rem } from '@mantine/core'; +import merge from 'lodash/merge'; + +import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types'; + +// const lightColors: MantineColorsTuple = [ +// '#f5f5f5', +// '#e7e7e7', +// '#cdcdcd', +// '#b2b2b2', +// '#9a9a9a', +// '#8b8b8b', +// '#848484', +// '#717171', +// '#656565', +// '#575757', +// ]; + +const darkColors: MantineColorsTuple = [ + '#C9C9C9', + '#b8b8b8', + '#828282', + '#696969', + '#424242', + '#3b3b3b', + '#242424', + '#181818', + '#1f1f21', + '#141414', +]; + +const mantineTheme: MantineThemeOverride = createTheme({ + autoContrast: true, + breakpoints: { + '2xl': '120em', + '3xl': '160em', + lg: '75em', + md: '62em', + sm: '48em', + xl: '88em', + xs: '36em', + }, + cursorType: 'pointer', + defaultRadius: 'sm', + focusRing: 'never', + fontFamily: 'var(--theme-content-font-family)', + fontSizes: { + '2xl': rem('20px'), + '3xl': rem('24px'), + '4xl': rem('28px'), + '5xl': rem('32px'), + lg: rem('16px'), + md: rem('13px'), + sm: rem('12px'), + xl: rem('18px'), + xs: rem('10px'), + }, + fontSmoothing: true, + headings: { + fontFamily: 'var(--theme-content-font-family)', + sizes: { + h1: { + fontSize: rem('36px'), + fontWeight: '600', + lineHeight: rem('44px'), + }, + h2: { + fontSize: rem('30px'), + fontWeight: '600', + lineHeight: rem('38px'), + }, + h3: { + fontSize: rem('24px'), + fontWeight: '600', + lineHeight: rem('32px'), + }, + h4: { + fontSize: rem('20px'), + fontWeight: '600', + lineHeight: rem('30px'), + }, + }, + }, + lineHeights: { + lg: rem('20px'), + md: rem('18px'), + sm: rem('16px'), + xl: rem('24px'), + xs: rem('14px'), + }, + luminanceThreshold: 0.3, + primaryColor: 'primary', + primaryShade: { dark: 5, light: 9 }, + radius: { + lg: rem('12px'), + md: rem('5px'), + sm: rem('3px'), + xl: rem('16px'), + xs: rem('3px'), + }, + scale: 1, + shadows: { + lg: '0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05)', + md: '0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)', + sm: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)', + xl: '0 20px 25px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.04)', + xs: '0 1px 2px rgba(0, 0, 0, 0.05)', + xxl: '0 25px 50px rgba(0, 0, 0, 0.25)', + }, + spacing: { + '0': rem('0px'), + '2xl': rem('32px'), + '3xl': rem('36px'), + '4xl': rem('40px'), + lg: rem('16px'), + md: rem('12px'), + sm: rem('8px'), + xl: rem('24px'), + xs: rem('4px'), + }, +}); + +export function createMantineTheme(theme: AppThemeConfiguration): MantineThemeOverride { + const primaryColor = theme.colors?.primary ?? '#000'; + + const mergedTheme: MantineThemeOverride = merge( + {}, + { + ...mantineTheme, + black: theme.colors?.black, + colors: { + dark: darkColors, + primary: generateColors(primaryColor), + }, + white: theme.colors?.white, + }, + theme.mantineOverride, + ); + return createTheme(mergedTheme); +} diff --git a/src/renderer/themes/types.ts b/src/renderer/themes/types.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/renderer/themes/use-app-theme.ts b/src/renderer/themes/use-app-theme.ts new file mode 100644 index 00000000..cf0bf196 --- /dev/null +++ b/src/renderer/themes/use-app-theme.ts @@ -0,0 +1,156 @@ +import { useMantineColorScheme } from '@mantine/core'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useSettingsStore } from '/@/renderer/store/settings.store'; +import { createMantineTheme } from '/@/renderer/themes/mantine-theme'; +import { getAppTheme } from '/@/shared/themes/app-theme'; +import { AppTheme, AppThemeConfiguration } from '/@/shared/themes/app-theme-types'; +import { FontType } from '/@/shared/types/types'; + +export const THEME_DATA = [ + { label: 'Default Dark', type: 'dark', value: AppTheme.DEFAULT_DARK }, + { label: 'Default Light', type: 'light', value: AppTheme.DEFAULT_LIGHT }, +]; + +export const useAppTheme = () => { + const accent = useSettingsStore((store) => store.general.accent); + const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio); + const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); + const textStyleRef = useRef<HTMLStyleElement | null>(null); + const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches; + const [isDarkTheme, setIsDarkTheme] = useState(getCurrentTheme()); + const { followSystemTheme, theme, themeDark, themeLight } = useSettingsStore( + (state) => state.general, + ); + + const mqListener = (e: any) => { + setIsDarkTheme(e.matches); + }; + + const getSelectedTheme = () => { + if (followSystemTheme) { + return isDarkTheme ? themeDark : themeLight; + } + + return theme; + }; + + const selectedTheme = getSelectedTheme(); + + useEffect(() => { + const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)'); + darkThemeMq.addListener(mqListener); + return () => darkThemeMq.removeListener(mqListener); + }, []); + + useEffect(() => { + if (type === FontType.SYSTEM && system) { + const root = document.documentElement; + root.style.setProperty('--theme-content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: local("${system}"); + }`; + } else if (type === FontType.CUSTOM && custom) { + const root = document.documentElement; + root.style.setProperty('--theme-content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: url("feishin://${custom}"); + }`; + } else { + const root = document.documentElement; + root.style.setProperty('--theme-content-font-family', builtIn); + } + }, [builtIn, custom, system, type]); + + const appTheme: AppThemeConfiguration = useMemo(() => { + const themeProperties = getAppTheme(selectedTheme); + + return { + ...themeProperties, + colors: { + ...themeProperties.colors, + primary: accent, + }, + }; + }, [accent, selectedTheme]); + + useEffect(() => { + const root = document.documentElement; + root.style.setProperty('--theme-colors-primary', accent); + }, [accent]); + + useEffect(() => { + const root = document.documentElement; + root.style.setProperty('--theme-image-fit', nativeImageAspect ? 'contain' : 'cover'); + }, [nativeImageAspect]); + + const themeVars = useMemo(() => { + return Object.entries(appTheme?.app ?? {}) + .map(([key, value]) => { + return [`--theme-${key}`, value]; + }) + .filter(Boolean) as [string, string][]; + }, [appTheme]); + + const colorVars = useMemo(() => { + return Object.entries(appTheme?.colors ?? {}) + .map(([key, value]) => { + return [`--theme-colors-${key}`, value]; + }) + .filter(Boolean) as [string, string][]; + }, [appTheme]); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', selectedTheme); + + if (themeVars.length > 0 || colorVars.length > 0) { + let styleElement = document.getElementById('theme-variables'); + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = 'theme-variables'; + document.head.appendChild(styleElement); + } + + let cssText = ':root {\n'; + + for (const [key, value] of themeVars) { + cssText += ` ${key}: ${value};\n`; + } + + for (const [key, value] of colorVars) { + cssText += ` ${key}: ${value};\n`; + } + + cssText += '}'; + + styleElement.textContent = cssText; + } + }, [colorVars, selectedTheme, themeVars]); + + return { + mode: appTheme?.mode || 'dark', + theme: createMantineTheme(appTheme as AppThemeConfiguration), + }; +}; + +export const useSetColorScheme = () => { + const { setColorScheme } = useMantineColorScheme(); + + return { setColorScheme }; +}; diff --git a/src/renderer/utils/format.tsx b/src/renderer/utils/format.tsx index 2b510f74..940e4d96 100644 --- a/src/renderer/utils/format.tsx +++ b/src/renderer/utils/format.tsx @@ -5,7 +5,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import formatDuration from 'format-duration'; -import { Rating } from '/@/renderer/components/rating'; +import { Rating } from '/@/shared/components/rating/rating'; dayjs.extend(relativeTime); dayjs.extend(utc); diff --git a/src/shared/api/navidrome.types.ts b/src/shared/api/navidrome.types.ts index 3f28bd2a..c4721278 100644 --- a/src/shared/api/navidrome.types.ts +++ b/src/shared/api/navidrome.types.ts @@ -430,7 +430,7 @@ export const NDSongQueryFields = [ { label: 'Movement', type: 'string', value: 'movement' }, { label: 'Movement Name', type: 'string', value: 'movementname' }, { label: 'Movement Total', type: 'number', value: 'movementtotal' }, - { label: 'MusicBrainz Artist Id', type: 'string', value: 'musicbrainz_albumartistid' }, + { label: 'MusicBrainz Artist Id', type: 'string', value: 'musicbrainz_artistid' }, { label: 'MusicBrainz Album Artist Id', type: 'string', value: 'musicbrainz_albumartistid' }, { label: 'MusicBrainz Album Id', type: 'string', value: 'musicbrainz_albumid' }, { label: 'MusicBrainz Disc Id', type: 'string', value: 'musicbrainz_discid' }, diff --git a/src/shared/components/accordion/accordion.module.css b/src/shared/components/accordion/accordion.module.css new file mode 100644 index 00000000..7cc31b92 --- /dev/null +++ b/src/shared/components/accordion/accordion.module.css @@ -0,0 +1,12 @@ +.panel { + background: var(--theme-colors-background); +} + +.control { + background: var(--theme-colors-background); +} + +.chevron { + display: flex; + justify-content: center; +} diff --git a/src/shared/components/accordion/accordion.tsx b/src/shared/components/accordion/accordion.tsx new file mode 100644 index 00000000..2fb6379a --- /dev/null +++ b/src/shared/components/accordion/accordion.tsx @@ -0,0 +1,41 @@ +import { + Accordion as MantineAccordion, + AccordionProps as MantineAccordionProps, +} from '@mantine/core'; + +import styles from './accordion.module.css'; + +import { Icon } from '/@/shared/components/icon/icon'; + +export interface AccordionProps + extends Omit<MantineAccordionProps, 'defaultValue' | 'multiple' | 'onChange'> { + defaultValue?: string | string[]; + multiple?: boolean; + onChange?: (value: null | string | string[]) => void; +} + +export const Accordion = ({ children, classNames, ...props }: AccordionProps) => { + return ( + <MantineAccordion + chevron={ + <Icon + icon="arrowUpS" + size="lg" + /> + } + classNames={{ + chevron: styles.chevron, + control: styles.control, + panel: styles.panel, + ...classNames, + }} + {...props} + > + {children} + </MantineAccordion> + ); +}; + +Accordion.Control = MantineAccordion.Control; +Accordion.Item = MantineAccordion.Item; +Accordion.Panel = MantineAccordion.Panel; diff --git a/src/shared/components/action-icon/action-icon.module.css b/src/shared/components/action-icon/action-icon.module.css new file mode 100644 index 00000000..3f53379c --- /dev/null +++ b/src/shared/components/action-icon/action-icon.module.css @@ -0,0 +1,92 @@ +.root { + --ai-size-xs: calc(1.875rem * var(--mantine-scale)); + --ai-size-sm: calc(2.25rem * var(--mantine-scale)); + --ai-size-md: calc(2.625rem * var(--mantine-scale)); + --ai-size-lg: calc(3.125rem * var(--mantine-scale)); + --ai-size-xl: calc(3.75rem * var(--mantine-scale)); + + font-weight: 500; + transition: + background-color 0.2s ease-in-out, + border-color 0.2s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } + + &[data-variant='default'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-surface); + border: 1px solid transparent; + + &:hover { + background: lighten(var(--theme-colors-surface), 5%); + } + + &:focus-visible { + background: lighten(var(--theme-colors-surface), 10%); + } + } + + &[data-variant='outline'] { + --button-border: var(--theme-colors-border); + + color: var(--theme-colors-foreground); + border: 1px solid var(--theme-colors-border); + + &:hover { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + + &:focus-visible { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + } + + &[data-variant='filled'] { + color: var(--theme-colors-primary-contrast); + background: var(--theme-colors-primary-filled); + border: 1px solid transparent; + transition: background-color 0.2s ease-in-out; + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-primary-filled), 10%); + } + } + + &[data-variant='subtle'] { + color: var(--theme-colors-foreground); + background: transparent; + + &:hover, + &:focus-visible { + background: lighten(var(--theme-colors-surface), 10%); + } + } + + &[data-variant='secondary'] { + border: 1px solid transparent; + + &:hover { + background: darken(var(--theme-colors-surface), 5%); + } + + &:focus-visible { + background: darken(var(--theme-colors-surface), 10%); + } + } + + &[data-variant='transparent'] { + color: var(--theme-colors-foreground); + border: 1px solid transparent; + + &:hover { + background: transparent; + } + + &:focus-visible { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + } +} diff --git a/src/shared/components/action-icon/action-icon.tsx b/src/shared/components/action-icon/action-icon.tsx new file mode 100644 index 00000000..b25877d6 --- /dev/null +++ b/src/shared/components/action-icon/action-icon.tsx @@ -0,0 +1,110 @@ +import { + ElementProps, + ActionIcon as MantineActionIcon, + ActionIconProps as MantineActionIconProps, +} from '@mantine/core'; +import { forwardRef } from 'react'; + +import styles from './action-icon.module.css'; + +import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon'; +import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip'; +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; + +export interface ActionIconProps + extends ElementProps<'button', keyof MantineActionIconProps>, + MantineActionIconProps { + icon?: keyof typeof AppIcon; + iconProps?: Omit<IconProps, 'icon'>; + tooltip?: Omit<TooltipProps, 'children'>; +} + +const _ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>( + ( + { + children, + classNames, + icon, + iconProps, + size = 'sm', + tooltip, + variant = 'default', + ...props + }, + ref, + ) => { + const actionIconProps: ActionIconProps = { + classNames: { + root: styles.root, + ...classNames, + }, + size, + variant, + ...props, + }; + + if (tooltip && icon) { + return ( + <Tooltip + withinPortal + {...tooltip} + > + <MantineActionIcon + ref={ref} + {...actionIconProps} + > + <Icon + icon={icon} + size={actionIconProps.size} + {...iconProps} + /> + </MantineActionIcon> + </Tooltip> + ); + } + + if (icon) { + return ( + <MantineActionIcon + ref={ref} + {...actionIconProps} + > + <Icon + icon={icon} + size={actionIconProps.size} + {...iconProps} + /> + </MantineActionIcon> + ); + } + + if (tooltip) { + return ( + <Tooltip + withinPortal + {...tooltip} + > + <MantineActionIcon + ref={ref} + {...actionIconProps} + > + {children} + </MantineActionIcon> + </Tooltip> + ); + } + + return ( + <MantineActionIcon + ref={ref} + {...actionIconProps} + > + {children} + </MantineActionIcon> + ); + }, +); + +export const ActionIcon = createPolymorphicComponent<'button', ActionIconProps>(_ActionIcon); +export const ActionIconGroup = MantineActionIcon.Group; +export const ActionIconSection = MantineActionIcon.GroupSection; diff --git a/src/shared/components/badge/badge.module.css b/src/shared/components/badge/badge.module.css new file mode 100644 index 00000000..3db9e5c0 --- /dev/null +++ b/src/shared/components/badge/badge.module.css @@ -0,0 +1,13 @@ +.root[data-variant='filled'] { + background: var(--theme-colors-primary-filled); +} + +.root[data-variant='outline'] { + background: transparent; +} + +.root { + padding: var(--theme-spacing-xs) var(--theme-spacing-sm); + font-weight: 500; + background: var(--theme-colors-surface); +} diff --git a/src/shared/components/badge/badge.tsx b/src/shared/components/badge/badge.tsx new file mode 100644 index 00000000..74586be1 --- /dev/null +++ b/src/shared/components/badge/badge.tsx @@ -0,0 +1,28 @@ +import { + ElementProps, + Badge as MantineBadge, + BadgeProps as MantineBadgeProps, +} from '@mantine/core'; + +import styles from './badge.module.css'; + +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; + +export interface BadgeProps + extends ElementProps<'div', keyof MantineBadgeProps>, + MantineBadgeProps {} + +const _Badge = ({ children, classNames, variant = 'default', ...props }: BadgeProps) => { + return ( + <MantineBadge + classNames={{ root: styles.root, ...classNames }} + radius="md" + variant={variant} + {...props} + > + {children} + </MantineBadge> + ); +}; + +export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge); diff --git a/src/shared/components/box/box.tsx b/src/shared/components/box/box.tsx new file mode 100644 index 00000000..0ad05a25 --- /dev/null +++ b/src/shared/components/box/box.tsx @@ -0,0 +1,7 @@ +import { ElementProps, Box as MantineBox, BoxProps as MantineBoxProps } from '@mantine/core'; + +export interface BoxProps extends ElementProps<'div', keyof MantineBoxProps>, MantineBoxProps {} + +export const Box = ({ children, ...props }: BoxProps) => { + return <MantineBox {...props}>{children}</MantineBox>; +}; diff --git a/src/shared/components/button/button.module.css b/src/shared/components/button/button.module.css new file mode 100644 index 00000000..dd0af991 --- /dev/null +++ b/src/shared/components/button/button.module.css @@ -0,0 +1,156 @@ +.root { + font-weight: 500; + border: 1px solid transparent; + transition: + background-color 0.2s ease-in-out, + border-color 0.2s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } + + &[data-variant='default'] { + color: var(--theme-colors-foreground); + background-color: var(--theme-colors-surface); + + &:hover { + background: lighten(var(--theme-colors-surface), 5%); + } + + &:focus-visible { + background: lighten(var(--theme-colors-surface), 10%); + } + } + + &[data-variant='outline'] { + --button-border: var(--theme-colors-border); + + color: var(--theme-colors-foreground); + border: 1px solid var(--theme-colors-border); + + &:hover { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + + &:focus-visible { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + } + + &[data-variant='filled'] { + color: var(--theme-colors-primary-contrast); + background: var(--theme-colors-primary-filled); + border: 1px solid transparent; + transition: background-color 0.2s ease-in-out; + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-primary-filled), 10%); + } + } + + &[data-variant='state-error'] { + background: var(--theme-colors-state-error); + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-state-error), 10%); + } + } + + &[data-variant='state-info'] { + background: var(--theme-colors-state-info); + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-state-info), 10%); + } + } + + &[data-variant='state-success'] { + background: var(--theme-colors-state-success); + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-state-success), 10%); + } + } + + &[data-variant='state-warning'] { + background: var(--theme-colors-state-warning); + + &:hover, + &:focus-visible { + background: darken(var(--theme-colors-state-warning), 10%); + } + } + + &[data-variant='subtle'] { + color: var(--theme-colors-foreground); + background: transparent; + + &:hover, + &:active, + &:focus-visible { + background-color: lighten(var(--button-bg), 10%); + } + } + + &[data-variant='secondary'] { + border: 1px solid transparent; + + &:hover { + background-color: darken(var(--button-bg), 5%); + } + + &:focus-visible { + background-color: darken(var(--button-bg), 10%); + } + } + + &[data-variant='transparent'] { + color: var(--theme-colors-foreground); + border: 1px solid transparent; + transition: color 0.2s ease-in-out; + + &:hover { + background-color: transparent; + + @mixin dark { + color: lighten(var(--theme-colors-foreground), 10%); + } + + @mixin light { + color: darken(var(--theme-colors-foreground), 10%); + } + } + + &:focus-visible { + border: 1px solid lighten(var(--theme-colors-border), 10%); + } + } +} + +.loader { + display: none; +} + +.section { + display: flex; + margin-inline-end: var(--theme-spacing-sm); +} + +.button-inner.loading { + color: transparent; +} + +.spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); +} + +.uppercase { + text-transform: uppercase; +} diff --git a/src/shared/components/button/button.tsx b/src/shared/components/button/button.tsx new file mode 100644 index 00000000..de864128 --- /dev/null +++ b/src/shared/components/button/button.tsx @@ -0,0 +1,155 @@ +import type { ButtonVariant, ButtonProps as MantineButtonProps } from '@mantine/core'; + +import { ElementProps, Button as MantineButton } from '@mantine/core'; +import { useTimeout } from '@mantine/hooks'; +import clsx from 'clsx'; +import { forwardRef, useCallback, useRef, useState } from 'react'; + +import styles from './button.module.css'; + +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip'; +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; + +export interface ButtonProps + extends ElementProps<'button', keyof MantineButtonProps>, + MantineButtonProps, + MantineButtonProps { + tooltip?: Omit<TooltipProps, 'children'>; + uppercase?: boolean; + variant?: ExtendedButtonVariant; +} + +type ExtendedButtonVariant = + | 'state-error' + | 'state-info' + | 'state-success' + | 'state-warning' + | ButtonVariant; + +export const _Button = forwardRef<HTMLButtonElement, ButtonProps>( + ( + { + children, + classNames, + loading, + size = 'sm', + style, + tooltip, + uppercase, + variant = 'default', + ...props + }: ButtonProps, + ref, + ) => { + if (tooltip) { + return ( + <Tooltip + withinPortal + {...tooltip} + > + <MantineButton + autoContrast + classNames={{ + label: clsx(styles.label, { + [styles.uppercase]: uppercase, + }), + loader: styles.loader, + root: styles.root, + section: styles.section, + ...classNames, + }} + ref={ref} + size={size} + style={style} + variant={variant} + {...props} + > + {children} + {loading && ( + <div className={styles.spinner}> + <Spinner /> + </div> + )} + </MantineButton> + </Tooltip> + ); + } + + return ( + <MantineButton + classNames={{ + label: clsx(styles.label, { + [styles.uppercase]: uppercase, + }), + loader: styles.loader, + root: styles.root, + section: styles.section, + ...classNames, + }} + ref={ref} + size={size} + style={style} + variant={variant} + {...props} + > + {children} + {loading && ( + <div className={styles.spinner}> + <Spinner /> + </div> + )} + </MantineButton> + ); + }, +); + +export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button); + +interface TimeoutButtonProps extends ButtonProps { + timeoutProps: { + callback: () => void; + duration: number; + }; +} + +export const TimeoutButton = ({ timeoutProps, ...props }: TimeoutButtonProps) => { + const [, setTimeoutRemaining] = useState(timeoutProps.duration); + const [isRunning, setIsRunning] = useState(false); + const intervalRef = useRef(0); + + const callback = () => { + timeoutProps.callback(); + setTimeoutRemaining(timeoutProps.duration); + clearInterval(intervalRef.current); + setIsRunning(false); + }; + + const { clear, start } = useTimeout(callback, timeoutProps.duration); + + const startTimeout = useCallback(() => { + if (isRunning) { + clearInterval(intervalRef.current); + setIsRunning(false); + clear(); + } else { + setIsRunning(true); + start(); + + const intervalId = window.setInterval(() => { + setTimeoutRemaining((prev) => prev - 100); + }, 100); + + intervalRef.current = intervalId; + } + }, [clear, isRunning, start]); + + return ( + <Button + onClick={startTimeout} + {...props} + > + {isRunning ? 'Cancel' : props.children} + </Button> + ); +}; diff --git a/src/shared/components/center/center.tsx b/src/shared/components/center/center.tsx new file mode 100644 index 00000000..2458236e --- /dev/null +++ b/src/shared/components/center/center.tsx @@ -0,0 +1,22 @@ +import { Center as MantineCenter, CenterProps as MantineCenterProps } from '@mantine/core'; +import { forwardRef, MouseEvent } from 'react'; + +export interface CenterProps extends MantineCenterProps { + onClick?: (e: MouseEvent<HTMLDivElement>) => void; +} + +export const Center = forwardRef<HTMLDivElement, CenterProps>( + ({ children, classNames, onClick, style, ...props }, ref) => { + return ( + <MantineCenter + classNames={{ ...classNames }} + onClick={onClick} + ref={ref} + style={{ ...style }} + {...props} + > + {children} + </MantineCenter> + ); + }, +); diff --git a/src/shared/components/checkbox-select/checkbox-select.module.css b/src/shared/components/checkbox-select/checkbox-select.module.css new file mode 100644 index 00000000..20a872ff --- /dev/null +++ b/src/shared/components/checkbox-select/checkbox-select.module.css @@ -0,0 +1,53 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; +} + +.root { + padding: 0 var(--mantine-spacing-sm); +} + +.body { + display: flex; + align-items: center; + width: 100%; + height: 100%; +} + +.label-wrapper { + width: 100%; + height: 100%; +} + +.label { + display: flex; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--mantine-font-size-sm); + white-space: nowrap; + user-select: none; +} + +.item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + width: 100%; + padding: var(--mantine-spacing-xs); +} + +.dragging { + opacity: 0.5; +} + +.dragged-over-top { + box-shadow: inset 0 2px 0 0 var(--mantine-color-secondary-7); +} + +.dragged-over-bottom { + box-shadow: inset 0 -2px 0 0 var(--mantine-color-secondary-7); +} diff --git a/src/shared/components/checkbox-select/checkbox-select.tsx b/src/shared/components/checkbox-select/checkbox-select.tsx new file mode 100644 index 00000000..b1ccea12 --- /dev/null +++ b/src/shared/components/checkbox-select/checkbox-select.tsx @@ -0,0 +1,167 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +import { + attachClosestEdge, + extractClosestEdge, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; + +import styles from './checkbox-select.module.css'; + +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; +import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; + +interface CheckboxSelectProps { + data: { label: string; value: string }[]; + enableDrag?: boolean; + onChange: (value: string[]) => void; + value: string[]; +} + +export const CheckboxSelect = ({ data, enableDrag, onChange, value }: CheckboxSelectProps) => { + const handleChange = (values: string[]) => { + onChange(values); + }; + + return ( + <div className={styles.container}> + {data.map((option) => ( + <CheckboxSelectItem + enableDrag={enableDrag} + key={option.value} + onChange={handleChange} + option={option} + values={value} + /> + ))} + </div> + ); +}; + +interface CheckboxSelectItemProps { + enableDrag?: boolean; + onChange: (values: string[]) => void; + option: { label: string; value: string }; + values: string[]; +} + +function CheckboxSelectItem({ enableDrag, onChange, option, values }: CheckboxSelectItemProps) { + const ref = useRef<HTMLInputElement | null>(null); + const dragHandleRef = useRef<HTMLButtonElement | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null); + + useEffect(() => { + if (!ref.current || !dragHandleRef.current || !enableDrag) { + return; + } + + return combine( + draggable({ + element: dragHandleRef.current, + getInitialData: () => { + const data = dndUtils.generateDragData({ + id: [option.value], + operation: [DragOperation.REORDER], + type: DragTarget.GENERIC, + }); + return data; + }, + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: (data) => { + disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage }); + }, + }), + dropTargetForElements({ + canDrop: (args) => { + const data = args.source.data as unknown as DragData; + const isSelf = (args.source.data.id as string[])[0] === option.value; + return dndUtils.isDropTarget(data.type, [DragTarget.GENERIC]) && !isSelf; + }, + element: ref.current, + getData: ({ element, input }) => { + const data = dndUtils.generateDragData({ + id: [option.value], + operation: [DragOperation.REORDER], + type: DragTarget.GENERIC, + }); + + return attachClosestEdge(data, { + allowedEdges: ['top', 'bottom'], + element, + input, + }); + }, + onDrag: (args) => { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + setIsDraggedOver(closestEdgeOfTarget); + }, + onDragLeave: () => { + setIsDraggedOver(null); + }, + onDrop: (args) => { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + + const from = args.source.data.id as string[]; + const to = args.self.data.id as string[]; + + const newOrder = dndUtils.reorderById({ + edge: closestEdgeOfTarget, + idFrom: from[0], + idTo: to[0], + list: values, + }); + + onChange(newOrder); + setIsDraggedOver(null); + }, + }), + ); + }, [values, enableDrag, onChange, option.value]); + + return ( + <div + className={clsx(styles.item, { + [styles.draggedOverBottom]: isDraggedOver === 'bottom', + [styles.draggedOverTop]: isDraggedOver === 'top', + [styles.dragging]: isDragging, + })} + ref={ref} + > + {enableDrag && ( + <ActionIcon + className={styles.dragHandle} + icon="dragVertical" + ref={dragHandleRef} + size="xs" + variant="default" + /> + )} + <Checkbox + checked={values.includes(option.value)} + label={option.label} + onChange={(e) => { + onChange( + e.target.checked + ? [...values, option.value] + : values.filter((v) => v !== option.value), + ); + }} + variant="filled" + /> + </div> + ); +} diff --git a/src/shared/components/checkbox/checkbox.module.css b/src/shared/components/checkbox/checkbox.module.css new file mode 100644 index 00000000..7aa021db --- /dev/null +++ b/src/shared/components/checkbox/checkbox.module.css @@ -0,0 +1,13 @@ +.input { + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + + &[data-variant='filled'] { + background: var(--theme-colors-background); + } +} + +.input:disabled { + border: 1px solid var(--theme-colors-border); + opacity: 0.6; +} diff --git a/src/shared/components/checkbox/checkbox.tsx b/src/shared/components/checkbox/checkbox.tsx new file mode 100644 index 00000000..2b2c7d3b --- /dev/null +++ b/src/shared/components/checkbox/checkbox.tsx @@ -0,0 +1,22 @@ +import { Checkbox as MantineCheckbox, CheckboxProps as MantineCheckboxProps } from '@mantine/core'; +import { forwardRef } from 'react'; + +import styles from './checkbox.module.css'; + +interface CheckboxProps extends MantineCheckboxProps {} + +export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( + ({ classNames, ...props }: CheckboxProps, ref) => { + return ( + <MantineCheckbox + classNames={{ + input: styles.input, + label: styles.label, + ...classNames, + }} + ref={ref} + {...props} + /> + ); + }, +); diff --git a/src/shared/components/code/code.module.css b/src/shared/components/code/code.module.css new file mode 100644 index 00000000..81151b5d --- /dev/null +++ b/src/shared/components/code/code.module.css @@ -0,0 +1,4 @@ +.root { + background: var(--theme-colors-surface); + border-radius: var(--theme-radius-md); +} diff --git a/src/shared/components/code/code.tsx b/src/shared/components/code/code.tsx new file mode 100644 index 00000000..6325590d --- /dev/null +++ b/src/shared/components/code/code.tsx @@ -0,0 +1,18 @@ +import { Code as MantineCode, CodeProps as MantineCodeProps } from '@mantine/core'; + +import styles from './code.module.css'; + +export interface CodeProps extends MantineCodeProps {} + +export const Code = ({ classNames, ...props }: CodeProps) => { + return ( + <MantineCode + {...props} + classNames={{ + ...classNames, + root: styles.root, + }} + spellCheck={false} + /> + ); +}; diff --git a/src/shared/components/color-input/color-input.module.css b/src/shared/components/color-input/color-input.module.css new file mode 100644 index 00000000..1a47f013 --- /dev/null +++ b/src/shared/components/color-input/color-input.module.css @@ -0,0 +1,37 @@ +.root { + & [data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} + +.dropdown { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid transparent; + border-radius: var(--theme-radius-md); + box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); +} diff --git a/src/shared/components/color-input/color-input.tsx b/src/shared/components/color-input/color-input.tsx new file mode 100644 index 00000000..ea7b6b61 --- /dev/null +++ b/src/shared/components/color-input/color-input.tsx @@ -0,0 +1,30 @@ +import { + ColorInput as MantineColorInput, + ColorInputProps as MantineColorInputProps, +} from '@mantine/core'; + +import styles from './color-input.module.css'; + +export interface ColorInputProps extends MantineColorInputProps {} + +export const ColorInput = ({ + classNames, + size = 'sm', + variant = 'default', + ...props +}: ColorInputProps) => { + return ( + <MantineColorInput + classNames={{ + dropdown: styles.dropdown, + input: styles.input, + label: styles.label, + root: styles.root, + ...classNames, + }} + size={size} + variant={variant} + {...props} + /> + ); +}; diff --git a/src/shared/components/copy-button/copy-button.tsx b/src/shared/components/copy-button/copy-button.tsx new file mode 100644 index 00000000..1d12d2b4 --- /dev/null +++ b/src/shared/components/copy-button/copy-button.tsx @@ -0,0 +1,10 @@ +import { + CopyButton as MantineCopyButton, + CopyButtonProps as MantineCopyButtonProps, +} from '@mantine/core'; + +export interface CopyButtonProps extends MantineCopyButtonProps {} + +export const CopyButton = ({ children, ...props }: CopyButtonProps) => { + return <MantineCopyButton {...props}>{children}</MantineCopyButton>; +}; diff --git a/src/shared/components/date-picker/date-picker.module.css b/src/shared/components/date-picker/date-picker.module.css new file mode 100644 index 00000000..1d540394 --- /dev/null +++ b/src/shared/components/date-picker/date-picker.module.css @@ -0,0 +1,19 @@ +.root { + & [data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid transparent; +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} diff --git a/src/shared/components/date-picker/date-picker.tsx b/src/shared/components/date-picker/date-picker.tsx new file mode 100644 index 00000000..add07846 --- /dev/null +++ b/src/shared/components/date-picker/date-picker.tsx @@ -0,0 +1,35 @@ +import type { DateInputProps as MantineDateInputProps } from '@mantine/dates'; + +import { DateInput as MantineDateInput } from '@mantine/dates'; + +import styles from './date-picker.module.css'; + +interface DateInputProps extends MantineDateInputProps { + maxWidth?: number | string; + width?: number | string; +} + +export const DateInput = ({ + classNames, + maxWidth, + size = 'sm', + style, + width, + ...props +}: DateInputProps) => { + return ( + <MantineDateInput + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + ...classNames, + }} + size={size} + style={{ maxWidth, width, ...style }} + {...props} + /> + ); +}; diff --git a/src/shared/components/date-time-picker/date-time-picker.module.css b/src/shared/components/date-time-picker/date-time-picker.module.css new file mode 100644 index 00000000..1d540394 --- /dev/null +++ b/src/shared/components/date-time-picker/date-time-picker.module.css @@ -0,0 +1,19 @@ +.root { + & [data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid transparent; +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} diff --git a/src/shared/components/date-time-picker/date-time-picker.tsx b/src/shared/components/date-time-picker/date-time-picker.tsx new file mode 100644 index 00000000..e9c3ffac --- /dev/null +++ b/src/shared/components/date-time-picker/date-time-picker.tsx @@ -0,0 +1,37 @@ +import type { DateTimePickerProps as MantineDateTimePickerProps } from '@mantine/dates'; + +import { DateTimePicker as MantineDateTimePicker } from '@mantine/dates'; + +import styles from './date-time-picker.module.css'; + +interface DateTimePickerProps extends MantineDateTimePickerProps { + maxWidth?: number | string; + width?: number | string; +} + +export const DateTimePicker = ({ + classNames, + maxWidth, + popoverProps, + size = 'sm', + style, + width, + ...props +}: DateTimePickerProps) => { + return ( + <MantineDateTimePicker + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + ...classNames, + }} + popoverProps={{ withinPortal: true, ...popoverProps }} + size={size} + style={{ maxWidth, width, ...style }} + {...props} + /> + ); +}; diff --git a/src/shared/components/dialog/dialog.module.css b/src/shared/components/dialog/dialog.module.css new file mode 100644 index 00000000..261cdf1d --- /dev/null +++ b/src/shared/components/dialog/dialog.module.css @@ -0,0 +1,5 @@ +.root { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%); +} diff --git a/src/shared/components/dialog/dialog.tsx b/src/shared/components/dialog/dialog.tsx new file mode 100644 index 00000000..f5a9c9bc --- /dev/null +++ b/src/shared/components/dialog/dialog.tsx @@ -0,0 +1,19 @@ +import type { DialogProps as MantineDialogProps } from '@mantine/core'; + +import { Dialog as MantineDialog } from '@mantine/core'; + +import styles from './dialog.module.css'; + +interface DialogProps extends MantineDialogProps {} + +export const Dialog = ({ classNames, style, ...props }: DialogProps) => { + return ( + <MantineDialog + classNames={{ root: styles.root, ...classNames }} + style={{ + ...style, + }} + {...props} + /> + ); +}; diff --git a/src/shared/components/divider/divider.module.css b/src/shared/components/divider/divider.module.css new file mode 100644 index 00000000..5ecd8874 --- /dev/null +++ b/src/shared/components/divider/divider.module.css @@ -0,0 +1,3 @@ +.root { + --divider-color: var(--theme-colors-border); +} diff --git a/src/shared/components/divider/divider.tsx b/src/shared/components/divider/divider.tsx new file mode 100644 index 00000000..a16af891 --- /dev/null +++ b/src/shared/components/divider/divider.tsx @@ -0,0 +1,19 @@ +import { Divider as MantineDivider, DividerProps as MantineDividerProps } from '@mantine/core'; +import { forwardRef } from 'react'; + +import styles from './divider.module.css'; + +export interface DividerProps extends MantineDividerProps {} + +export const Divider = forwardRef<HTMLDivElement, DividerProps>( + ({ classNames, style, ...props }, ref) => { + return ( + <MantineDivider + classNames={{ root: styles.root, ...classNames }} + ref={ref} + style={{ ...style }} + {...props} + /> + ); + }, +); diff --git a/src/shared/components/dropdown-menu/dropdown-menu.module.css b/src/shared/components/dropdown-menu/dropdown-menu.module.css new file mode 100644 index 00000000..4fe3ae8d --- /dev/null +++ b/src/shared/components/dropdown-menu/dropdown-menu.module.css @@ -0,0 +1,58 @@ +.menu-item { + position: relative; + display: flex; + align-items: center; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + cursor: default; +} + +.menu-item:disabled { + opacity: 0.6; +} + +.menu-item-label { + margin-right: var(--theme-spacing-md); + margin-left: var(--theme-spacing-md); + font-size: var(--theme-font-size-sm); + color: var(--theme-colors-surface-foreground); +} + +.selected { + &::before { + position: absolute; + top: 50%; + left: 2px; + width: 4px; + height: 50%; + content: ''; + background-color: var(--theme-colors-primary-filled); + border-radius: var(--theme-border-radius-xl); + transform: translateY(-50%); + } +} + +.menu-item-label-danger { + color: var(--theme-colors-state-error); +} + +.menu-item-right-section { + display: flex; +} + +.menu-dropdown { + padding: var(--theme-spacing-xs); + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%)); +} + +.menu-divider { + padding: 0; + margin: 0; + border-color: var(--theme-colors-border); +} + +.menu-item-section svg { + font-size: var(--theme-font-size-sm); +} diff --git a/src/shared/components/dropdown-menu/dropdown-menu.tsx b/src/shared/components/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 00000000..e0a89246 --- /dev/null +++ b/src/shared/components/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,102 @@ +import type { + MenuDividerProps as MantineMenuDividerProps, + MenuDropdownProps as MantineMenuDropdownProps, + MenuItemProps as MantineMenuItemProps, + MenuLabelProps as MantineMenuLabelProps, + MenuProps as MantineMenuProps, +} from '@mantine/core'; + +import { Menu as MantineMenu } from '@mantine/core'; +import clsx from 'clsx'; +import { ReactNode } from 'react'; + +import styles from './dropdown-menu.module.css'; + +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; + +type MenuDividerProps = MantineMenuDividerProps; +type MenuDropdownProps = MantineMenuDropdownProps; +interface MenuItemProps extends MantineMenuItemProps { + children: ReactNode; + isDanger?: boolean; + isSelected?: boolean; +} +type MenuLabelProps = MantineMenuLabelProps; +type MenuProps = MantineMenuProps; + +export const DropdownMenu = ({ children, ...props }: MenuProps) => { + return ( + <MantineMenu + classNames={{ + dropdown: styles['menu-dropdown'], + itemSection: styles['menu-item-section'], + }} + transitionProps={{ + transition: 'fade', + }} + withinPortal + {...props} + > + {children} + </MantineMenu> + ); +}; + +const MenuLabel = ({ children, ...props }: MenuLabelProps) => { + return ( + <MantineMenu.Label + className={styles['menu-label']} + {...props} + > + {children} + </MantineMenu.Label> + ); +}; + +const pMenuItem = ({ children, isDanger, isSelected, ...props }: MenuItemProps) => { + return ( + <MantineMenu.Item + className={clsx(styles['menu-item'], { + [styles.selected]: isSelected, + })} + {...props} + > + <span + className={clsx(styles['menu-item-label'], { + [styles['menu-item-label-danger']]: isDanger, + [styles['menu-item-label-normal']]: !isDanger, + })} + > + {children} + </span> + </MantineMenu.Item> + ); +}; + +const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => { + return ( + <MantineMenu.Dropdown + className={styles['menu-dropdown']} + {...props} + > + {children} + </MantineMenu.Dropdown> + ); +}; + +const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem); + +const MenuDivider = ({ ...props }: MenuDividerProps) => { + return ( + <MantineMenu.Divider + className={styles['menu-divider']} + {...props} + /> + ); +}; + +DropdownMenu.Label = MenuLabel; +DropdownMenu.Item = MenuItem; +DropdownMenu.Target = MantineMenu.Target; +DropdownMenu.Dropdown = MenuDropdown; +DropdownMenu.Divider = MenuDivider; diff --git a/src/shared/components/file-input/file-input.module.css b/src/shared/components/file-input/file-input.module.css new file mode 100644 index 00000000..d8f49d46 --- /dev/null +++ b/src/shared/components/file-input/file-input.module.css @@ -0,0 +1,40 @@ +.root { + transition: width 0.3s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} + +.label { + margin-bottom: var(--theme-spacing-sm); + font-family: var(--theme-label-font-family); +} diff --git a/src/shared/components/file-input/file-input.tsx b/src/shared/components/file-input/file-input.tsx new file mode 100644 index 00000000..a32c74bd --- /dev/null +++ b/src/shared/components/file-input/file-input.tsx @@ -0,0 +1,49 @@ +import { + FileInput as MantineFileInput, + FileInputProps as MantineFileInputProps, +} from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './file-input.module.css'; + +export interface FileInputProps extends MantineFileInputProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>( + ( + { + children, + classNames, + maxWidth, + size = 'sm', + style, + variant = 'default', + width, + ...props + }, + ref, + ) => { + return ( + <MantineFileInput + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + wrapper: styles.wrapper, + ...classNames, + }} + ref={ref} + size={size} + style={{ maxWidth, width, ...style }} + variant={variant} + {...props} + > + {children} + </MantineFileInput> + ); + }, +); diff --git a/src/shared/components/flex/flex.tsx b/src/shared/components/flex/flex.tsx new file mode 100644 index 00000000..8e6b78d1 --- /dev/null +++ b/src/shared/components/flex/flex.tsx @@ -0,0 +1,17 @@ +import { Flex as MantineFlex, FlexProps as MantineFlexProps } from '@mantine/core'; +import { forwardRef } from 'react'; + +export interface FlexProps extends MantineFlexProps {} + +export const Flex = forwardRef<HTMLDivElement, FlexProps>(({ children, ...props }, ref) => { + return ( + <MantineFlex + classNames={{ ...props.classNames }} + ref={ref} + style={{ ...props.style }} + {...props} + > + {children} + </MantineFlex> + ); +}); diff --git a/src/shared/components/grid/grid.tsx b/src/shared/components/grid/grid.tsx new file mode 100644 index 00000000..d6c1bdd3 --- /dev/null +++ b/src/shared/components/grid/grid.tsx @@ -0,0 +1,15 @@ +import { Grid as MantineGrid, GridProps as MantineGridProps } from '@mantine/core'; + +export interface GridProps extends MantineGridProps {} + +export const Grid = ({ classNames, style, ...props }: GridProps) => { + return ( + <MantineGrid + classNames={{ ...classNames }} + style={{ ...style }} + {...props} + /> + ); +}; + +Grid.Col = MantineGrid.Col; diff --git a/src/shared/components/group/group.tsx b/src/shared/components/group/group.tsx new file mode 100644 index 00000000..eb2e73c3 --- /dev/null +++ b/src/shared/components/group/group.tsx @@ -0,0 +1,17 @@ +import { Group as MantineGroup, GroupProps as MantineGroupProps } from '@mantine/core'; +import { forwardRef } from 'react'; + +export interface GroupProps extends MantineGroupProps {} + +export const Group = forwardRef<HTMLDivElement, GroupProps>(({ children, ...props }, ref) => { + return ( + <MantineGroup + classNames={{ ...props.classNames }} + ref={ref} + style={{ ...props.style }} + {...props} + > + {children} + </MantineGroup> + ); +}); diff --git a/src/shared/components/hover-card/hover-card.module.css b/src/shared/components/hover-card/hover-card.module.css new file mode 100644 index 00000000..70a86817 --- /dev/null +++ b/src/shared/components/hover-card/hover-card.module.css @@ -0,0 +1,7 @@ +.dropdown { + padding: var(--theme-spacing-xs); + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%)); +} diff --git a/src/shared/components/hover-card/hover-card.tsx b/src/shared/components/hover-card/hover-card.tsx new file mode 100644 index 00000000..b0e73111 --- /dev/null +++ b/src/shared/components/hover-card/hover-card.tsx @@ -0,0 +1,25 @@ +import { + HoverCard as MantineHoverCard, + HoverCardProps as MantineHoverCardProps, +} from '@mantine/core'; + +import styles from './hover-card.module.css'; + +interface HoverCardProps extends MantineHoverCardProps {} + +export const HoverCard = ({ children, classNames, ...props }: HoverCardProps) => { + return ( + <MantineHoverCard + classNames={{ + dropdown: styles.dropdown, + ...classNames, + }} + {...props} + > + {children} + </MantineHoverCard> + ); +}; + +HoverCard.Target = MantineHoverCard.Target; +HoverCard.Dropdown = MantineHoverCard.Dropdown; diff --git a/src/shared/components/icon/icon.module.css b/src/shared/components/icon/icon.module.css new file mode 100644 index 00000000..aa86d196 --- /dev/null +++ b/src/shared/components/icon/icon.module.css @@ -0,0 +1,123 @@ +.size-xs { + font-size: var(--theme-font-size-xs); +} + +.size-sm { + font-size: var(--theme-font-size-sm); +} + +.size-md { + font-size: var(--theme-font-size-md); +} + +.size-lg { + font-size: var(--theme-font-size-lg); +} + +.size-xl { + font-size: var(--theme-font-size-xl); +} + +.size-2xl { + font-size: var(--theme-font-size-2xl); +} + +.size-3xl { + font-size: var(--theme-font-size-3xl); +} + +.size-4xl { + font-size: var(--theme-font-size-4xl); +} + +.size-5xl { + font-size: var(--theme-font-size-5xl); +} + +.color-default { + color: var(--theme-colors-foreground); +} + +.color-primary { + color: var(--theme-colors-primary-filled); +} + +.color-muted { + color: var(--theme-colors-foreground-muted); +} + +.color-success { + color: var(--theme-colors-state-success); +} + +.color-error { + color: var(--theme-colors-state-error); +} + +.color-info { + color: var(--theme-colors-state-info); +} + +.color-warn { + color: var(--theme-colors-state-warn); +} + +.fill { + fill: transparent; +} + +.fill-default { + fill: var(--theme-colors-foreground); +} + +.fill-inherit { + fill: inherit; +} + +.fill-primary { + fill: var(--theme-colors-primary-filled); +} + +.fill-muted { + fill: var(--theme-colors-foreground-muted); +} + +.fill-success { + fill: var(--theme-colors-state-success); +} + +.fill-error { + fill: var(--theme-colors-state-error); +} + +.fill-info { + fill: var(--theme-colors-state-info); +} + +.fill-warn { + fill: var(--theme-colors-state-warn); +} + +.spin { + animation: spin 1s linear infinite; +} + +.pulse { + animation: pulse 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 0% { + opacity: 0.5; + } +} diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx new file mode 100644 index 00000000..7cfbdf29 --- /dev/null +++ b/src/shared/components/icon/icon.tsx @@ -0,0 +1,269 @@ +import clsx from 'clsx'; +import { motion } from 'motion/react'; +import { type ComponentType, forwardRef } from 'react'; +import { IconBaseProps } from 'react-icons'; +import { FaLastfmSquare } from 'react-icons/fa'; +import { + LuAppWindow, + LuArrowDown, + LuArrowDownToLine, + LuArrowDownWideNarrow, + LuArrowLeft, + LuArrowLeftToLine, + LuArrowRight, + LuArrowRightToLine, + LuArrowUp, + LuArrowUpDown, + LuArrowUpNarrowWide, + LuArrowUpToLine, + LuBookOpen, + LuCheck, + LuChevronDown, + LuChevronLast, + LuChevronLeft, + LuChevronRight, + LuChevronUp, + LuCircleCheck, + LuCircleX, + LuClipboardCopy, + LuClock3, + LuCloudDownload, + LuCornerUpRight, + LuDelete, + LuDisc3, + LuDownload, + LuEllipsis, + LuEllipsisVertical, + LuExternalLink, + LuFlag, + LuFolderOpen, + LuGauge, + LuGithub, + LuGripHorizontal, + LuGripVertical, + LuHardDrive, + LuHash, + LuHeart, + LuHeartCrack, + LuImage, + LuImageOff, + LuInfinity, + LuInfo, + LuKeyboard, + LuLayoutGrid, + LuLibrary, + LuList, + LuListFilter, + LuListMinus, + LuListMusic, + LuListPlus, + LuLoader, + LuLock, + LuLogIn, + LuLogOut, + LuMenu, + LuMinus, + LuMusic, + LuMusic2, + LuPanelRightClose, + LuPanelRightOpen, + LuPause, + LuPencilLine, + LuPlay, + LuPlus, + LuRadio, + LuRotateCw, + LuSave, + LuSearch, + LuSettings2, + LuShare2, + LuShieldAlert, + LuShuffle, + LuSkipBack, + LuSkipForward, + LuSlidersHorizontal, + LuSquare, + LuSquareCheck, + LuSquareMenu, + LuStar, + LuStepBack, + LuStepForward, + LuTable, + LuTriangleAlert, + LuUser, + LuUserPen, + LuUserRoundCog, + LuVolume1, + LuVolume2, + LuVolumeX, + LuX, +} from 'react-icons/lu'; +import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; +import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri'; +import { SiMusicbrainz } from 'react-icons/si'; + +import styles from './icon.module.css'; + +export type AppIconSelection = keyof typeof AppIcon; + +export const AppIcon = { + add: LuPlus, + album: LuDisc3, + appWindow: LuAppWindow, + arrowDown: LuArrowDown, + arrowDownS: LuChevronDown, + arrowDownToLine: LuArrowDownToLine, + arrowLeft: LuArrowLeft, + arrowLeftS: LuChevronLeft, + arrowLeftToLine: LuArrowLeftToLine, + arrowRight: LuArrowRight, + arrowRightLast: LuChevronLast, + arrowRightS: LuChevronRight, + arrowRightToLine: LuArrowRightToLine, + arrowUp: LuArrowUp, + arrowUpS: LuChevronUp, + arrowUpToLine: LuArrowUpToLine, + artist: LuUserPen, + brandGitHub: LuGithub, + brandLastfm: FaLastfmSquare, + brandMusicBrainz: SiMusicbrainz, + cache: LuCloudDownload, + check: LuCheck, + clipboardCopy: LuClipboardCopy, + delete: LuDelete, + download: LuDownload, + dragHorizontal: LuGripHorizontal, + dragVertical: LuGripVertical, + dropdown: LuChevronDown, + duration: LuClock3, + edit: LuPencilLine, + ellipsisHorizontal: LuEllipsis, + ellipsisVertical: LuEllipsisVertical, + emptyImage: LuImageOff, + error: LuShieldAlert, + externalLink: LuExternalLink, + favorite: LuHeart, + filter: LuListFilter, + folder: LuFolderOpen, + genre: LuFlag, + hash: LuHash, + home: LuSquareMenu, + image: LuImage, + info: LuInfo, + itemAlbum: LuDisc3, + itemSong: LuMusic, + keyboard: LuKeyboard, + layoutGrid: LuLayoutGrid, + layoutList: LuList, + layoutTable: LuTable, + library: LuLibrary, + list: LuList, + listInfinite: LuInfinity, + listPaginated: LuArrowRightToLine, + lock: LuLock, + mediaNext: LuSkipForward, + mediaPause: LuPause, + mediaPlay: LuPlay, + mediaPlayLast: LuChevronLast, + mediaPlayNext: LuCornerUpRight, + mediaPrevious: LuSkipBack, + mediaRandom: RiPlayListAddLine, + mediaRepeat: RiRepeat2Line, + mediaRepeatOne: RiRepeatOneLine, + mediaSettings: LuSlidersHorizontal, + mediaShuffle: LuShuffle, + mediaSpeed: LuGauge, + mediaStepBackward: LuStepBack, + mediaStepForward: LuStepForward, + mediaStop: LuSquare, + menu: LuMenu, + metadata: LuBookOpen, + minus: LuMinus, + panelRightClose: LuPanelRightClose, + panelRightOpen: LuPanelRightOpen, + playlist: LuListMusic, + playlistAdd: LuListPlus, + playlistDelete: LuListMinus, + plus: LuPlus, + queue: LuList, + radio: LuRadio, + refresh: LuRotateCw, + remove: LuMinus, + save: LuSave, + search: LuSearch, + server: LuHardDrive, + settings: LuSettings2, + share: LuShare2, + signIn: LuLogIn, + signOut: LuLogOut, + sort: LuArrowUpDown, + sortAsc: LuArrowUpNarrowWide, + sortDesc: LuArrowDownWideNarrow, + spinner: LuLoader, + square: LuSquare, + squareCheck: LuSquareCheck, + star: LuStar, + success: LuCircleCheck, + track: LuMusic2, + unfavorite: LuHeartCrack, + user: LuUser, + userManage: LuUserRoundCog, + visibility: MdOutlineVisibility, + visibilityOff: MdOutlineVisibilityOff, + volumeMax: LuVolume2, + volumeMute: LuVolumeX, + volumeNormal: LuVolume1, + warn: LuTriangleAlert, + x: LuX, + xCircle: LuCircleX, +} as const; + +export interface IconProps extends Omit<IconBaseProps, 'color' | 'fill' | 'size'> { + animate?: 'pulse' | 'spin'; + color?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn'; + fill?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn'; + icon: keyof typeof AppIcon; + size?: '2xl' | '3xl' | '4xl' | '5xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number | string; +} + +export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => { + const { animate, className, color, fill, icon, size = 'md' } = props; + + const IconComponent: ComponentType<any> = AppIcon[icon]; + + const classNames = clsx(className, { + [styles.fill]: true, + [styles.pulse]: animate === 'pulse', + [styles.spin]: animate === 'spin', + [styles[`color-${color || fill}`]]: color || fill, + [styles[`fill-${fill}`]]: fill, + [styles[`size-${size}`]]: true, + }); + + return ( + <IconComponent + className={classNames} + fill={fill} + ref={ref} + size={isPredefinedSize(size) ? undefined : size} + /> + ); +}); + +Icon.displayName = 'Icon'; + +export const MotionIcon: ComponentType = motion.create(Icon); + +function isPredefinedSize(size: IconProps['size']) { + return ( + size === '2xl' || + size === '3xl' || + size === '4xl' || + size === '5xl' || + size === 'lg' || + size === 'md' || + size === 'sm' || + size === 'xl' || + size === 'xs' + ); +} diff --git a/src/shared/components/image/image.module.css b/src/shared/components/image/image.module.css new file mode 100644 index 00000000..5ffb0b0f --- /dev/null +++ b/src/shared/components/image/image.module.css @@ -0,0 +1,34 @@ +.image { + width: 100%; + height: 100%; + object-fit: var(--theme-image-fit); + border-radius: var(--theme-radius-md); +} + +.loader { + width: 100%; + height: 100%; +} + +.image-container { + display: flex; + width: 100%; + max-height: 100%; + aspect-ratio: 1 / 1; +} + +.unloader { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + max-height: 100%; + background-color: darken(var(--theme-colors-foreground), 40%); + opacity: 0.3; +} + +.skeleton { + width: 100%; + height: 100%; +} diff --git a/src/shared/components/image/image.tsx b/src/shared/components/image/image.tsx new file mode 100644 index 00000000..a81c853e --- /dev/null +++ b/src/shared/components/image/image.tsx @@ -0,0 +1,93 @@ +import type { ImgHTMLAttributes } from 'react'; + +import clsx from 'clsx'; +import { Img } from 'react-image'; + +import styles from './image.module.css'; + +import { Icon } from '/@/shared/components/icon/icon'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; + +interface ImageContainerProps { + children: React.ReactNode; + className?: string; +} + +interface ImageLoaderProps { + className?: string; +} + +interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> { + containerClassName?: string; + includeLoader?: boolean; + includeUnloader?: boolean; + src: string | string[] | undefined; + thumbHash?: string; +} + +interface ImageUnloaderProps { + className?: string; +} + +export function Image({ + className, + containerClassName, + includeLoader = true, + includeUnloader = true, + src, +}: ImageProps) { + if (src) { + return ( + <Img + className={clsx(styles.image, className)} + container={(children) => ( + <ImageContainer className={containerClassName}>{children}</ImageContainer> + )} + loader={ + includeLoader ? ( + <ImageContainer className={containerClassName}> + <ImageLoader className={className} /> + </ImageContainer> + ) : null + } + loading="eager" + src={src} + unloader={ + includeUnloader ? ( + <ImageContainer className={containerClassName}> + <ImageUnloader className={className} /> + </ImageContainer> + ) : null + } + /> + ); + } + + return <ImageUnloader />; +} + +function ImageContainer({ children, className }: ImageContainerProps) { + return <div className={clsx(styles.imageContainer, className)}>{children}</div>; +} + +function ImageLoader({ className }: ImageLoaderProps) { + return ( + <div className={clsx(styles.loader, className)}> + <Skeleton + className={clsx(styles.skeleton, className)} + enableAnimation={true} + /> + </div> + ); +} + +function ImageUnloader({ className }: ImageUnloaderProps) { + return ( + <div className={clsx(styles.unloader, className)}> + <Icon + icon="emptyImage" + size="xl" + /> + </div> + ); +} diff --git a/src/shared/components/json-input/json-input.module.css b/src/shared/components/json-input/json-input.module.css new file mode 100644 index 00000000..d5fc4f17 --- /dev/null +++ b/src/shared/components/json-input/json-input.module.css @@ -0,0 +1,39 @@ +.root { + transition: width 0.3s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} diff --git a/src/shared/components/json-input/json-input.tsx b/src/shared/components/json-input/json-input.tsx new file mode 100644 index 00000000..2b37d7ea --- /dev/null +++ b/src/shared/components/json-input/json-input.tsx @@ -0,0 +1,49 @@ +import { + JsonInput as MantineJsonInput, + JsonInputProps as MantineJsonInputProps, +} from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './json-input.module.css'; + +export interface JsonInputProps extends MantineJsonInputProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>( + ( + { + children, + classNames, + maxWidth, + size = 'sm', + style, + variant = 'default', + width, + ...props + }, + ref, + ) => { + return ( + <MantineJsonInput + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + wrapper: styles.wrapper, + ...classNames, + }} + ref={ref} + size={size} + style={{ maxWidth, width, ...style }} + variant={variant} + {...props} + > + {children} + </MantineJsonInput> + ); + }, +); diff --git a/src/shared/components/kbd/kbd.tsx b/src/shared/components/kbd/kbd.tsx new file mode 100644 index 00000000..ced4f20b --- /dev/null +++ b/src/shared/components/kbd/kbd.tsx @@ -0,0 +1,7 @@ +import { Kbd as MantineKbd, KbdProps as MantineKbdProps } from '@mantine/core'; + +export interface KbdProps extends MantineKbdProps {} + +export const Kbd = (props: KbdProps) => { + return <MantineKbd {...props} />; +}; diff --git a/src/shared/components/modal/modal.module.css b/src/shared/components/modal/modal.module.css new file mode 100644 index 00000000..66aeb903 --- /dev/null +++ b/src/shared/components/modal/modal.module.css @@ -0,0 +1,17 @@ +.title { + font-size: var(--theme-font-size-lg); + font-weight: 700; +} + +.body { + padding: var(--theme-spacing-sm) var(--theme-spacing-md); +} + +.header { + background: var(--theme-colors-background); + border-bottom: none; +} + +.content { + background: var(--theme-colors-background); +} diff --git a/src/renderer/components/modal/index.tsx b/src/shared/components/modal/modal.tsx similarity index 50% rename from src/renderer/components/modal/index.tsx rename to src/shared/components/modal/modal.tsx index 3607ef8f..60da272a 100644 --- a/src/renderer/components/modal/index.tsx +++ b/src/shared/components/modal/modal.tsx @@ -1,14 +1,18 @@ -import { - Flex, - Group, - Modal as MantineModal, - ModalProps as MantineModalProps, - Stack, -} from '@mantine/core'; +import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core'; import { closeAllModals, ContextModalProps } from '@mantine/modals'; +import { + ModalsProvider as MantineModalsProvider, + ModalsProviderProps as MantineModalsProviderProps, +} from '@mantine/modals'; import React, { ReactNode } from 'react'; -import { Button } from '/@/renderer/components/button'; +import styles from './modal.module.css'; + +import { Button } from '/@/shared/components/button/button'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; export interface ModalProps extends Omit<MantineModalProps, 'onClose'> { children?: ReactNode; @@ -19,11 +23,25 @@ export interface ModalProps extends Omit<MantineModalProps, 'onClose'> { }; } -export const Modal = ({ children, handlers, ...rest }: ModalProps) => { +export const Modal = ({ children, classNames, handlers, ...rest }: ModalProps) => { return ( <MantineModal {...rest} + classNames={{ + body: styles.body, + content: styles.content, + header: styles.header, + root: styles.root, + title: styles.title, + ...classNames, + }} onClose={handlers.close} + radius="lg" + transitionProps={{ + duration: 300, + exitDuration: 300, + transition: 'fade', + }} > {children} </MantineModal> @@ -74,7 +92,7 @@ export const ConfirmModal = ({ return ( <Stack> <Flex>{children}</Flex> - <Group position="right"> + <Group justify="flex-end"> <Button data-focus onClick={handleCancel} @@ -94,3 +112,34 @@ export const ConfirmModal = ({ </Stack> ); }; + +export interface ModalsProviderProps extends MantineModalsProviderProps {} + +export const ModalsProvider = ({ children, ...rest }: ModalsProviderProps) => { + return ( + <MantineModalsProvider + modalProps={{ + centered: true, + classNames: { + body: styles.body, + content: styles.content, + header: styles.header, + root: styles.root, + title: styles.title, + }, + closeButtonProps: { + icon: <Icon icon="x" />, + }, + radius: 'lg', + transitionProps: { + duration: 300, + exitDuration: 300, + transition: 'fade', + }, + }} + {...rest} + > + {children} + </MantineModalsProvider> + ); +}; diff --git a/src/shared/components/multi-select/multi-select.module.css b/src/shared/components/multi-select/multi-select.module.css new file mode 100644 index 00000000..cd9ef10e --- /dev/null +++ b/src/shared/components/multi-select/multi-select.module.css @@ -0,0 +1,59 @@ +.root { + & [data-disabled='true'] { + opacity: 0.6; + } +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} + +.dropdown { + padding: var(--theme-spacing-xs); + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.option { + position: relative; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + + &[data-checked='true'] { + &::before { + position: absolute; + top: 50%; + left: 2px; + width: 4px; + height: 50%; + content: ''; + background-color: var(--theme-colors-primary-filled); + border-radius: var(--theme-border-radius-xl); + transform: translateY(-50%); + } + } +} + +.option:hover { + background: lighten(var(--theme-colors-surface), 5%); +} diff --git a/src/shared/components/multi-select/multi-select.tsx b/src/shared/components/multi-select/multi-select.tsx new file mode 100644 index 00000000..3a9984df --- /dev/null +++ b/src/shared/components/multi-select/multi-select.tsx @@ -0,0 +1,36 @@ +import { + MultiSelect as MantineMultiSelect, + MultiSelectProps as MantineMultiSelectProps, +} from '@mantine/core'; +import { CSSProperties } from 'react'; + +import styles from './multi-select.module.css'; + +export interface MultiSelectProps extends MantineMultiSelectProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const MultiSelect = ({ + classNames, + maxWidth, + variant = 'default', + width, + ...props +}: MultiSelectProps) => { + return ( + <MantineMultiSelect + classNames={{ + dropdown: styles.dropdown, + input: styles.input, + option: styles.option, + root: styles.root, + ...classNames, + }} + style={{ maxWidth, width }} + variant={variant} + withCheckIcon={false} + {...props} + /> + ); +}; diff --git a/src/shared/components/number-input/number-input.module.css b/src/shared/components/number-input/number-input.module.css new file mode 100644 index 00000000..26002f2d --- /dev/null +++ b/src/shared/components/number-input/number-input.module.css @@ -0,0 +1,46 @@ +.root { + transition: width 0.3s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.control { + svg { + color: var(--theme-btn-default-fg); + fill: var(--theme-btn-default-fg); + } +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} diff --git a/src/shared/components/number-input/number-input.tsx b/src/shared/components/number-input/number-input.tsx new file mode 100644 index 00000000..0f45b994 --- /dev/null +++ b/src/shared/components/number-input/number-input.tsx @@ -0,0 +1,51 @@ +import { + NumberInput as MantineNumberInput, + NumberInputProps as MantineNumberInputProps, +} from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './number-input.module.css'; + +export interface NumberInputProps extends MantineNumberInputProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>( + ( + { + children, + classNames, + maxWidth, + size = 'sm', + style, + variant = 'default', + width, + ...props + }: NumberInputProps, + ref, + ) => { + return ( + <MantineNumberInput + classNames={{ + control: styles.control, + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + wrapper: styles.wrapper, + ...classNames, + }} + hideControls + ref={ref} + size={size} + style={{ maxWidth, width, ...style }} + variant={variant} + {...props} + > + {children} + </MantineNumberInput> + ); + }, +); diff --git a/src/shared/components/option/option.module.css b/src/shared/components/option/option.module.css new file mode 100644 index 00000000..905a72a8 --- /dev/null +++ b/src/shared/components/option/option.module.css @@ -0,0 +1,3 @@ +.root { + padding: var(--theme-spacing-sm); +} diff --git a/src/shared/components/option/option.tsx b/src/shared/components/option/option.tsx new file mode 100644 index 00000000..bbb364a8 --- /dev/null +++ b/src/shared/components/option/option.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; + +import styles from './option.module.css'; + +import { Flex } from '/@/shared/components/flex/flex'; +import { Group, GroupProps } from '/@/shared/components/group/group'; +import { Text } from '/@/shared/components/text/text'; + +interface OptionProps extends GroupProps { + children: ReactNode; +} + +export const Option = ({ children, ...props }: OptionProps) => { + return ( + <Group + classNames={{ root: styles.root }} + grow + {...props} + > + {children} + </Group> + ); +}; + +interface LabelProps { + children: ReactNode; +} + +const Label = ({ children }: LabelProps) => { + return <Text>{children}</Text>; +}; + +interface ControlProps { + children: ReactNode; +} + +const Control = ({ children }: ControlProps) => { + return <Flex justify="flex-end">{children}</Flex>; +}; + +Option.Label = Label; +Option.Control = Control; diff --git a/src/shared/components/pagination/pagination.module.css b/src/shared/components/pagination/pagination.module.css new file mode 100644 index 00000000..d46acc9f --- /dev/null +++ b/src/shared/components/pagination/pagination.module.css @@ -0,0 +1,31 @@ +.control { + color: var(--theme-btn-default-fg); + background-color: var(--theme-btn-default-bg); + border: none; + transition: + background 0.2s ease-in-out, + color 0.2s ease-in-out; + + &[data-active] { + color: var(--theme-btn-primary-fg); + background-color: var(--theme-btn-primary-bg); + } + + &[data-dots] { + background-color: transparent; + } + + &:hover { + color: var(--theme-btn-default-fg-hover); + background-color: var(--theme-btn-default-bg-hover); + + &[data-active] { + color: var(--theme-btn-primary-fg-hover); + background-color: var(--theme-btn-primary-bg-hover); + } + + &[data-dots] { + background-color: transparent; + } + } +} diff --git a/src/shared/components/pagination/pagination.tsx b/src/shared/components/pagination/pagination.tsx new file mode 100644 index 00000000..00abcf25 --- /dev/null +++ b/src/shared/components/pagination/pagination.tsx @@ -0,0 +1,24 @@ +import { + Pagination as MantinePagination, + PaginationProps as MantinePaginationProps, +} from '@mantine/core'; + +import styles from './pagination.module.css'; + +interface PaginationProps extends MantinePaginationProps {} + +export const Pagination = ({ classNames, style, ...props }: PaginationProps) => { + return ( + <MantinePagination + classNames={{ + control: styles.control, + ...classNames, + }} + radius="xl" + style={{ + ...style, + }} + {...props} + /> + ); +}; diff --git a/src/shared/components/paper/paper.module.css b/src/shared/components/paper/paper.module.css new file mode 100644 index 00000000..4f6c72cd --- /dev/null +++ b/src/shared/components/paper/paper.module.css @@ -0,0 +1,4 @@ +.root { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); +} diff --git a/src/shared/components/paper/paper.tsx b/src/shared/components/paper/paper.tsx new file mode 100644 index 00000000..f5a6b4e7 --- /dev/null +++ b/src/shared/components/paper/paper.tsx @@ -0,0 +1,27 @@ +import type { PaperProps as MantinePaperProps } from '@mantine/core'; + +import { Paper as MantinePaper } from '@mantine/core'; +import { ReactNode } from 'react'; + +import styles from './paper.module.css'; + +export interface PaperProps extends MantinePaperProps { + children?: ReactNode; +} + +export const Paper = ({ children, classNames, style, ...props }: PaperProps) => { + return ( + <MantinePaper + classNames={{ + root: styles.root, + ...classNames, + }} + style={{ + ...style, + }} + {...props} + > + {children} + </MantinePaper> + ); +}; diff --git a/src/shared/components/password-input/password-input.module.css b/src/shared/components/password-input/password-input.module.css new file mode 100644 index 00000000..91d45cc3 --- /dev/null +++ b/src/shared/components/password-input/password-input.module.css @@ -0,0 +1,39 @@ +.root { + &[data-disabled='true'] { + opacity: 0.6; + } + + transition: width 0.3s ease-in-out; +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} diff --git a/src/shared/components/password-input/password-input.tsx b/src/shared/components/password-input/password-input.tsx new file mode 100644 index 00000000..dfa839fa --- /dev/null +++ b/src/shared/components/password-input/password-input.tsx @@ -0,0 +1,35 @@ +import { + PasswordInput as MantinePasswordInput, + PasswordInputProps as MantinePasswordInputProps, +} from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './password-input.module.css'; + +export interface PasswordInputProps extends MantinePasswordInputProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>( + ({ children, classNames, maxWidth, style, variant = 'default', width, ...props }, ref) => { + return ( + <MantinePasswordInput + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + ...classNames, + }} + ref={ref} + style={{ maxWidth, width, ...style }} + variant={variant} + {...props} + > + {children} + </MantinePasswordInput> + ); + }, +); diff --git a/src/shared/components/popover/popover.module.css b/src/shared/components/popover/popover.module.css new file mode 100644 index 00000000..70a86817 --- /dev/null +++ b/src/shared/components/popover/popover.module.css @@ -0,0 +1,7 @@ +.dropdown { + padding: var(--theme-spacing-xs); + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + filter: drop-shadow(0 0 5px rgb(0 0 0 / 50%)); +} diff --git a/src/shared/components/popover/popover.tsx b/src/shared/components/popover/popover.tsx new file mode 100644 index 00000000..fd552c33 --- /dev/null +++ b/src/shared/components/popover/popover.tsx @@ -0,0 +1,29 @@ +import type { + PopoverDropdownProps as MantinePopoverDropdownProps, + PopoverProps as MantinePopoverProps, +} from '@mantine/core'; + +import { Popover as MantinePopover } from '@mantine/core'; + +import styles from './popover.module.css'; + +export interface PopoverDropdownProps extends MantinePopoverDropdownProps {} +export interface PopoverProps extends MantinePopoverProps {} + +export const Popover = ({ children, ...props }: PopoverProps) => { + return ( + <MantinePopover + classNames={{ + dropdown: styles.dropdown, + }} + transitionProps={{ transition: 'fade' }} + withinPortal + {...props} + > + {children} + </MantinePopover> + ); +}; + +Popover.Target = MantinePopover.Target; +Popover.Dropdown = MantinePopover.Dropdown; diff --git a/src/shared/components/portal/portal.tsx b/src/shared/components/portal/portal.tsx new file mode 100644 index 00000000..a1bf7995 --- /dev/null +++ b/src/shared/components/portal/portal.tsx @@ -0,0 +1,7 @@ +import { Portal as MantinePortal, PortalProps as MantinePortalProps } from '@mantine/core'; + +export interface PortalProps extends MantinePortalProps {} + +export const Portal = ({ children, ...props }: PortalProps) => { + return <MantinePortal {...props}>{children}</MantinePortal>; +}; diff --git a/src/shared/components/rating/rating.module.css b/src/shared/components/rating/rating.module.css new file mode 100644 index 00000000..8cd55d5f --- /dev/null +++ b/src/shared/components/rating/rating.module.css @@ -0,0 +1,5 @@ +.symbol-body { + svg { + stroke: var(--theme-colors-foreground-muted); + } +} diff --git a/src/renderer/components/rating/index.tsx b/src/shared/components/rating/rating.tsx similarity index 60% rename from src/renderer/components/rating/index.tsx rename to src/shared/components/rating/rating.tsx index 4523f59e..5427330b 100644 --- a/src/renderer/components/rating/index.tsx +++ b/src/shared/components/rating/rating.tsx @@ -1,17 +1,12 @@ -import { Rating as MantineRating, RatingProps } from '@mantine/core'; +import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useCallback } from 'react'; -import styled from 'styled-components'; -const StyledRating = styled(MantineRating)` - & .mantine-Rating-symbolBody { - svg { - stroke: var(--main-fg-secondary); - } - } -`; +import styles from './rating.module.css'; -export const Rating = ({ onChange, ...props }: RatingProps) => { +interface RatingProps extends MantineRatingProps {} + +export const Rating = ({ classNames, onChange, style, ...props }: RatingProps) => { const valueChange = useCallback( (rating: number) => { if (onChange) { @@ -28,7 +23,14 @@ export const Rating = ({ onChange, ...props }: RatingProps) => { const debouncedOnChange = debounce(valueChange, 100); return ( - <StyledRating + <MantineRating + classNames={{ + symbolBody: styles.symbolBody, + ...classNames, + }} + style={{ + ...style, + }} {...props} onChange={(e) => { debouncedOnChange(e); diff --git a/src/shared/components/scroll-area/scroll-area.css b/src/shared/components/scroll-area/scroll-area.css new file mode 100644 index 00000000..77a4a6e5 --- /dev/null +++ b/src/shared/components/scroll-area/scroll-area.css @@ -0,0 +1,11 @@ +.feishin-os-scrollbar { + --os-size: var(--theme-scrollbar-size); + --os-track-bg: var(--theme-scrollbar-track-background); + --os-track-bg-hover: var(--theme-scrollbar-track-hover-background); + --os-track-border-radius: var(--theme-scrollbar-track-border-radius); + --os-handle-bg: var(--theme-scrollbar-handle-background); + --os-handle-bg-hover: var(--theme-scrollbar-handle-hover-background); + --os-handle-bg-active: var(--theme-scrollbar-handle-active-background); + --os-handle-border-radius: var(--theme-scrollbar-handle-border-radius); + --os-handle-max-size: 200px; +} diff --git a/src/shared/components/scroll-area/scroll-area.module.css b/src/shared/components/scroll-area/scroll-area.module.css new file mode 100644 index 00000000..262b115e --- /dev/null +++ b/src/shared/components/scroll-area/scroll-area.module.css @@ -0,0 +1,35 @@ +/* .thumb { + background: var(--theme-scrollbar-handle-background); + border-radius: var(--theme-scrollbar-handle-border-radius); + + &:hover { + background: var(--theme-scrollbar-handle-hover-background); + } + + &[data-state='visible'] { + animation: fade-in 0.3s forwards; + } + + &[data-state='hidden'] { + animation: fade-out 0.2s forwards; + } +} + +.scrollbar { + padding: 0; + background: var(--theme-scrollbar-track-background); + border-radius: var(--theme-scrollbar-track-border-radius); + + &:hover { + background: var(--theme-scrollbar-track-hover-background); + } +} + +.viewport > div { + display: block !important; +} */ + +.scroll-area { + width: 100%; + height: 100%; +} diff --git a/src/shared/components/scroll-area/scroll-area.tsx b/src/shared/components/scroll-area/scroll-area.tsx new file mode 100644 index 00000000..7c6bc690 --- /dev/null +++ b/src/shared/components/scroll-area/scroll-area.tsx @@ -0,0 +1,78 @@ +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { useMergedRef } from '@mantine/hooks'; +import clsx from 'clsx'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { forwardRef, Ref, useEffect, useRef, useState } from 'react'; + +import styles from './scroll-area.module.css'; +import './scroll-area.css'; + +import { DragData, DragTarget } from '/@/shared/types/drag-and-drop'; + +interface ScrollAreaProps extends React.ComponentPropsWithoutRef<'div'> { + allowDragScroll?: boolean; + debugScrollPosition?: boolean; + scrollHideDelay?: number; +} + +export const ScrollArea = forwardRef((props: ScrollAreaProps, ref: Ref<HTMLDivElement>) => { + const { allowDragScroll, children, className, scrollHideDelay, ...htmlProps } = props; + + const containerRef = useRef(null); + const [scroller, setScroller] = useState<HTMLElement | null | Window>(null); + + const [initialize, osInstance] = useOverlayScrollbars({ + defer: false, + options: { + overflow: { x: 'hidden', y: 'scroll' }, + scrollbars: { + autoHide: 'leave', + autoHideDelay: scrollHideDelay || 500, + pointers: ['mouse', 'pen', 'touch'], + theme: 'feishin-os-scrollbar', + visibility: 'visible', + }, + }, + }); + + useEffect(() => { + const { current: root } = containerRef; + + if (scroller && root) { + initialize({ + elements: { viewport: scroller as HTMLElement }, + target: root, + }); + + if (allowDragScroll) { + autoScrollForElements({ + canScroll: (args) => { + const data = args.source.data as unknown as DragData<unknown>; + if (data.type === DragTarget.TABLE_COLUMN) return false; + return true; + }, + element: scroller as HTMLElement, + getAllowedAxis: () => 'vertical', + getConfiguration: () => ({ maxScrollSpeed: 'standard' }), + }); + } + } + + return () => osInstance()?.destroy(); + }, [allowDragScroll, initialize, osInstance, scroller]); + + const mergedRef = useMergedRef(ref, containerRef); + + return ( + <div + className={clsx(styles.scrollArea, className)} + ref={(el) => { + setScroller(el); + mergedRef(el); + }} + {...htmlProps} + > + {children} + </div> + ); +}); diff --git a/src/shared/components/segmented-control/segmented-control.module.css b/src/shared/components/segmented-control/segmented-control.module.css new file mode 100644 index 00000000..75c188ea --- /dev/null +++ b/src/shared/components/segmented-control/segmented-control.module.css @@ -0,0 +1,3 @@ +.root { + background: var(--theme-colors-surface); +} diff --git a/src/shared/components/segmented-control/segmented-control.tsx b/src/shared/components/segmented-control/segmented-control.tsx new file mode 100644 index 00000000..b784a995 --- /dev/null +++ b/src/shared/components/segmented-control/segmented-control.tsx @@ -0,0 +1,29 @@ +import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core'; + +import { SegmentedControl as MantineSegmentedControl } from '@mantine/core'; +import { forwardRef } from 'react'; + +import styles from './segmented-control.module.css'; + +type SegmentedControlProps = MantineSegmentedControlProps; + +export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>( + ({ classNames, size = 'sm', ...props }: SegmentedControlProps, ref) => { + return ( + <MantineSegmentedControl + classNames={{ + control: styles.control, + indicator: styles.indicator, + label: styles.label, + root: styles.root, + ...classNames, + }} + ref={ref} + size={size} + transitionDuration={250} + transitionTimingFunction="linear" + {...props} + /> + ); + }, +); diff --git a/src/shared/components/select/select.module.css b/src/shared/components/select/select.module.css new file mode 100644 index 00000000..96f48d88 --- /dev/null +++ b/src/shared/components/select/select.module.css @@ -0,0 +1,59 @@ +.root { + & [data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} + +.dropdown { + padding: var(--theme-spacing-xs); + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); +} + +.option { + position: relative; + padding: var(--theme-spacing-sm) var(--theme-spacing-lg); + + &[data-checked='true'] { + &::before { + position: absolute; + top: 50%; + left: 2px; + width: 4px; + height: 50%; + content: ''; + background-color: var(--theme-colors-primary-filled); + border-radius: var(--theme-border-radius-xl); + transform: translateY(-50%); + } + } +} + +.option:hover { + background: lighten(var(--theme-colors-surface), 5%); +} diff --git a/src/shared/components/select/select.tsx b/src/shared/components/select/select.tsx new file mode 100644 index 00000000..dd72f74a --- /dev/null +++ b/src/shared/components/select/select.tsx @@ -0,0 +1,36 @@ +import type { SelectProps as MantineSelectProps } from '@mantine/core'; + +import { Select as MantineSelect } from '@mantine/core'; +import { CSSProperties } from 'react'; + +import styles from './select.module.css'; + +export interface SelectProps extends MantineSelectProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const Select = ({ + classNames, + maxWidth, + variant = 'default', + width, + ...props +}: SelectProps) => { + return ( + <MantineSelect + classNames={{ + dropdown: styles.dropdown, + input: styles.input, + label: styles.label, + option: styles.option, + root: styles.root, + ...classNames, + }} + style={{ maxWidth, width }} + variant={variant} + withCheckIcon={false} + {...props} + /> + ); +}; diff --git a/src/renderer/styles/fonts.scss b/src/shared/components/separator/separator.module.css similarity index 100% rename from src/renderer/styles/fonts.scss rename to src/shared/components/separator/separator.module.css diff --git a/src/shared/components/separator/separator.tsx b/src/shared/components/separator/separator.tsx new file mode 100644 index 00000000..ef31fa8d --- /dev/null +++ b/src/shared/components/separator/separator.tsx @@ -0,0 +1,5 @@ +import { SEPARATOR_STRING } from '/@/shared/api/utils'; + +export const Separator = () => { + return <>{SEPARATOR_STRING}</>; +}; diff --git a/src/shared/components/skeleton/skeleton.module.css b/src/shared/components/skeleton/skeleton.module.css new file mode 100644 index 00000000..2122dfbe --- /dev/null +++ b/src/shared/components/skeleton/skeleton.module.css @@ -0,0 +1,27 @@ +.skeleton { + @mixin dark { + --base-color: lighten(var(--theme-colors-surface), 10%) !important; + --highlight-color: lighten(var(--theme-colors-surface), 15%) !important; + } + + @mixin light { + --base-color: var(--theme-colors-foreground-muted) !important; + --highlight-color: darken(var(--theme-colors-foreground-muted), 40%) !important; + } + + --animation-duration: 1.5s !important; + + width: 100%; + height: 100%; +} + +.skeleton-container { + display: flex; + align-items: center; + width: 100%; + height: 100%; +} + +.centered { + justify-content: center; +} diff --git a/src/shared/components/skeleton/skeleton.tsx b/src/shared/components/skeleton/skeleton.tsx new file mode 100644 index 00000000..3208cb31 --- /dev/null +++ b/src/shared/components/skeleton/skeleton.tsx @@ -0,0 +1,56 @@ +import type { CSSProperties } from 'react'; + +import clsx from 'clsx'; +import RSkeleton from 'react-loading-skeleton'; + +import styles from './skeleton.module.css'; + +import 'react-loading-skeleton/dist/skeleton.css'; + +interface SkeletonProps { + baseColor?: string; + borderRadius?: string; + className?: string; + containerClassName?: string; + count?: number; + direction?: 'ltr' | 'rtl'; + enableAnimation?: boolean; + height?: number | string; + inline?: boolean; + isCentered?: boolean; + style?: CSSProperties; + width?: number | string; +} + +export function Skeleton({ + baseColor, + borderRadius, + className, + containerClassName, + count, + direction, + enableAnimation = true, + height, + inline, + isCentered, + style, + width, +}: SkeletonProps) { + return ( + <RSkeleton + baseColor={baseColor} + borderRadius={borderRadius} + className={clsx(styles.skeleton, className)} + containerClassName={clsx(styles.skeletonContainer, containerClassName, { + [styles.centered]: isCentered, + })} + count={count} + direction={direction} + enableAnimation={enableAnimation} + height={height} + inline={inline} + style={style} + width={width} + /> + ); +} diff --git a/src/shared/components/slider/slider.module.css b/src/shared/components/slider/slider.module.css new file mode 100644 index 00000000..6eac3ef3 --- /dev/null +++ b/src/shared/components/slider/slider.module.css @@ -0,0 +1,17 @@ +.track { + height: 0.5rem; +} + +.thumb { + background: var(--theme-colors-foreground); +} + +.label { + max-width: 200px; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + font-size: var(--theme-font-size-md); + font-weight: 550; + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%); +} diff --git a/src/shared/components/slider/slider.tsx b/src/shared/components/slider/slider.tsx new file mode 100644 index 00000000..4a92265a --- /dev/null +++ b/src/shared/components/slider/slider.tsx @@ -0,0 +1,25 @@ +import type { SliderProps as MantineSliderProps } from '@mantine/core'; + +import { Slider as MantineSlider } from '@mantine/core'; + +import styles from './slider.module.css'; + +export interface SliderProps extends MantineSliderProps {} + +export const Slider = ({ classNames, style, ...props }: SliderProps) => { + return ( + <MantineSlider + classNames={{ + bar: styles.bar, + label: styles.label, + thumb: styles.thumb, + track: styles.track, + ...classNames, + }} + style={{ + ...style, + }} + {...props} + /> + ); +}; diff --git a/src/shared/components/spinner/spinner.module.css b/src/shared/components/spinner/spinner.module.css new file mode 100644 index 00000000..dd714eb5 --- /dev/null +++ b/src/shared/components/spinner/spinner.module.css @@ -0,0 +1,18 @@ +.container { + width: 100%; + height: 100%; +} + +.icon { + animation: rotating 1s ease-in-out infinite; +} + +@keyframes rotating { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/renderer/components/spinner/index.tsx b/src/shared/components/spinner/spinner.tsx similarity index 61% rename from src/renderer/components/spinner/index.tsx rename to src/shared/components/spinner/spinner.tsx index 8b212892..3bb36e5f 100644 --- a/src/renderer/components/spinner/index.tsx +++ b/src/shared/components/spinner/spinner.tsx @@ -1,9 +1,8 @@ import { Center } from '@mantine/core'; import { IconBaseProps } from 'react-icons'; import { RiLoader5Fill } from 'react-icons/ri'; -import styled from 'styled-components'; -import { rotating } from '/@/renderer/styles'; +import styles from './spinner.module.css'; interface SpinnerProps extends IconBaseProps { color?: string; @@ -11,19 +10,14 @@ interface SpinnerProps extends IconBaseProps { size?: number; } -export const SpinnerIcon = styled(RiLoader5Fill)` - ${rotating} - animation: rotating 1s ease-in-out infinite; -`; +export const SpinnerIcon = RiLoader5Fill; export const Spinner = ({ ...props }: SpinnerProps) => { if (props.container) { return ( - <Center - h="100%" - w="100%" - > + <Center className={styles.container}> <SpinnerIcon + className={styles.icon} color={props.color} size={props.size} /> @@ -31,5 +25,11 @@ export const Spinner = ({ ...props }: SpinnerProps) => { ); } - return <SpinnerIcon {...props} />; + return ( + <SpinnerIcon + className={styles.icon} + color={props.color} + size={props.size} + /> + ); }; diff --git a/src/renderer/components/spoiler/spoiler.module.scss b/src/shared/components/spoiler/spoiler.module.css similarity index 68% rename from src/renderer/components/spoiler/spoiler.module.scss rename to src/shared/components/spoiler/spoiler.module.css index 36bd1796..522b88c7 100644 --- a/src/renderer/components/spoiler/spoiler.module.scss +++ b/src/shared/components/spoiler/spoiler.module.css @@ -1,25 +1,25 @@ .control:hover { - color: var(--btn-subtle-fg-hover); + color: var(--theme-btn-subtle-fg-hover); text-decoration: none; } .spoiler { position: relative; - text-align: justify; width: 100%; height: 100%; overflow: hidden; + text-align: justify; } -.spoiler:not(.is-expanded).can-expand:after { +.spoiler:not(.is-expanded).can-expand::after { position: absolute; - left: 0; bottom: 0; + left: 0; width: 100%; height: 100%; - content: ''; - background: linear-gradient(to top, var(--main-bg) 10%, transparent 60%); pointer-events: none; + content: ''; + background: linear-gradient(to top, var(--theme-colors-background) 10%, transparent 60%); } .spoiler.can-expand { diff --git a/src/renderer/components/spoiler/index.tsx b/src/shared/components/spoiler/spoiler.tsx similarity index 84% rename from src/renderer/components/spoiler/index.tsx rename to src/shared/components/spoiler/spoiler.tsx index c7bc3548..38691b32 100644 --- a/src/renderer/components/spoiler/index.tsx +++ b/src/shared/components/spoiler/spoiler.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx'; import { HTMLAttributes, ReactNode, useRef, useState } from 'react'; -import styles from './spoiler.module.scss'; +import styles from './spoiler.module.css'; -import { useIsOverflow } from '/@/renderer/hooks'; +import { Text } from '/@/shared/components/text/text'; +import { useIsOverflow } from '/@/shared/hooks/use-is-overflow'; interface SpoilerProps extends HTMLAttributes<HTMLDivElement> { children?: ReactNode; @@ -26,7 +27,7 @@ export const Spoiler = ({ children, defaultOpened, maxHeight, ...props }: Spoile }; return ( - <div + <Text className={spoilerClassNames} onClick={handleToggleExpand} ref={ref} @@ -36,6 +37,6 @@ export const Spoiler = ({ children, defaultOpened, maxHeight, ...props }: Spoile {...props} > {children} - </div> + </Text> ); }; diff --git a/src/shared/components/stack/stack.tsx b/src/shared/components/stack/stack.tsx new file mode 100644 index 00000000..7ed3ea5b --- /dev/null +++ b/src/shared/components/stack/stack.tsx @@ -0,0 +1,17 @@ +import { Stack as MantineStack, StackProps as MantineStackProps } from '@mantine/core'; +import { forwardRef } from 'react'; + +export interface StackProps extends MantineStackProps {} + +export const Stack = forwardRef<HTMLDivElement, StackProps>(({ children, ...props }, ref) => { + return ( + <MantineStack + classNames={{ ...props.classNames }} + ref={ref} + style={{ ...props.style }} + {...props} + > + {children} + </MantineStack> + ); +}); diff --git a/src/shared/components/switch/switch.module.css b/src/shared/components/switch/switch.module.css new file mode 100644 index 00000000..7d9a1b70 --- /dev/null +++ b/src/shared/components/switch/switch.module.css @@ -0,0 +1,16 @@ +.thumb { + background: var(--theme-colors-foreground); +} + +.track { + background-color: var(--theme-colors-surface); + border: 1px solid var(--theme-colors-border); + + input:checked + & { + background-color: var(--theme-colors-primary-filled); + + & > .thumb { + background: var(--theme-colors-primary-contrast); + } + } +} diff --git a/src/shared/components/switch/switch.tsx b/src/shared/components/switch/switch.tsx new file mode 100644 index 00000000..e186f929 --- /dev/null +++ b/src/shared/components/switch/switch.tsx @@ -0,0 +1,23 @@ +import type { SwitchProps as MantineSwitchProps } from '@mantine/core'; + +import { Switch as MantineSwitch } from '@mantine/core'; + +import styles from './switch.module.css'; + +type SwitchProps = MantineSwitchProps; + +export const Switch = ({ classNames, ...props }: SwitchProps) => { + return ( + <MantineSwitch + classNames={{ + input: styles.input, + root: styles.root, + thumb: styles.thumb, + track: styles.track, + ...classNames, + }} + withThumbIndicator={false} + {...props} + /> + ); +}; diff --git a/src/shared/components/table/table.module.css b/src/shared/components/table/table.module.css new file mode 100644 index 00000000..f511206e --- /dev/null +++ b/src/shared/components/table/table.module.css @@ -0,0 +1,7 @@ +.td { + padding: var(--theme-spacing-xs) var(--theme-spacing-sm); +} + +.th { + padding: var(--theme-spacing-xs) var(--theme-spacing-sm); +} diff --git a/src/shared/components/table/table.tsx b/src/shared/components/table/table.tsx new file mode 100644 index 00000000..6cab9508 --- /dev/null +++ b/src/shared/components/table/table.tsx @@ -0,0 +1,24 @@ +import { Table as MantineTable, TableProps as MantineTableProps } from '@mantine/core'; + +import styles from './table.module.css'; + +export interface TableProps extends MantineTableProps {} + +export const Table = ({ classNames, ...props }: TableProps) => { + return ( + <MantineTable + classNames={{ + td: styles.td, + th: styles.th, + ...classNames, + }} + {...props} + /> + ); +}; + +Table.Thead = MantineTable.Thead; +Table.Tr = MantineTable.Tr; +Table.Td = MantineTable.Td; +Table.Th = MantineTable.Th; +Table.Tbody = MantineTable.Tbody; diff --git a/src/shared/components/tabs/tabs.module.css b/src/shared/components/tabs/tabs.module.css new file mode 100644 index 00000000..04e47404 --- /dev/null +++ b/src/shared/components/tabs/tabs.module.css @@ -0,0 +1,37 @@ +.root { + height: 100%; +} + +.list { + padding-right: var(--theme-spacing-md); + + &::before { + border: 1px solid var(--theme-colors-border); + } +} + +.tab { + padding: var(--theme-spacing-md); + font-weight: 500; + color: var(--theme-btn-subtle-fg); + transition: color 0.2s ease-in-out; + + &:hover { + color: var(--theme-btn-subtle-fg-hover); + background: var(--theme-btn-subtle-bg-hover); + } +} + +.panel { + padding: var(--theme-spacing-lg) var(--theme-spacing-sm); +} + +.tab[data-active] { + color: var(--theme-btn-subtle-fg); + background: none; + border-color: var(--theme-colors-primary-filled); + + &:hover { + background: none; + } +} diff --git a/src/shared/components/tabs/tabs.tsx b/src/shared/components/tabs/tabs.tsx new file mode 100644 index 00000000..c530149c --- /dev/null +++ b/src/shared/components/tabs/tabs.tsx @@ -0,0 +1,34 @@ +import { Tabs as MantineTabs, TabsProps as MantineTabsProps, TabsPanelProps } from '@mantine/core'; +import { Suspense } from 'react'; + +import styles from './tabs.module.css'; + +type TabsProps = MantineTabsProps; + +export const Tabs = ({ children, ...props }: TabsProps) => { + return ( + <MantineTabs + classNames={{ + list: styles.list, + panel: styles.panel, + root: styles.root, + tab: styles.tab, + }} + {...props} + > + {children} + </MantineTabs> + ); +}; + +const Panel = ({ children, ...props }: TabsPanelProps) => { + return ( + <MantineTabs.Panel {...props}> + <Suspense fallback={<></>}>{children}</Suspense> + </MantineTabs.Panel> + ); +}; + +Tabs.List = MantineTabs.List; +Tabs.Panel = Panel; +Tabs.Tab = MantineTabs.Tab; diff --git a/src/shared/components/text-input/text-input.module.css b/src/shared/components/text-input/text-input.module.css new file mode 100644 index 00000000..2f5cbfbb --- /dev/null +++ b/src/shared/components/text-input/text-input.module.css @@ -0,0 +1,39 @@ +.root { + transition: width 0.3s ease-in-out; +} + +.input { + width: 100%; + border: 1px solid transparent; + + &[data-variant='default'] { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + } + + &[data-variant='filled'] { + color: var(--theme-colors-foreground); + background: var(--theme-colors-background); + } +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} + +.section { + color: var(--theme-colors-foreground-muted); +} + +.required { + color: var(--theme-colors-state-error); +} + +.disabled { + opacity: 0.6; +} diff --git a/src/shared/components/text-input/text-input.tsx b/src/shared/components/text-input/text-input.tsx new file mode 100644 index 00000000..60103018 --- /dev/null +++ b/src/shared/components/text-input/text-input.tsx @@ -0,0 +1,50 @@ +import { + TextInput as MantineTextInput, + TextInputProps as MantineTextInputProps, +} from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './text-input.module.css'; + +export interface TextInputProps extends MantineTextInputProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const TextInput = forwardRef<HTMLInputElement, TextInputProps>( + ( + { + children, + classNames, + maxWidth, + size = 'sm', + style, + variant = 'default', + width, + ...props + }: TextInputProps, + ref, + ) => { + return ( + <MantineTextInput + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + section: styles.section, + wrapper: styles.wrapper, + ...classNames, + }} + ref={ref} + size={size} + spellCheck={false} + style={{ maxWidth, width, ...style }} + variant={variant} + {...props} + > + {children} + </MantineTextInput> + ); + }, +); diff --git a/src/shared/components/text-title/text-title.module.css b/src/shared/components/text-title/text-title.module.css new file mode 100644 index 00000000..7b738642 --- /dev/null +++ b/src/shared/components/text-title/text-title.module.css @@ -0,0 +1,27 @@ +.root { + color: var(--theme-colors-foreground); + transition: color 0.2s ease-in-out; +} + +.muted { + color: var(--theme-colors-foreground-muted); +} + +.link { + cursor: pointer; +} + +.link:hover { + color: var(--theme-colors-foreground); + text-decoration: underline; +} + +.no-select { + user-select: none; +} + +.overflow-hidden { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/shared/components/text-title/text-title.tsx b/src/shared/components/text-title/text-title.tsx new file mode 100644 index 00000000..0b200a25 --- /dev/null +++ b/src/shared/components/text-title/text-title.tsx @@ -0,0 +1,49 @@ +import type { TitleProps as MantineTitleProps } from '@mantine/core'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import { createPolymorphicComponent, Title as MantineHeader } from '@mantine/core'; +import clsx from 'clsx'; + +import styles from './text-title.module.css'; + +type MantineTextTitleDivProps = ComponentPropsWithoutRef<'div'> & MantineTitleProps; + +interface TextTitleProps extends MantineTextTitleDivProps { + children?: ReactNode; + isLink?: boolean; + isMuted?: boolean; + isNoSelect?: boolean; + overflow?: 'hidden' | 'visible'; + to?: string; + weight?: number; +} + +const _TextTitle = ({ + children, + className, + isLink, + isMuted, + isNoSelect, + overflow, + ...rest +}: TextTitleProps) => { + return ( + <MantineHeader + className={clsx( + styles.root, + { + [styles.link]: isLink, + [styles.muted]: isMuted, + [styles.noSelect]: isNoSelect, + [styles.overflowHidden]: overflow === 'hidden' && !rest.lineClamp, + }, + className, + )} + {...rest} + > + {children} + </MantineHeader> + ); +}; + +export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle); diff --git a/src/shared/components/text/text.module.css b/src/shared/components/text/text.module.css new file mode 100644 index 00000000..f90ef331 --- /dev/null +++ b/src/shared/components/text/text.module.css @@ -0,0 +1,28 @@ +.root { + font-family: var(--font-family); + color: var(--theme-colors-foreground); + user-select: auto; +} + +.root.muted { + color: var(--theme-colors-foreground-muted); +} + +.root.link { + cursor: pointer; +} + +.root.link:hover { + color: var(--theme-colors-foreground); + text-decoration: underline; +} + +.root.overflow-hidden { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root.no-select { + user-select: none; +} diff --git a/src/shared/components/text/text.tsx b/src/shared/components/text/text.tsx new file mode 100644 index 00000000..d6e5d715 --- /dev/null +++ b/src/shared/components/text/text.tsx @@ -0,0 +1,54 @@ +import { Text as MantineText, TextProps as MantineTextProps } from '@mantine/core'; +import clsx from 'clsx'; +import { ComponentPropsWithoutRef, ReactNode } from 'react'; + +import styles from './text.module.css'; + +import { createPolymorphicComponent } from '/@/shared/utils/create-polymorphic-component'; + +export interface TextProps extends MantineTextDivProps { + children?: ReactNode; + font?: Font; + isLink?: boolean; + isMuted?: boolean; + isNoSelect?: boolean; + overflow?: 'hidden' | 'visible'; + to?: string; + weight?: number; +} + +type Font = 'Epilogue' | 'Gotham' | 'Inter' | 'Poppins'; + +type MantineTextDivProps = ComponentPropsWithoutRef<'div'> & MantineTextProps; + +export const _Text = ({ + children, + font, + isLink, + isMuted, + isNoSelect, + overflow, + ...rest +}: TextProps) => { + return ( + <MantineText + className={clsx(styles.root, { + [styles.link]: isLink, + [styles.muted]: isMuted, + [styles.noSelect]: isNoSelect, + [styles.overflowHidden]: overflow === 'hidden', + })} + component="div" + style={ + { + '--font-family': font, + } as React.CSSProperties + } + {...rest} + > + {children} + </MantineText> + ); +}; + +export const Text = createPolymorphicComponent<'div', TextProps>(_Text); diff --git a/src/shared/components/textarea/textarea.module.css b/src/shared/components/textarea/textarea.module.css new file mode 100644 index 00000000..fe18b85d --- /dev/null +++ b/src/shared/components/textarea/textarea.module.css @@ -0,0 +1,22 @@ +.root { + transition: width 0.3s ease-in-out; + + &[data-disabled='true'] { + opacity: 0.6; + } +} + +.input { + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + border: 1px solid transparent; +} + +.input:focus, +.input:focus-visible { + border-color: lighten(var(--theme-colors-border), 10%); +} + +.label { + margin-bottom: var(--theme-spacing-sm); +} diff --git a/src/shared/components/textarea/textarea.tsx b/src/shared/components/textarea/textarea.tsx new file mode 100644 index 00000000..92d453a1 --- /dev/null +++ b/src/shared/components/textarea/textarea.tsx @@ -0,0 +1,31 @@ +import { Textarea as MantineTextarea, TextareaProps as MantineTextareaProps } from '@mantine/core'; +import { CSSProperties, forwardRef } from 'react'; + +import styles from './textarea.module.css'; + +export interface TextareaProps extends MantineTextareaProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => { + return ( + <MantineTextarea + classNames={{ + input: styles.input, + label: styles.label, + required: styles.required, + root: styles.root, + wrapper: styles.wrapper, + ...classNames, + }} + ref={ref} + style={{ maxWidth, width, ...style }} + {...props} + > + {children} + </MantineTextarea> + ); + }, +); diff --git a/src/shared/components/toast/toast.module.css b/src/shared/components/toast/toast.module.css new file mode 100644 index 00000000..8f14addf --- /dev/null +++ b/src/shared/components/toast/toast.module.css @@ -0,0 +1,40 @@ +.root { + bottom: 90px; + background-color: var(--theme-colors-surface); +} + +.root.error { + --notification-color: var(--theme-colors-state-error); +} + +.root.info { + --notification-color: var(--theme-colors-state-info); +} + +.root.success { + --notification-color: var(--theme-colors-state-success); +} + +.root.warning { + --notification-color: var(--theme-colors-state-warning); +} + +.title { + font-size: var(--theme-font-size-md); +} + +.body { + padding: var(--theme-spacing-md); +} + +.loader { + margin: var(--theme-spacing-md); +} + +.description { + font-size: var(--theme-font-size-md); +} + +.close-button { + background-color: var(--theme-colors-surface); +} diff --git a/src/shared/components/toast/toast.tsx b/src/shared/components/toast/toast.tsx new file mode 100644 index 00000000..57029582 --- /dev/null +++ b/src/shared/components/toast/toast.tsx @@ -0,0 +1,61 @@ +import type { NotificationsProps as MantineNotificationProps } from '@mantine/notifications'; + +import { + cleanNotifications, + cleanNotificationsQueue, + hideNotification, + notifications, + updateNotification, +} from '@mantine/notifications'; +import clsx from 'clsx'; + +import styles from './toast.module.css'; + +interface NotificationProps extends MantineNotificationProps { + message?: string; + onClose?: () => void; + type?: 'error' | 'info' | 'success' | 'warning'; +} + +const getTitle = (type: NotificationProps['type']) => { + if (type === 'success') return 'Success'; + if (type === 'warning') return 'Warning'; + if (type === 'error') return 'Error'; + return 'Info'; +}; + +const showToast = ({ message, onClose, type, ...props }: NotificationProps) => { + return notifications.show({ + autoClose: props.autoClose, + classNames: { + body: styles.body, + closeButton: styles.closeButton, + description: styles.description, + loader: styles.loader, + root: clsx(styles.root, { + [styles.error]: type === 'error', + [styles.info]: type === 'info', + [styles.success]: type === 'success', + [styles.warning]: type === 'warning', + }), + title: styles.title, + }, + message: message ?? '', + onClose, + title: getTitle(type), + withBorder: true, + withCloseButton: true, + }); +}; + +export const toast = { + clean: cleanNotifications, + cleanQueue: cleanNotificationsQueue, + error: (props: NotificationProps) => showToast({ type: 'error', ...props }), + hide: hideNotification, + info: (props: NotificationProps) => showToast({ type: 'info', ...props }), + show: showToast, + success: (props: NotificationProps) => showToast({ type: 'success', ...props }), + update: updateNotification, + warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }), +}; diff --git a/src/shared/components/tooltip/tooltip.module.css b/src/shared/components/tooltip/tooltip.module.css new file mode 100644 index 00000000..86b5177a --- /dev/null +++ b/src/shared/components/tooltip/tooltip.module.css @@ -0,0 +1,9 @@ +.tooltip { + max-width: 200px; + padding: var(--theme-spacing-sm) var(--theme-spacing-md); + font-size: var(--theme-font-size-md); + font-weight: 550; + color: var(--theme-colors-surface-foreground); + background: var(--theme-colors-surface); + box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%); +} diff --git a/src/shared/components/tooltip/tooltip.tsx b/src/shared/components/tooltip/tooltip.tsx new file mode 100644 index 00000000..e2245661 --- /dev/null +++ b/src/shared/components/tooltip/tooltip.tsx @@ -0,0 +1,31 @@ +import { Tooltip as MantineTooltip, TooltipProps as MantineTooltipProps } from '@mantine/core'; + +import styles from './tooltip.module.css'; + +export interface TooltipProps extends MantineTooltipProps {} + +export const Tooltip = ({ + children, + openDelay = 500, + transitionProps = { + duration: 250, + transition: 'fade', + }, + withinPortal = true, + ...props +}: TooltipProps) => { + return ( + <MantineTooltip + classNames={{ + tooltip: styles.tooltip, + }} + multiline + openDelay={openDelay} + transitionProps={transitionProps} + withinPortal={withinPortal} + {...props} + > + {children} + </MantineTooltip> + ); +}; diff --git a/src/renderer/hooks/use-is-overflow.ts b/src/shared/hooks/use-is-overflow.ts similarity index 100% rename from src/renderer/hooks/use-is-overflow.ts rename to src/shared/hooks/use-is-overflow.ts diff --git a/src/shared/themes/app-theme-types.ts b/src/shared/themes/app-theme-types.ts new file mode 100644 index 00000000..17ad83b7 --- /dev/null +++ b/src/shared/themes/app-theme-types.ts @@ -0,0 +1,45 @@ +import type { MantineThemeOverride } from '@mantine/core'; + +import { CSSProperties } from 'react'; + +export enum AppTheme { + DEFAULT_DARK = 'defaultDark', + DEFAULT_LIGHT = 'defaultLight', +} + +export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>; + +export interface BaseAppThemeConfiguration { + app: { + 'overlay-header'?: CSSProperties['background']; + 'overlay-subheader'?: CSSProperties['background']; + 'root-font-size'?: CSSProperties['fontSize']; + 'scrollbar-handle-active-background'?: CSSProperties['background']; + 'scrollbar-handle-background'?: CSSProperties['background']; + 'scrollbar-handle-border-radius'?: CSSProperties['borderRadius']; + 'scrollbar-handle-hover-background'?: CSSProperties['background']; + 'scrollbar-size'?: CSSProperties['width']; + 'scrollbar-track-active-background'?: CSSProperties['background']; + 'scrollbar-track-background'?: CSSProperties['background']; + 'scrollbar-track-border-radius'?: CSSProperties['borderRadius']; + 'scrollbar-track-hover-background'?: CSSProperties['background']; + }; + colors: { + background?: CSSProperties['background']; + 'background-alternate'?: CSSProperties['background']; + black?: CSSProperties['color']; + foreground?: CSSProperties['color']; + 'foreground-muted'?: CSSProperties['color']; + primary?: CSSProperties['color']; + 'state-error'?: CSSProperties['color']; + 'state-info'?: CSSProperties['color']; + 'state-success'?: CSSProperties['color']; + 'state-warning'?: CSSProperties['color']; + surface?: CSSProperties['background']; + 'surface-foreground'?: CSSProperties['color']; + white?: CSSProperties['color']; + }; + mantineOverride?: MantineThemeOverride; + mode: 'dark' | 'light'; + stylesheets?: string[]; +} diff --git a/src/shared/themes/app-theme.ts b/src/shared/themes/app-theme.ts new file mode 100644 index 00000000..206925b6 --- /dev/null +++ b/src/shared/themes/app-theme.ts @@ -0,0 +1,23 @@ +import merge from 'lodash/merge'; + +import { AppThemeConfiguration } from './app-theme-types'; +import { AppTheme } from './app-theme-types'; + +import { defaultTheme } from '/@/shared/themes/default'; +import { defaultDark } from '/@/shared/themes/default-dark/default-dark'; +import { defaultLight } from '/@/shared/themes/default-light/default-light'; + +export const appTheme: Record<AppTheme, AppThemeConfiguration> = { + [AppTheme.DEFAULT_DARK]: defaultDark, + [AppTheme.DEFAULT_LIGHT]: defaultLight, +}; + +export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => { + return { + app: merge({}, defaultTheme.app, appTheme[theme].app), + colors: merge({}, defaultTheme.colors, appTheme[theme].colors), + mantineOverride: merge({}, defaultTheme.mantineOverride, appTheme[theme].mantineOverride), + mode: appTheme[theme].mode, + stylesheets: appTheme[theme].stylesheets, + }; +}; diff --git a/src/shared/themes/default-dark/default-dark.ts b/src/shared/themes/default-dark/default-dark.ts new file mode 100644 index 00000000..e0c1d96e --- /dev/null +++ b/src/shared/themes/default-dark/default-dark.ts @@ -0,0 +1,6 @@ +import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types'; + +export const defaultDark: AppThemeConfiguration = { + app: {}, + mode: 'dark', +}; diff --git a/src/shared/themes/default-light/default-light.ts b/src/shared/themes/default-light/default-light.ts new file mode 100644 index 00000000..2dec6041 --- /dev/null +++ b/src/shared/themes/default-light/default-light.ts @@ -0,0 +1,33 @@ +import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types'; + +export const defaultLight: AppThemeConfiguration = { + app: { + 'overlay-header': + 'linear-gradient(rgb(255 255 255 / 50%) 0%, rgb(255 255 255 / 80%)), var(--theme-background-noise)', + 'overlay-subheader': + 'linear-gradient(180deg, rgba(255, 255, 255, 5%) 0%, var(--theme-colors-background)), var(--theme-background-noise)', + 'scrollbar-handle-background': 'rgba(140, 140, 140, 30%)', + 'scrollbar-handle-hover-background': 'rgba(140, 140, 140, 60%)', + 'scrollbar-track-background': 'transparent', + }, + colors: { + background: 'rgb(255, 255, 255)', + 'background-alternate': 'rgb(245, 245, 245)', + black: 'rgb(0, 0, 0)', + foreground: 'rgb(25, 25, 25)', + 'foreground-muted': 'rgb(80, 80, 80)', + 'state-error': 'rgb(255, 59, 48)', + 'state-info': 'rgb(0, 122, 255)', + 'state-success': 'rgb(48, 209, 88)', + 'state-warning': 'rgb(255, 214, 0)', + surface: 'rgb(245, 245, 245)', + 'surface-foreground': 'rgb(0, 0, 0)', + white: 'rgb(255, 255, 255)', + }, + mantineOverride: { + primaryShade: { + light: 4, + }, + }, + mode: 'light', +}; diff --git a/src/shared/themes/default.ts b/src/shared/themes/default.ts new file mode 100644 index 00000000..e58a8b2b --- /dev/null +++ b/src/shared/themes/default.ts @@ -0,0 +1,35 @@ +import { AppThemeConfiguration } from './app-theme-types'; + +export const defaultTheme: AppThemeConfiguration = { + app: { + 'overlay-header': + 'linear-gradient(transparent 0%, rgb(0 0 0 / 75%) 100%), var(--theme-background-noise)', + 'overlay-subheader': + 'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)', + 'root-font-size': '16px', + 'scrollbar-handle-active-background': 'rgba(160, 160, 160, 60%)', + 'scrollbar-handle-background': 'rgba(160, 160, 160, 30%)', + 'scrollbar-handle-border-radius': '0px', + 'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 60%)', + 'scrollbar-size': '12px', + 'scrollbar-track-active-background': 'transparent', + 'scrollbar-track-background': 'transparent', + 'scrollbar-track-border-radius': '0', + 'scrollbar-track-hover-background': 'transparent', + }, + colors: { + background: 'rgb(16, 16, 16)', + 'background-alternate': 'rgb(0, 0, 0)', + black: 'rgb(0, 0, 0)', + foreground: 'rgb(225, 225, 225)', + 'foreground-muted': 'rgb(150, 150, 150)', + 'state-error': 'rgb(204, 50, 50)', + 'state-info': 'rgb(53, 116, 252)', + 'state-success': 'rgb(50, 204, 50)', + 'state-warning': 'rgb(255, 120, 120)', + surface: 'rgb(24, 24, 24)', + 'surface-foreground': 'rgb(215, 215, 215)', + white: 'rgb(255, 255, 255)', + }, + mode: 'dark', +}; diff --git a/src/shared/types/css-modules.d.ts b/src/shared/types/css-modules.d.ts new file mode 100644 index 00000000..8b008475 --- /dev/null +++ b/src/shared/types/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index efeaa640..448413fa 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1434,7 +1434,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder case SongListSort.ID: if (order === 'desc') { - results = reverse(results); + results = reverse(results as any); } break; @@ -1505,7 +1505,3 @@ export const sortAlbumArtistList = ( return results; }; -export enum AppTheme { - DEFAULT_DARK = 'defaultDark', - DEFAULT_LIGHT = 'defaultLight', -} diff --git a/src/shared/types/drag-and-drop.ts b/src/shared/types/drag-and-drop.ts new file mode 100644 index 00000000..169855b1 --- /dev/null +++ b/src/shared/types/drag-and-drop.ts @@ -0,0 +1,109 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +import { LibraryItem } from '/@/shared/types/domain-types'; + +export enum DragTarget { + ALBUM = LibraryItem.ALBUM, + ALBUM_ARTIST = LibraryItem.ALBUM_ARTIST, + ARTIST = LibraryItem.ARTIST, + GENERIC = 'generic', + GENRE = LibraryItem.GENRE, + PLAYLIST = LibraryItem.PLAYLIST, + TABLE_COLUMN = 'tableColumn', + TRACK = LibraryItem.SONG, +} + +export const DragTargetMap = { + [LibraryItem.ALBUM]: DragTarget.ALBUM, + [LibraryItem.ALBUM_ARTIST]: DragTarget.ALBUM_ARTIST, + [LibraryItem.ARTIST]: DragTarget.ARTIST, + [LibraryItem.GENRE]: DragTarget.GENRE, + [LibraryItem.PLAYLIST]: DragTarget.PLAYLIST, + [LibraryItem.SONG]: DragTarget.TRACK, +}; + +export enum DragOperation { + ADD = 'add', + REORDER = 'reorder', +} + +export interface AlbumDragMetadata { + image: string; + title: string; +} + +export interface DragData< + TDataType = unknown, + T extends Record<string, unknown> = Record<string, unknown>, +> { + id: string[]; + item?: TDataType[]; + metadata?: T; + operation?: DragOperation[]; + type: DragTarget; +} + +export const dndUtils = { + dropType: (args: { data: DragData }) => { + const { data } = args; + return data.type; + }, + generateDragData: <TDataType, T extends Record<string, unknown> = Record<string, unknown>>( + args: { + id: string[]; + item?: TDataType[]; + operation?: DragOperation[]; + type: DragTarget; + }, + metadata?: T, + ) => { + return { + id: args.id, + item: args.item, + metadata, + operation: args.operation, + type: args.type, + }; + }, + isDropTarget: (target: DragTarget, types: DragTarget[]) => { + return types.includes(target); + }, + reorderById: (args: { edge: Edge | null; idFrom: string; idTo: string; list: string[] }) => { + const { edge, idFrom, idTo, list } = args; + const indexFrom = list.indexOf(idFrom); + const indexTo = list.indexOf(idTo); + + // If dragging to the same position, do nothing + if (indexFrom === indexTo) { + return list; + } + + // If dragging to the right, but is left edge, do nothing + if (edge === 'left' && indexTo > indexFrom) { + return list; + } + + // If dragging to the left, but is right edge, do nothing + if (edge === 'right' && indexTo < indexFrom) { + return list; + } + + // If dragging to the top, but is bottom edge, do nothing + if (edge === 'top' && indexTo > indexFrom) { + return list; + } + + // If dragging to the bottom, but is top edge, do nothing + if (edge === 'bottom' && indexTo < indexFrom) { + return list; + } + + return dndUtils.reorderByIndex({ index: indexFrom, list, newIndex: indexTo }); + }, + reorderByIndex: (args: { index: number; list: string[]; newIndex: number }) => { + const { index, list, newIndex } = args; + const newList = [...list]; + newList.splice(newIndex, 0, newList.splice(index, 1)[0]); + return newList; + }, +}; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 8515d9a4..5beb700a 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -14,7 +14,8 @@ import { ServerFeatures } from '/@/shared/types/features-types'; export enum ListDisplayType { CARD = 'card', - POSTER = 'poster', + GRID = 'poster', + LIST = 'list', TABLE = 'table', TABLE_PAGINATED = 'paginatedTable', } diff --git a/src/shared/utils/create-polymorphic-component.ts b/src/shared/utils/create-polymorphic-component.ts new file mode 100644 index 00000000..12359e34 --- /dev/null +++ b/src/shared/utils/create-polymorphic-component.ts @@ -0,0 +1,3 @@ +import { createPolymorphicComponent as mantineCreatePolymorphicComponent } from '@mantine/core'; + +export const createPolymorphicComponent = mantineCreatePolymorphicComponent; diff --git a/src/shared/utils/create-use-external-events.ts b/src/shared/utils/create-use-external-events.ts new file mode 100644 index 00000000..b5bdfbae --- /dev/null +++ b/src/shared/utils/create-use-external-events.ts @@ -0,0 +1,3 @@ +import { createUseExternalEvents as mantineCreateUseExternalEvents } from '@mantine/core'; + +export const createUseExternalEvents = mantineCreateUseExternalEvents; diff --git a/web.vite.config.ts b/web.vite.config.ts index 740ec60a..629d08f3 100644 --- a/web.vite.config.ts +++ b/web.vite.config.ts @@ -25,6 +25,15 @@ export default defineConfig({ localsConvention: 'camelCase', }, }, + optimizeDeps: { + exclude: [ + '@atlaskit/pragmatic-drag-and-drop', + '@atlaskit/pragmatic-drag-and-drop-auto-scroll', + '@atlaskit/pragmatic-drag-and-drop-hitbox', + '@tanstack_react-query-persist-client', + 'idb-keyval', + ], + }, plugins: [ react(), ViteEjsPlugin({