mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbf840b185 | |||
| e0f0524eb9 | |||
| 8598313d12 | |||
| c84dd648ea | |||
| 01885c1a9b | |||
| 5121f57171 | |||
| 3d7ee10328 | |||
| 4db47b4d37 | |||
| 786a693526 | |||
| 1faef6a1a7 | |||
| 1598642389 | |||
| 8c4a7f4f91 | |||
| 5878f89339 | |||
| 4acbb1820d | |||
| 01f5745629 | |||
| 73dd781a88 | |||
| d777be6251 | |||
| 6689e84f67 | |||
| ad533a1d9c | |||
| b691891e62 | |||
| 53fa265af9 | |||
| 55f6a382d4 | |||
| 9c30acdd56 | |||
| b29d3e7f78 | |||
| c1330d92b2 | |||
| bea55d48a8 | |||
| ae41fe99bb | |||
| e3751229b6 | |||
| 87c9963354 | |||
| b7fb7c7f94 | |||
| b8ceb174b3 | |||
| 48f085b0ac | |||
| dfc0639f95 | |||
| 80ffd1a925 | |||
| c87bb65023 | |||
| 5ae21bd224 | |||
| 12d293a74c | |||
| 62f4bb0d7b | |||
| 9f11061433 | |||
| aba64b10d0 | |||
| c20e30e387 | |||
| c4b4300845 | |||
| f1e5ed41bc | |||
| 9b79502022 | |||
| 636c227a83 | |||
| e8a94a0b1c | |||
| fa93dfd771 | |||
| 8629994eb6 | |||
| c54423a667 | |||
| d28fc9f630 | |||
| bd2b39fdfb |
@@ -3,6 +3,7 @@ name: Publish Docker to GHCR
|
||||
permissions: write-all
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
@@ -49,6 +50,6 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
|
||||
+9
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
-7
@@ -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": "<FTName | kebabcase>.module.scss"
|
||||
"fileName": "<FTName | kebabcase>.module.css"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"folderTemplates.fileTemplates": {
|
||||
"Functional Component with CSS Modules": [
|
||||
"import styles from './<FTName | kebabcase>.module.scss';",
|
||||
"import styles from './<FTName | kebabcase>.module.css';",
|
||||
"",
|
||||
"interface <FTName | pascalcase>Props {}",
|
||||
"",
|
||||
|
||||
+5
-3
@@ -1,9 +1,11 @@
|
||||
# --- Builder stage
|
||||
FROM node:18-alpine as builder
|
||||
FROM node:23-alpine as builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json first to cache node_modules
|
||||
COPY package.json package-lock.json .
|
||||
COPY package.json pnpm-lock.yaml .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
@@ -14,7 +16,7 @@ RUN pnpm run build:web
|
||||
# --- Production stage
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
|
||||
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
|
||||
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ First thing to do is check that your MPV binary path is correct. Navigate to the
|
||||
|
||||
### What music servers does Feishin support?
|
||||
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/), [Jellyfin](https://jellyfin.org/), or [OpenSubsonic compatible](https://opensubsonic.netlify.app/) API.
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- Subsonic-compatible servers
|
||||
- [OpenSubsonic](https://opensubsonic.netlify.app/) compatible servers, such as...
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Ampache](https://ampache.org)
|
||||
- [Astiga](https://asti.ga/)
|
||||
|
||||
@@ -13,6 +13,7 @@ const config: UserConfig = {
|
||||
rollupOptions: {
|
||||
external: ['source-map-support'],
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.IS_LINUX': JSON.stringify(currentOSEnv === 'linux'),
|
||||
@@ -46,7 +47,7 @@ const config: UserConfig = {
|
||||
renderer: {
|
||||
css: {
|
||||
modules: {
|
||||
generateScopedName: '[name]__[local]__[hash:base64:5]',
|
||||
generateScopedName: 'fs-[name]-[local]',
|
||||
localsConvention: 'camelCase',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 9180;
|
||||
sendfile on;
|
||||
default_type application/octet-stream;
|
||||
|
||||
gzip on;
|
||||
gzip_http_version 1.1;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_comp_level 9;
|
||||
|
||||
location ${PUBLIC_PATH} {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
|
||||
location ${PUBLIC_PATH}settings.js {
|
||||
alias /etc/nginx/conf.d/settings.js;
|
||||
}
|
||||
|
||||
location ${PUBLIC_PATH}/settings.js {
|
||||
alias /etc/nginx/conf.d/settings.js;
|
||||
}
|
||||
}
|
||||
+38
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
@@ -25,11 +25,16 @@
|
||||
"build:remote": "vite build --config remote.vite.config.ts",
|
||||
"build:web": "vite build --config web.vite.config.ts",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:remote": "vite dev --config remote.vite.config.ts",
|
||||
"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 +59,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 +91,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 +99,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 +154,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 +170,9 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"abstract-socket",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+716
-1293
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,13 @@ export default defineConfig({
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
css: {
|
||||
modules: {
|
||||
generateScopedName: 'fs-[name]-[local]',
|
||||
localsConvention: 'camelCase',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
@@ -259,7 +259,13 @@
|
||||
"lastfmApiKey": "klíč API {{lastfm}}",
|
||||
"lastfmApiKey_description": "klíč API pro {{lastfm}}. vyžadováno pro obaly alb",
|
||||
"discordServeImage": "načítat obrázky {{discord}} ze serveru",
|
||||
"discordServeImage_description": "sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro jellyfin a navidrome"
|
||||
"discordServeImage_description": "sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro jellyfin a navidrome",
|
||||
"lastfm": "zobrazit odkazy na last.fm",
|
||||
"lastfm_description": "na stránkách umělců a alb zobrazit odkazy na last.fm",
|
||||
"musicbrainz": "zobrazit odkazy na musicbrainz",
|
||||
"musicbrainz_description": "na stránkách umělců a alb, kde existuje mbid, zobrazit odkazy na musicbrainz",
|
||||
"neteaseTranslation": "Povolit překlady NetEase",
|
||||
"neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné."
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
|
||||
@@ -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)",
|
||||
@@ -587,6 +591,8 @@
|
||||
"imageAspectRatio_description": "if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty",
|
||||
"language": "language",
|
||||
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
||||
"lastfm": "show last.fm links",
|
||||
"lastfm_description": "show links to last.fm on artist/album pages",
|
||||
"lastfmApiKey": "{{lastfm}} API key",
|
||||
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
||||
"lyricFetch": "fetch lyrics from the internet",
|
||||
@@ -605,6 +611,10 @@
|
||||
"mpvExecutablePath_description": "sets the path to the mpv executable. if left empty, the default path will be used",
|
||||
"mpvExtraParameters": "mpv parameters",
|
||||
"mpvExtraParameters_help": "one per line",
|
||||
"musicbrainz": "show musicbrainz links",
|
||||
"musicbrainz_description": "show links to musicbrainz on artist/album pages, where mbid exists",
|
||||
"neteaseTranslation": "Enable NetEase translations",
|
||||
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available.",
|
||||
"passwordStore": "passwords/secret store",
|
||||
"passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords.",
|
||||
"playbackStyle": "playback style",
|
||||
@@ -768,6 +778,8 @@
|
||||
},
|
||||
"view": {
|
||||
"card": "card",
|
||||
"grid": "grid",
|
||||
"list": "list",
|
||||
"poster": "poster",
|
||||
"table": "table"
|
||||
}
|
||||
|
||||
@@ -259,7 +259,13 @@
|
||||
"lastfmApiKey_description": "la clave API para {{lastfm}}. Requerida para la portada",
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}",
|
||||
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome"
|
||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome",
|
||||
"lastfm": "Mostrar enlaces de last.fm",
|
||||
"lastfm_description": "Muestra enlaces a last.fm en las páginas de artistas/álbumes",
|
||||
"musicbrainz": "Mostrar enlaces de MusicBrainz",
|
||||
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista mbid",
|
||||
"neteaseTranslation": "Activar traducciones de NetEase",
|
||||
"neteaseTranslation_description": "Cuando se habilita, busca y muestra letras traducidas desde NetEase si está disponible."
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||
|
||||
@@ -88,7 +88,9 @@
|
||||
"albumGain": "albumin vahvistus (gain)",
|
||||
"albumPeak": "albumin huippu (peak)",
|
||||
"trackGain": "raidan vahvistus (gain)",
|
||||
"trackPeak": "kappaleen huippu (peak)"
|
||||
"trackPeak": "kappaleen huippu (peak)",
|
||||
"additionalParticipants": "muut osallistujat",
|
||||
"tags": "tägit"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "albumi",
|
||||
@@ -173,7 +175,8 @@
|
||||
"localFontAccessDenied": "paikallisiin fontteihin pääsy on kielletty",
|
||||
"playbackError": "mediaa toistaessa tapahtui virhe",
|
||||
"remotePortWarning": "käynnistä palvelin uudestaan ottaaksesi uuden portin käyttöön",
|
||||
"endpointNotImplementedError": "päätepiste {{endpoint}} ei ole toteutettu {{serverType}} varten"
|
||||
"endpointNotImplementedError": "päätepiste {{endpoint}} ei ole toteutettu {{serverType}} varten",
|
||||
"badValue": "kelpaamaton optio \"{{value}}\". tätä arvoa ei ole enää olemassa"
|
||||
},
|
||||
"filter": {
|
||||
"album": "$t(entity.album_one)",
|
||||
@@ -506,7 +509,13 @@
|
||||
"useSystemTheme": "käytä järjestelmän teemaa",
|
||||
"volumeWheelStep": "äänenvoimakkuusrullan askel",
|
||||
"discordServeImage": "jaa {{discord}} kuvat palvelimelta",
|
||||
"discordServeImage_description": "jaa kansikuvat {{discord}}n rich presenceä varten suoraan palvelimelta. saatavilla vain jellyfinille ja navidromelle"
|
||||
"discordServeImage_description": "jaa kansikuvat {{discord}}n rich presenceä varten suoraan palvelimelta. saatavilla vain jellyfinille ja navidromelle",
|
||||
"musicbrainz_description": "näytä linkit musicbrainz sivulle artistin/albumin sivuilla, jos musicbrainz-id löytyy",
|
||||
"lastfm": "näytä last.fm linkit",
|
||||
"lastfm_description": "näytä linkit last.fm sivulle artistin/albumin sivuilla",
|
||||
"musicbrainz": "näytä musicbrainz linkit",
|
||||
"neteaseTranslation": "Ota NetEasen käännökset käyttöön",
|
||||
"neteaseTranslation_description": "Käytöss ollessa noutaa ja näyttää käännetyt sanat NetEasesta, jos ne ovat saatavilla."
|
||||
},
|
||||
"page": {
|
||||
"itemDetail": {
|
||||
|
||||
@@ -598,7 +598,11 @@
|
||||
"lastfmApiKey": "clé API {{lastfm}}",
|
||||
"lastfmApiKey_description": "la clé API pour {{lastfm}} . requise pour la pochette d'album",
|
||||
"discordServeImage": "servir l'image {{discord}} depuis le serveur",
|
||||
"discordServeImage_description": "partage pochette du status d'activité {{discord}} depuis le serveur lui même, disponible uniquement pour jellyfin et navidrome"
|
||||
"discordServeImage_description": "partage pochette du status d'activité {{discord}} depuis le serveur lui même, disponible uniquement pour jellyfin et navidrome",
|
||||
"lastfm": "affiche les liens de last.fm",
|
||||
"musicbrainz_description": "affiches les liens vers musicbrainz sur les pages des artistes/albums, quand mbid existes",
|
||||
"lastfm_description": "affiche les liens vers last.fm sur les pages des artistes/albums",
|
||||
"musicbrainz": "affiches les liens musicbrainz"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
|
||||
@@ -91,7 +91,9 @@
|
||||
"albumGain": "ganho do álbum",
|
||||
"trackPeak": "pico da faixa",
|
||||
"albumPeak": "pico do álbum",
|
||||
"trackGain": "ganho da faixa"
|
||||
"trackGain": "ganho da faixa",
|
||||
"additionalParticipants": "participantes adicionais",
|
||||
"tags": "tags"
|
||||
},
|
||||
"action": {
|
||||
"goToPage": "vá para página",
|
||||
@@ -205,7 +207,16 @@
|
||||
"buttonSize_description": "o tamanho dos botões da barra de reprodução",
|
||||
"albumBackgroundBlur": "tamanho de desfoque da imagem de fundo do álbum",
|
||||
"albumBackgroundBlur_description": "ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum",
|
||||
"albumBackground": "imagem de fundo do álbum"
|
||||
"albumBackground": "imagem de fundo do álbum",
|
||||
"contextMenu_description": "permite esconder itens exibidos no menu quando você clica em um item com o botão direito. itens não selecionados serão escondidos",
|
||||
"customCssEnable": "habilitar css customizado",
|
||||
"customCssEnable_description": "permite escrever css customizado.",
|
||||
"crossfadeDuration": "duraçao de crossfade",
|
||||
"customCss": "css customizado",
|
||||
"crossfadeDuration_description": "define a duração do efeito crossfade",
|
||||
"customCssNotice": "Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de CSS personalizado ainda pode representar riscos ao alterar a interface.",
|
||||
"crossfadeStyle": "estilo do crossfade",
|
||||
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -524,6 +535,7 @@
|
||||
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos",
|
||||
"badAlbum": "você está vendo este erro por que está música não é parte de algum album. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta.",
|
||||
"networkError": "ocorreu um erro na internet",
|
||||
"openError": "não foi possível abrir o arquivo"
|
||||
"openError": "não foi possível abrir o arquivo",
|
||||
"badValue": "opção inválida \"{{value}}\". este valor não existe no momento"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
"followLyric_description": "滚动歌词到当前播放位置",
|
||||
"audioExclusiveMode": "音频独占模式",
|
||||
"font": "字体",
|
||||
"neteaseTranslation": "启用网易云歌词翻译",
|
||||
"neteaseTranslation_description": "启用后,在获取歌词时将包含并显示网易云音乐提供的翻译(如果存在)。",
|
||||
"crossfadeDuration_description": "设置淡入淡出持续时间",
|
||||
"audioDevice": "音频设备",
|
||||
"enableRemote": "启用远程控制服务器",
|
||||
@@ -393,7 +395,11 @@
|
||||
"lastfmApiKey": "{{lastfm}} API 密钥",
|
||||
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需",
|
||||
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
||||
"discordServeImage_description": "分享 {{discord}} 封面艺术图,来自 rich presence 服务器,仅适用于 jellyfin 和 navidrome"
|
||||
"discordServeImage_description": "分享 {{discord}} 封面艺术图,来自 rich presence 服务器,仅适用于 jellyfin 和 navidrome",
|
||||
"musicbrainz": "显示 musicbrainz 链接",
|
||||
"musicbrainz_description": "在 mbid 的艺术家/专辑页面上显示 musicbrainz 的链接",
|
||||
"lastfm": "显示 last.fm 链接",
|
||||
"lastfm_description": "在艺术家/专辑页面上显示 last.fm 的链接"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LyricSearchQuery,
|
||||
LyricSource,
|
||||
} from '.';
|
||||
import { store } from '../settings';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const SEARCH_URL = 'https://music.163.com/api/search/get';
|
||||
@@ -76,14 +77,20 @@ export async function getLyricsBySongId(songId: string): Promise<null | string>
|
||||
id: songId,
|
||||
kv: '-1',
|
||||
lv: '-1',
|
||||
tv: '-1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
const enableTranslation = store.get('enableNeteaseTranslation', false) as boolean;
|
||||
const originalLrc = result.data.lrc?.lyric;
|
||||
if (!enableTranslation) {
|
||||
return originalLrc || null;
|
||||
}
|
||||
const translatedLrc = result.data.tlyric?.lyric;
|
||||
return mergeLyrics(originalLrc, translatedLrc);
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
@@ -166,3 +173,54 @@ async function getMatchedLyrics(
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
function mergeLyrics(original: string | undefined, translated: string | undefined): null | string {
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
if (!translated) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const lrcLineRegex = /\[(\d{2}:\d{2}\.\d{2,3})\](.*)/;
|
||||
const translatedMap = new Map<string, string>();
|
||||
|
||||
// Parse the translated LRC and store it in a Map for efficient timestamp-based lookups.
|
||||
translated.split('\n').forEach((line) => {
|
||||
const match = line.match(lrcLineRegex);
|
||||
if (match) {
|
||||
const timestamp = match[1];
|
||||
const text = match[2].trim();
|
||||
if (text) {
|
||||
translatedMap.set(timestamp, text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (translatedMap.size === 0) {
|
||||
return original;
|
||||
}
|
||||
|
||||
// Iterate through each line of the original LRC. If a translation exists for
|
||||
// the same timestamp, insert it as a new, fully-formatted LRC line.
|
||||
const finalLines = original.split('\n').flatMap((line) => {
|
||||
const match = line.match(lrcLineRegex);
|
||||
|
||||
if (match) {
|
||||
const timestamp = match[1];
|
||||
const translatedText = translatedMap.get(timestamp);
|
||||
|
||||
if (translatedText) {
|
||||
// Return an array containing both the original line and the new translated line.
|
||||
// flatMap will flatten this into the final array of lines.
|
||||
const translatedLine = `[${timestamp}]${translatedText}`;
|
||||
return [line, translatedLine];
|
||||
}
|
||||
}
|
||||
|
||||
// If no match or no translation is found, return only the original line.
|
||||
return [line];
|
||||
});
|
||||
|
||||
return finalLines.join('\n');
|
||||
}
|
||||
|
||||
+11
-65
@@ -1,10 +1,15 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
|
||||
import './styles/global.scss';
|
||||
import '/@/shared/styles/global.css';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Shell } from '/@/remote/components/shell';
|
||||
import { useIsDark, useReconnect } from '/@/remote/store';
|
||||
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
||||
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
||||
|
||||
export const App = () => {
|
||||
const isDark = useIsDark();
|
||||
@@ -14,71 +19,12 @@ export const App = () => {
|
||||
reconnect();
|
||||
}, [reconnect]);
|
||||
|
||||
const { mode, theme } = useAppTheme(isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={{
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: {
|
||||
body: {
|
||||
height: '100vh',
|
||||
overflow: 'scroll',
|
||||
},
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
styles: {
|
||||
body: {
|
||||
background: 'var(--modal-bg)',
|
||||
height: '100vh',
|
||||
},
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
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)',
|
||||
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}
|
||||
theme={theme}
|
||||
>
|
||||
<Shell />
|
||||
</MantineProvider>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { CiImageOff, CiImageOn } from 'react-icons/ci';
|
||||
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { useShowImage, useToggleShowImage } from '/@/remote/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
|
||||
export const ImageButton = () => {
|
||||
const showImage = useShowImage();
|
||||
const toggleImage = useToggleShowImage();
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
mr={5}
|
||||
<ActionIcon
|
||||
onClick={() => toggleImage()}
|
||||
size="xl"
|
||||
tooltip={showImage ? 'Hide Image' : 'Show Image'}
|
||||
tooltip={{
|
||||
label: showImage ? 'Hide Image' : 'Show Image',
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
|
||||
</RemoteButton>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { RiRestartLine } from 'react-icons/ri';
|
||||
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { useConnected, useReconnect } from '/@/remote/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
|
||||
export const ReconnectButton = () => {
|
||||
const connected = useConnected();
|
||||
const reconnect = useReconnect();
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
$active={!connected}
|
||||
mr={5}
|
||||
<ActionIcon
|
||||
onClick={() => reconnect()}
|
||||
size="xl"
|
||||
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
|
||||
tooltip={{
|
||||
label: connected ? 'Reconnect' : 'Not connected. Reconnect.',
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<RiRestartLine size={30} />
|
||||
</RemoteButton>
|
||||
<RiRestartLine
|
||||
color={connected ? 'var(--theme-colors-primary)' : 'var(--theme-colors-foreground)'}
|
||||
size={30}
|
||||
/>
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Button, type ButtonProps as MantineButtonProps, Tooltip } from '@mantine/core';
|
||||
import { forwardRef, MouseEvent, ReactNode, Ref } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface ButtonProps extends StyledButtonProps {
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
interface StyledButtonProps extends MantineButtonProps {
|
||||
$active?: boolean;
|
||||
children: ReactNode;
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
ref: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
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<HTMLButtonElement, any>(
|
||||
({ children, tooltip, ...props }: any, ref) => {
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltip}
|
||||
withinPortal
|
||||
>
|
||||
<StyledButton
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,28 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
|
||||
export const ThemeButton = () => {
|
||||
const isDark = useIsDark();
|
||||
const toggleDark = useToggleDark();
|
||||
|
||||
useEffect(() => {
|
||||
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
|
||||
document.body.setAttribute('data-theme', targetTheme);
|
||||
}, [isDark]);
|
||||
const handleToggleTheme = () => {
|
||||
toggleDark();
|
||||
};
|
||||
|
||||
return (
|
||||
<RemoteButton
|
||||
mr={5}
|
||||
onClick={() => toggleDark()}
|
||||
size="xl"
|
||||
tooltip="Toggle Theme"
|
||||
<ActionIcon
|
||||
onClick={handleToggleTheme}
|
||||
tooltip={{
|
||||
label: 'Toggle Theme',
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
|
||||
</RemoteButton>
|
||||
{isDark ? (
|
||||
<Icon
|
||||
icon="themeLight"
|
||||
size={30}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon="themeDark"
|
||||
size={30}
|
||||
/>
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: var(--theme-image-fit);
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import styles from './player-image.module.css';
|
||||
|
||||
import { useSend } from '/@/remote/store';
|
||||
|
||||
interface PlayerImageProps {
|
||||
src?: null | string;
|
||||
}
|
||||
export const PlayerImage = ({ src }: PlayerImageProps) => {
|
||||
const send = useSend();
|
||||
|
||||
return (
|
||||
<img
|
||||
className={styles.container}
|
||||
onError={() => send({ event: 'proxy' })}
|
||||
src={src?.replaceAll(/&(size|width|height=\d+)/g, '')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Group, Image, Rating, Text, Title, Tooltip } from '@mantine/core';
|
||||
import formatDuration from 'format-duration';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
RiHeartLine,
|
||||
RiPauseFill,
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiVolumeUpFill,
|
||||
} from 'react-icons/ri';
|
||||
import { RiPauseFill, RiPlayFill, RiVolumeUpFill } from 'react-icons/ri';
|
||||
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||
import { PlayerImage } from '/@/remote/components/player-image';
|
||||
import { WrappedSlider } from '/@/remote/components/wrapped-slider';
|
||||
import { useInfo, useSend, useShowImage } from '/@/remote/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
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 { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const RemoteContainer = () => {
|
||||
@@ -36,107 +32,81 @@ export const RemoteContainer = () => {
|
||||
const debouncedSetRating = debounce(setRating, 400);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
gap="md"
|
||||
h="100dvh"
|
||||
w="100%"
|
||||
>
|
||||
{showImage && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
w="100%"
|
||||
>
|
||||
<PlayerImage src={song?.imageUrl} />
|
||||
</Flex>
|
||||
)}
|
||||
{id && (
|
||||
<>
|
||||
<Title order={1}>{song.name}</Title>
|
||||
<Group align="flex-end">
|
||||
<Title order={2}>Album: {song.album}</Title>
|
||||
<Title order={2}>Artist: {song.artistName}</Title>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
|
||||
<Stack gap="xs">
|
||||
<Text
|
||||
fw={700}
|
||||
size="xl"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{song.name}
|
||||
</Text>
|
||||
<Text
|
||||
isMuted
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{song.album}
|
||||
</Text>
|
||||
<Text
|
||||
isMuted
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{song.artistName}
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
{song.releaseDate && (
|
||||
<Title order={3}>
|
||||
Released: {new Date(song.releaseDate).toLocaleDateString()}
|
||||
</Title>
|
||||
<Text isMuted>{new Date(song.releaseDate).toLocaleDateString()}</Text>
|
||||
)}
|
||||
<Title order={3}>Plays: {song.playCount}</Title>
|
||||
<Text isMuted>Plays: {song.playCount}</Text>
|
||||
</Group>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
<Group
|
||||
gap={0}
|
||||
grow
|
||||
spacing={0}
|
||||
>
|
||||
<RemoteButton
|
||||
<ActionIcon
|
||||
disabled={!id}
|
||||
onClick={() => send({ event: 'previous' })}
|
||||
tooltip="Previous track"
|
||||
variant="default"
|
||||
>
|
||||
<RiSkipBackFill size={25} />
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
disabled={!id}
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
send({ event: 'pause' });
|
||||
} else if (status === PlayerStatus.PAUSED) {
|
||||
send({ event: 'play' });
|
||||
}
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: song?.userFavorite ? 'primary' : 'default',
|
||||
}}
|
||||
tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||
variant="default"
|
||||
>
|
||||
{id && status === PlayerStatus.PLAYING ? (
|
||||
<RiPauseFill size={25} />
|
||||
) : (
|
||||
<RiPlayFill size={25} />
|
||||
)}
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
disabled={!id}
|
||||
onClick={() => send({ event: 'next' })}
|
||||
tooltip="Next track"
|
||||
variant="default"
|
||||
>
|
||||
<RiSkipForwardFill size={25} />
|
||||
</RemoteButton>
|
||||
</Group>
|
||||
<Group
|
||||
grow
|
||||
spacing={0}
|
||||
>
|
||||
<RemoteButton
|
||||
$active={shuffle || false}
|
||||
onClick={() => send({ event: 'shuffle' })}
|
||||
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
|
||||
variant="default"
|
||||
>
|
||||
<RiShuffleFill size={25} />
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
|
||||
onClick={() => send({ event: 'repeat' })}
|
||||
tooltip={`Repeat ${
|
||||
repeat === PlayerRepeat.ONE
|
||||
? 'One'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'all'
|
||||
: 'none'
|
||||
}`}
|
||||
variant="default"
|
||||
>
|
||||
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={25} />
|
||||
) : (
|
||||
<RiRepeat2Line size={25} />
|
||||
)}
|
||||
</RemoteButton>
|
||||
<RemoteButton
|
||||
$active={song?.userFavorite}
|
||||
disabled={!id}
|
||||
onClick={() => {
|
||||
if (!id) return;
|
||||
|
||||
send({ event: 'favorite', favorite: !song.userFavorite, id });
|
||||
}}
|
||||
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
||||
variant="default"
|
||||
>
|
||||
<RiHeartLine size={25} />
|
||||
</RemoteButton>
|
||||
tooltip={{
|
||||
label: song?.userFavorite ? 'Unfavorite' : 'Favorite',
|
||||
}}
|
||||
variant="transparent"
|
||||
/>
|
||||
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<Tooltip
|
||||
@@ -146,43 +116,132 @@ export const RemoteContainer = () => {
|
||||
<Rating
|
||||
onChange={debouncedSetRating}
|
||||
onDoubleClick={() => debouncedSetRating(0)}
|
||||
sx={{ margin: 'auto' }}
|
||||
style={{ margin: 'auto' }}
|
||||
value={song.userRating ?? 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
{id && position !== undefined && (
|
||||
<WrapperSlider
|
||||
label={(value) => formatDuration(value * 1e3)}
|
||||
leftLabel={formatDuration(position * 1e3)}
|
||||
max={song.duration / 1e3}
|
||||
onChangeEnd={(e) => send({ event: 'position', position: e })}
|
||||
rightLabel={formatDuration(song.duration)}
|
||||
value={position}
|
||||
<Group
|
||||
gap="xs"
|
||||
grow
|
||||
>
|
||||
<ActionIcon
|
||||
disabled={!id}
|
||||
icon="mediaPrevious"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={() => send({ event: 'previous' })}
|
||||
tooltip={{
|
||||
label: 'Previous track',
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
)}
|
||||
<WrapperSlider
|
||||
leftLabel={<RiVolumeUpFill size={20} />}
|
||||
max={100}
|
||||
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
|
||||
rightLabel={
|
||||
<Text
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{volume ?? 0}
|
||||
</Text>
|
||||
}
|
||||
value={volume ?? 0}
|
||||
/>
|
||||
{showImage && (
|
||||
<Image
|
||||
onError={() => send({ event: 'proxy' })}
|
||||
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
|
||||
<ActionIcon
|
||||
disabled={!id}
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
send({ event: 'pause' });
|
||||
} else if (status === PlayerStatus.PAUSED) {
|
||||
send({ event: 'play' });
|
||||
}
|
||||
}}
|
||||
tooltip={{
|
||||
label: id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play',
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{id && status === PlayerStatus.PLAYING ? (
|
||||
<RiPauseFill size={25} />
|
||||
) : (
|
||||
<RiPlayFill size={25} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={!id}
|
||||
icon="mediaNext"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={() => send({ event: 'next' })}
|
||||
tooltip={{
|
||||
label: 'Next track',
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Group>
|
||||
<Group
|
||||
gap="xs"
|
||||
grow
|
||||
>
|
||||
<ActionIcon
|
||||
icon="mediaShuffle"
|
||||
iconProps={{
|
||||
fill: shuffle ? 'primary' : 'default',
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={() => send({ event: 'shuffle' })}
|
||||
tooltip={{
|
||||
label: shuffle ? 'Shuffle tracks' : 'Shuffle disabled',
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={
|
||||
repeat === undefined || repeat === PlayerRepeat.ONE
|
||||
? 'mediaRepeatOne'
|
||||
: 'mediaRepeat'
|
||||
}
|
||||
iconProps={{
|
||||
fill:
|
||||
repeat !== undefined && repeat !== PlayerRepeat.NONE
|
||||
? 'primary'
|
||||
: 'default',
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={() => send({ event: 'repeat' })}
|
||||
tooltip={{
|
||||
label: `Repeat ${
|
||||
repeat === PlayerRepeat.ONE
|
||||
? 'One'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'all'
|
||||
: 'none'
|
||||
}`,
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{id && position !== undefined && (
|
||||
<WrappedSlider
|
||||
label={(value) => formatDuration(value * 1e3)}
|
||||
leftLabel={formatDuration(position * 1e3)}
|
||||
max={song.duration / 1e3}
|
||||
onChangeEnd={(e) => send({ event: 'position', position: e })}
|
||||
rightLabel={formatDuration(song.duration)}
|
||||
value={position}
|
||||
/>
|
||||
)}
|
||||
<WrappedSlider
|
||||
leftLabel={<RiVolumeUpFill size={20} />}
|
||||
max={100}
|
||||
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
|
||||
rightLabel={
|
||||
<Text
|
||||
fw={600}
|
||||
size="xs"
|
||||
>
|
||||
{volume ?? 0}
|
||||
</Text>
|
||||
}
|
||||
value={volume ?? 0}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,76 +1,71 @@
|
||||
import {
|
||||
AppShell,
|
||||
Container,
|
||||
Flex,
|
||||
Grid,
|
||||
Header,
|
||||
Image,
|
||||
MediaQuery,
|
||||
Skeleton,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { AppShell, Flex, Grid, Image } from '@mantine/core';
|
||||
|
||||
import { ImageButton } from '/@/remote/components/buttons/image-button';
|
||||
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
|
||||
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
|
||||
import { RemoteContainer } from '/@/remote/components/remote-container';
|
||||
import { useConnected } from '/@/remote/store';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
|
||||
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</Title>
|
||||
</Grid.Col>
|
||||
</MediaQuery>
|
||||
|
||||
<Grid.Col span="auto">
|
||||
<Flex
|
||||
direction="row"
|
||||
justify="right"
|
||||
>
|
||||
<ReconnectButton />
|
||||
<ImageButton />
|
||||
<ThemeButton />
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Header>
|
||||
}
|
||||
h="100vh"
|
||||
padding="md"
|
||||
w="100vw"
|
||||
>
|
||||
<Container>
|
||||
<AppShell.Header style={{ background: 'var(--theme-colors-surface)' }}>
|
||||
<Grid
|
||||
px="md"
|
||||
py="sm"
|
||||
>
|
||||
<Grid.Col span={4}>
|
||||
<Flex
|
||||
align="center"
|
||||
direction="row"
|
||||
h="100%"
|
||||
justify="flex-start"
|
||||
style={{
|
||||
justifySelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
fit="contain"
|
||||
height={32}
|
||||
src="/favicon.ico"
|
||||
width={32}
|
||||
/>
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
<Group
|
||||
gap="sm"
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<ReconnectButton />
|
||||
<ImageButton />
|
||||
<ThemeButton />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main pt="60px">
|
||||
{connected ? (
|
||||
<RemoteContainer />
|
||||
) : (
|
||||
<Skeleton
|
||||
height={300}
|
||||
width="100%"
|
||||
/>
|
||||
<Center
|
||||
h="100vh"
|
||||
w="100vw"
|
||||
>
|
||||
<Spinner />
|
||||
</Center>
|
||||
)}
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
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 { Group } from '/@/shared/components/group/group';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
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',
|
||||
@@ -59,9 +36,9 @@ const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
||||
},
|
||||
track: {
|
||||
'&::before': {
|
||||
backgroundColor: 'var(--playerbar-slider-track-bg)',
|
||||
right: 'calc(0.1rem * -1)',
|
||||
},
|
||||
height: '1rem',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
@@ -79,31 +56,32 @@ export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
|
||||
export const WrappedSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [seek, setSeek] = useState(0);
|
||||
|
||||
return (
|
||||
<SliderContainer>
|
||||
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
|
||||
<SliderWrapper>
|
||||
<PlayerbarSlider
|
||||
{...props}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
setIsSeeking(true);
|
||||
setSeek(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
props.onChangeEnd(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
size={6}
|
||||
value={!isSeeking ? (value ?? 0) : seek}
|
||||
w="100%"
|
||||
/>
|
||||
</SliderWrapper>
|
||||
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
|
||||
</SliderContainer>
|
||||
<Group
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
{leftLabel && <Text size="sm">{leftLabel}</Text>}
|
||||
<PlayerbarSlider
|
||||
{...props}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
setIsSeeking(true);
|
||||
setSeek(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
props.onChangeEnd(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
size={6}
|
||||
value={!isSeeking ? (value ?? 0) : seek}
|
||||
w="100%"
|
||||
/>
|
||||
{rightLabel && <Text size="sm">{rightLabel}</Text>}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Feishin Remote</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<script>
|
||||
|
||||
+1
-10
@@ -1,4 +1,3 @@
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from '/@/remote/app';
|
||||
@@ -6,12 +5,4 @@ import { App } from '/@/remote/app';
|
||||
const container = document.getElementById('root')! as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<>
|
||||
<Notifications
|
||||
containerWidth="300px"
|
||||
position="bottom-center"
|
||||
/>
|
||||
<App />
|
||||
</>,
|
||||
);
|
||||
root.render(<App />);
|
||||
|
||||
@@ -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<SettingsSlice>()(
|
||||
export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
|
||||
persist(
|
||||
devtools(
|
||||
immer((set, get) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -347,8 +347,8 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
type = AlbumListSortType.BY_YEAR;
|
||||
}
|
||||
|
||||
let fromYear;
|
||||
let toYear;
|
||||
let fromYear: number | undefined;
|
||||
let toYear: number | undefined;
|
||||
|
||||
if (query.minYear) {
|
||||
fromYear = query.minYear;
|
||||
@@ -398,124 +398,8 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
totalRecordCount: null,
|
||||
};
|
||||
},
|
||||
getAlbumListCount: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (query.searchTerm) {
|
||||
let fetchNextPage = true;
|
||||
let startIndex = 0;
|
||||
let totalRecordCount = 0;
|
||||
|
||||
while (fetchNextPage) {
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
albumCount: 500,
|
||||
albumOffset: startIndex,
|
||||
artistCount: 0,
|
||||
artistOffset: 0,
|
||||
query: query.searchTerm || '""',
|
||||
songCount: 0,
|
||||
songOffset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list count');
|
||||
}
|
||||
|
||||
const albumCount = (res.body.searchResult3?.album || [])?.length;
|
||||
|
||||
totalRecordCount += albumCount;
|
||||
startIndex += albumCount;
|
||||
|
||||
// The max limit size for Subsonic is 500
|
||||
fetchNextPage = albumCount === 500;
|
||||
}
|
||||
|
||||
return totalRecordCount;
|
||||
}
|
||||
|
||||
if (query.favorite) {
|
||||
const res = await ssApiClient(apiClientProps).getStarred({
|
||||
query: {
|
||||
musicFolderId: query.musicFolderId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
return (res.body.starred?.album || []).length || 0;
|
||||
}
|
||||
|
||||
let type = AlbumListSortType.ALPHABETICAL_BY_NAME;
|
||||
|
||||
let fetchNextPage = true;
|
||||
let startIndex = 0;
|
||||
let totalRecordCount = 0;
|
||||
|
||||
if (query.genres?.length) {
|
||||
type = AlbumListSortType.BY_GENRE;
|
||||
}
|
||||
|
||||
if (query.minYear || query.maxYear) {
|
||||
type = AlbumListSortType.BY_YEAR;
|
||||
}
|
||||
|
||||
let fromYear;
|
||||
let toYear;
|
||||
|
||||
if (query.minYear) {
|
||||
fromYear = query.minYear;
|
||||
toYear = dayjs().year();
|
||||
}
|
||||
|
||||
if (query.maxYear) {
|
||||
toYear = query.maxYear;
|
||||
|
||||
if (!query.minYear) {
|
||||
fromYear = 0;
|
||||
}
|
||||
}
|
||||
|
||||
while (fetchNextPage) {
|
||||
const res = await ssApiClient(apiClientProps).getAlbumList2({
|
||||
query: {
|
||||
fromYear,
|
||||
genre: query.genres?.length ? query.genres[0] : undefined,
|
||||
musicFolderId: query.musicFolderId,
|
||||
offset: startIndex,
|
||||
size: 500,
|
||||
toYear,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
const headers = res.headers;
|
||||
|
||||
// Navidrome returns the total count in the header
|
||||
if (headers.get('x-total-count')) {
|
||||
fetchNextPage = false;
|
||||
totalRecordCount = Number(headers.get('x-total-count'));
|
||||
break;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list count');
|
||||
}
|
||||
|
||||
const albumCount = res.body.albumList2.album.length;
|
||||
|
||||
totalRecordCount += albumCount;
|
||||
startIndex += albumCount;
|
||||
|
||||
// The max limit size for Subsonic is 500
|
||||
fetchNextPage = albumCount === 500;
|
||||
}
|
||||
|
||||
return totalRecordCount;
|
||||
},
|
||||
getAlbumListCount: async (args) =>
|
||||
SubsonicController.getAlbumList(args).then((res) => res!.totalRecordCount!),
|
||||
getArtistList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+18
-109
@@ -2,17 +2,20 @@ 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 '/@/shared/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 +23,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 +36,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<HTMLStyleElement | null>(null);
|
||||
const cssRef = useRef<HTMLStyleElement | null>(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<WebAudio>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,16 +85,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 +191,14 @@ export const App = () => {
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={{
|
||||
colorScheme: theme as 'dark' | 'light',
|
||||
components: {
|
||||
Modal: {
|
||||
styles: {
|
||||
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
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)',
|
||||
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}
|
||||
>
|
||||
<Notifications
|
||||
containerWidth="300px"
|
||||
position="bottom-center"
|
||||
zIndex={5}
|
||||
/>
|
||||
<PlayQueueHandlerContext.Provider value={providerValue}>
|
||||
<ContextMenuProvider>
|
||||
<WebAudioContext.Provider value={webAudioProvider}>
|
||||
|
||||
@@ -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 <StyledAccordion {...props}>{children}</StyledAccordion>;
|
||||
};
|
||||
|
||||
Accordion.Control = StyledAccordion.Control;
|
||||
Accordion.Item = StyledAccordion.Item;
|
||||
Accordion.Panel = StyledAccordion.Panel;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)<BadgeProps>`
|
||||
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 (
|
||||
<StyledBadge
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
root: { background: 'var(--badge-bg)' },
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge);
|
||||
@@ -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<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
}
|
||||
|
||||
interface StyledButtonProps extends ButtonProps {
|
||||
ref: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
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<HTMLButtonElement, ButtonProps>(
|
||||
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
{...tooltip}
|
||||
>
|
||||
<StyledButton
|
||||
loaderPosition="center"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
loaderPosition="center"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 (
|
||||
<Button
|
||||
onClick={startTimeout}
|
||||
sx={{ color: 'var(--danger-color)' }}
|
||||
{...props}
|
||||
>
|
||||
{isRunning ? 'Cancel' : props.children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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<Album | AlbumArtist | Artist>[];
|
||||
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 (
|
||||
<CardWrapper
|
||||
link
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={size}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={size}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${size}px`,
|
||||
width: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<CardControls
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={cardRows}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper>
|
||||
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<Skeleton
|
||||
height={size}
|
||||
radius="sm"
|
||||
visible
|
||||
width={size}
|
||||
>
|
||||
<ImageSection />
|
||||
</Skeleton>
|
||||
<DetailSection style={{ width: '100%' }}>
|
||||
{(cardRows || []).map((_row: CardRow<Album>, index: number) => (
|
||||
<Skeleton
|
||||
height={15}
|
||||
key={`skeleton-${data?.id}-${index}`}
|
||||
my={3}
|
||||
radius="md"
|
||||
visible
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
>
|
||||
<Row />
|
||||
</Skeleton>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<PlayButtonType>`
|
||||
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 (
|
||||
<GridCardControlsContainer>
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<SecondaryButton
|
||||
<div className={styles.gridCardControlsContainer}>
|
||||
<div className={styles.bottomControls}>
|
||||
<button
|
||||
className={styles.playButton}
|
||||
onClick={handlePlay}
|
||||
>
|
||||
<Icon icon="mediaPlay" />
|
||||
</button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
className={styles.secondaryButton}
|
||||
disabled
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
style={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
<div className={itemData?.isFavorite ? styles.favoriteWrapper : ''}>
|
||||
{itemData?.isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
<Icon icon="favorite" />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
<Icon icon="favorite" />
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={(e) => {
|
||||
</div>
|
||||
</Button>
|
||||
<ActionIcon
|
||||
className={styles.secondaryButton}
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
style={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
<Icon icon="ellipsisHorizontal" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Album>[] | CardRow<AlbumArtist>[] | CardRow<Artist>[];
|
||||
@@ -33,17 +23,19 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
{rows.map((row, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
$secondary={index > 0}
|
||||
<div
|
||||
className={clsx(styles.row, {
|
||||
[styles.secondary]: index > 0,
|
||||
})}
|
||||
key={`row-${row.property}-${index}`}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
sx={{
|
||||
isMuted
|
||||
isNoSelect
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
@@ -52,10 +44,10 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 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) => {
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
<div
|
||||
className={clsx(styles.row, {
|
||||
[styles.secondary]: index > 0,
|
||||
})}
|
||||
key={`row-${row.property}`}
|
||||
>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 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])}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
<div
|
||||
className={clsx(styles.row, {
|
||||
[styles.secondary]: index > 0,
|
||||
})}
|
||||
key={`row-${row.property}`}
|
||||
>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
isLink
|
||||
isNoSelect
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
@@ -125,15 +127,15 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
isMuted={index > 0}
|
||||
isNoSelect
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
{data && (row.format ? row.format(data) : data[row.property])}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './album-card';
|
||||
export * from './card-rows';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
|
||||
<ImageContainer
|
||||
$isFavorite={data?.userFavorite}
|
||||
<div
|
||||
className={styles.container}
|
||||
key={`${uniqueId}-${data.id}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Link
|
||||
className={styles.imageContainer}
|
||||
to={path}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
isHovered={isHovered}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
</Link>
|
||||
<div className={styles.detailContainer}>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
|
||||
<Skeleton
|
||||
radius="sm"
|
||||
visible
|
||||
>
|
||||
<ImageContainerSkeleton />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
<div
|
||||
className={styles.container}
|
||||
key={`placeholder-${uniqueId}-${data.id}`}
|
||||
>
|
||||
<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}-${row.arrayProperty}`}
|
||||
radius="sm"
|
||||
visible
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HTMLInputElement, CheckboxProps>(
|
||||
({ ...props }: CheckboxProps, ref) => {
|
||||
return (
|
||||
<StyledCheckbox
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
{...props}
|
||||
className={styles.contextMenuButton}
|
||||
disabled={props.disabled}
|
||||
key={props.key}
|
||||
onClick={props.onClick}
|
||||
ref={ref}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<Group
|
||||
className={styles.left}
|
||||
gap="md"
|
||||
>
|
||||
{leftIcon}
|
||||
{children}
|
||||
</Group>
|
||||
{rightIcon}
|
||||
</Group>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
return (
|
||||
<motion.div
|
||||
animate="open"
|
||||
className={styles.container}
|
||||
initial="closed"
|
||||
ref={ref}
|
||||
style={{
|
||||
left: xPos,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
top: yPos,
|
||||
}}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Box, 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)<Omit<ContextMenuProps, 'children'>>`
|
||||
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 (
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
key={props.key}
|
||||
onClick={props.onClick}
|
||||
ref={ref}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group spacing="md">
|
||||
<Box>{leftIcon}</Box>
|
||||
<Box mr="2rem">{children}</Box>
|
||||
</Group>
|
||||
<Box>{rightIcon}</Box>
|
||||
</Group>
|
||||
</StyledContextMenuButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
return (
|
||||
<ContextMenuContainer
|
||||
animate="open"
|
||||
initial="closed"
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
ref={ref}
|
||||
variants={variants}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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)<DatePickerProps>`
|
||||
& .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 (
|
||||
<StyledDatePicker
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 <StyledDialog {...props} />;
|
||||
};
|
||||
@@ -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)<MenuProps>``;
|
||||
|
||||
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
|
||||
padding: 0.5rem;
|
||||
font-family: var(--content-font-family);
|
||||
`;
|
||||
|
||||
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
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 (
|
||||
<StyledMenu
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transitionProps={{
|
||||
transition: 'fade',
|
||||
}}
|
||||
withinPortal
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
|
||||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||
};
|
||||
|
||||
const pMenuItem = ({ $danger, $isActive, children, ...props }: MenuItemProps) => {
|
||||
return (
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
$isActive={$isActive}
|
||||
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {
|
||||
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
|
||||
};
|
||||
|
||||
const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);
|
||||
|
||||
const MenuDivider = ({ ...props }: MenuDividerProps) => {
|
||||
return <StyledMenuDivider {...props} />;
|
||||
};
|
||||
|
||||
DropdownMenu.Label = MenuLabel;
|
||||
DropdownMenu.Item = MenuItem;
|
||||
DropdownMenu.Target = MantineMenu.Target;
|
||||
DropdownMenu.Dropdown = MenuDropdown;
|
||||
DropdownMenu.Divider = MenuDivider;
|
||||
@@ -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;
|
||||
}
|
||||
+51
-136
@@ -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 (
|
||||
<Wrapper
|
||||
<Link
|
||||
className={styles.wrapper}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
|
||||
>
|
||||
<AnimatePresence
|
||||
@@ -153,73 +83,61 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
mode="popLayout"
|
||||
>
|
||||
{data && (
|
||||
<Carousel
|
||||
<motion.div
|
||||
animate="animate"
|
||||
className={styles.carousel}
|
||||
custom={direction}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
key={`image-${itemIndex}`}
|
||||
variants={variants}
|
||||
>
|
||||
<Grid>
|
||||
<ImageColumn>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.imageColumn}>
|
||||
<Image
|
||||
height={225}
|
||||
placeholder="var(--card-default-bg)"
|
||||
radius="md"
|
||||
src={data[itemIndex]?.imageUrl}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
src={data[itemIndex]?.imageUrl || ''}
|
||||
width={225}
|
||||
/>
|
||||
</ImageColumn>
|
||||
<InfoColumn>
|
||||
</div>
|
||||
<div className={styles.infoColumn}>
|
||||
<Stack
|
||||
spacing="md"
|
||||
sx={{ width: '100%' }}
|
||||
gap="md"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<div className={styles.titleWrapper}>
|
||||
<TextTitle
|
||||
lh="3.5rem"
|
||||
fw={900}
|
||||
lineClamp={2}
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
sx={{ fontSize: '3.5rem' }}
|
||||
weight={900}
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</TitleWrapper>
|
||||
<TitleWrapper>
|
||||
</div>
|
||||
<div className={styles.titleWrapper}>
|
||||
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
|
||||
<TextTitle
|
||||
<Text
|
||||
fw={600}
|
||||
key={`carousel-artist-${artist.id}`}
|
||||
order={2}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</TextTitle>
|
||||
</Text>
|
||||
))}
|
||||
</TitleWrapper>
|
||||
</div>
|
||||
<Group>
|
||||
{currentItem?.genres?.slice(0, 1).map((genre) => (
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
{currentItem?.songCount !== null &&
|
||||
currentItem?.songCount !== undefined && (
|
||||
<Badge size="lg">
|
||||
{t('entity.trackWithCount', {
|
||||
count: currentItem?.songCount || 0,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="default">{currentItem?.releaseYear}</Badge>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
<Group justify="space-between">
|
||||
<PlayButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -233,8 +151,6 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
playType,
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
>
|
||||
{t(
|
||||
@@ -245,37 +161,36 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
: 'player.addLast',
|
||||
{ postProcess: 'titleCase' },
|
||||
)}
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
</PlayButton>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
<Icon icon="arrowLeftS" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
<Icon icon="arrowRightS" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InfoColumn>
|
||||
</Grid>
|
||||
<BackgroundImage
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
className={styles.backgroundImage}
|
||||
draggable="false"
|
||||
src={currentItem?.imageUrl || undefined}
|
||||
src={currentItem?.imageUrl || ''}
|
||||
/>
|
||||
<BackgroundImageOverlay />
|
||||
</Carousel>
|
||||
<div className={styles.backgroundImageOverlay} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
+18
-24
@@ -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 (
|
||||
<Group position="apart">
|
||||
<Group justify="space-between">
|
||||
{isValidElement(label) ? (
|
||||
label
|
||||
) : (
|
||||
<TextTitle
|
||||
order={2}
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
{label}
|
||||
</TextTitle>
|
||||
)}
|
||||
|
||||
<Group spacing="sm">
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasPreviousPage}
|
||||
onClick={handlePrev}
|
||||
size="lg"
|
||||
variant="default"
|
||||
size="compact-md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
<Icon icon="arrowLeftS" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasNextPage}
|
||||
onClick={handleNext}
|
||||
size="lg"
|
||||
variant="default"
|
||||
size="compact-md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
<Icon icon="arrowRightS" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -286,10 +280,10 @@ export const SwiperGridCarousel = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CarouselContainer
|
||||
<Stack
|
||||
className="grid-carousel"
|
||||
ref={containerRef}
|
||||
spacing="md"
|
||||
gap="md"
|
||||
ref={containerRef as any}
|
||||
>
|
||||
{title ? (
|
||||
<Title
|
||||
@@ -326,7 +320,7 @@ export const SwiperGridCarousel = ({
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</CarouselContainer>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-77
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.virtual-grid-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.virtual-grid-auto-sizer-container {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user