Migrate to Mantine v8 and Design Changes (#961)

* mantine v8 migration

* various design changes and improvements
This commit is contained in:
Jeff
2025-06-24 00:04:36 -07:00
committed by GitHub
parent bea55d48a8
commit c1330d92b2
473 changed files with 12469 additions and 11607 deletions

View File

@@ -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
}
}

16
.vscode/settings.json vendored
View File

@@ -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 {}",
"",

View File

@@ -47,7 +47,7 @@ const config: UserConfig = {
renderer: {
css: {
modules: {
generateScopedName: '[name]__[local]__[hash:base64:5]',
generateScopedName: 'fs-[name]-[local]',
localsConvention: 'camelCase',
},
},

View File

@@ -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,

View File

@@ -28,8 +28,12 @@
"dev:watch": "electron-vite dev --watch",
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps",
"lint": "eslint --cache .",
"lint:fix": "eslint --cache --fix .",
"lint": "pnpm run lint-code && pnpm run lint-styles",
"lint-code": "eslint --cache .",
"lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"package": "pnpm run build && electron-builder",
"package:dev": "pnpm run build && electron-builder --dir",
"package:linux": "pnpm run build && electron-builder --linux",
@@ -54,16 +58,18 @@
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@atlaskit/pragmatic-drag-and-drop": "1.4.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.22",
"@mantine/dates": "^6.0.22",
"@mantine/form": "^6.0.22",
"@mantine/hooks": "^6.0.22",
"@mantine/modals": "^6.0.22",
"@mantine/notifications": "^6.0.22",
"@mantine/utils": "^6.0.22",
"@mantine/colors-generator": "^8.1.1",
"@mantine/core": "^8.1.1",
"@mantine/dates": "^8.1.1",
"@mantine/form": "^8.1.1",
"@mantine/hooks": "^8.1.1",
"@mantine/modals": "^8.1.1",
"@mantine/notifications": "^8.1.1",
"@tanstack/react-query": "^4.32.1",
"@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1",
@@ -84,7 +90,6 @@
"electron-updater": "^6.3.9",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^11.0.0",
"fuse.js": "^6.6.2",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
@@ -93,43 +98,46 @@
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"motion": "^12.18.1",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.3",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars-react": "^0.5.6",
"qs": "^6.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-icons": "^5.5.0",
"react-image": "^4.1.0",
"react-loading-skeleton": "^3.5.0",
"react-player": "^2.11.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"semver": "^7.5.4",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"use-sync-external-store": "^1.5.0",
"ws": "^8.18.2",
"zod": "^3.22.3",
"zustand": "^4.3.9"
"zustand": "^5.0.5"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/electron-localshortcut": "^3.1.0",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^22.14.1",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/lodash": "^4.17.18",
"@types/md5": "^2.3.5",
"@types/node": "^22.15.32",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/source-map-support": "^0.5.10",
"@types/styled-components": "^5.1.26",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^7.1.0",
@@ -145,20 +153,15 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"i18next-parser": "^9.0.2",
"postcss-styled-syntax": "^0.5.0",
"postcss-preset-mantine": "^1.17.0",
"prettier": "^3.5.3",
"prettier-plugin-packagejson": "^2.5.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass-embedded": "^1.89.0",
"stylelint": "^15.10.3",
"stylelint-config-css-modules": "^4.3.0",
"stylelint-config-recess-order": "^4.3.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint": "^16.14.1",
"stylelint-config-css-modules": "^4.4.0",
"stylelint-config-recess-order": "^7.1.0",
"stylelint-config-standard": "^38.0.0",
"typescript": "^5.8.3",
"typescript-plugin-styled-components": "^3.0.0",
"vite": "^6.3.5",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
@@ -166,7 +169,9 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"abstract-socket",
"electron",
"electron-winstaller",
"esbuild"
]
},

2009
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

5
postcss.config.cjs Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
},
};

View File

@@ -28,6 +28,8 @@
"action_other": "actions",
"add": "add",
"additionalParticipants": "additional participants",
"newVersion": "a new version has been installed ({{version}})",
"viewReleaseNotes": "view release notes",
"albumGain": "album gain",
"albumPeak": "album peak",
"areYouSure": "are you sure?",
@@ -268,6 +270,7 @@
"title": "lyric search"
},
"queryEditor": {
"title": "query editor",
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
@@ -421,6 +424,7 @@
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"myLibrary": "my library",
"nowPlaying": "now playing",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
@@ -774,6 +778,8 @@
},
"view": {
"card": "card",
"grid": "grid",
"list": "list",
"poster": "poster",
"table": "table"
}

View File

@@ -1,7 +1,7 @@
import { MantineProvider } from '@mantine/core';
import { useEffect } from 'react';
import './styles/global.scss';
import './styles/global.css';
import { Shell } from '/@/remote/components/shell';
import { useIsDark, useReconnect } from '/@/remote/store';
@@ -16,8 +16,8 @@ export const App = () => {
return (
<MantineProvider
defaultColorScheme={isDark ? 'dark' : 'light'}
theme={{
colorScheme: isDark ? 'dark' : 'light',
components: {
AppShell: {
styles: {
@@ -30,13 +30,13 @@ export const App = () => {
Modal: {
styles: {
body: {
background: 'var(--modal-bg)',
background: 'var(--theme-modal-bg)',
height: '100vh',
},
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
background: 'var(--theme-modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
@@ -44,19 +44,8 @@ export const App = () => {
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontFamily: 'var(--theme-content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
@@ -65,8 +54,8 @@ export const App = () => {
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
fontFamily: 'var(--theme-content-font-family)',
fontWeight: '700',
},
other: {},
spacing: {
@@ -77,8 +66,6 @@ export const App = () => {
xs: '0rem',
},
}}
withGlobalStyles
withNormalizeCSS
>
<Shell />
</MantineProvider>

View File

@@ -12,7 +12,9 @@ export const ImageButton = () => {
mr={5}
onClick={() => toggleImage()}
size="xl"
tooltip={showImage ? 'Hide Image' : 'Show Image'}
tooltip={{
label: showImage ? 'Hide Image' : 'Show Image',
}}
variant="default"
>
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}

View File

@@ -9,11 +9,13 @@ export const ReconnectButton = () => {
return (
<RemoteButton
$active={!connected}
isActive={!connected}
mr={5}
onClick={() => reconnect()}
size="xl"
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
tooltip={{
label: connected ? 'Reconnect' : 'Not connected. Reconnect.',
}}
variant="default"
>
<RiRestartLine size={30} />

View File

@@ -0,0 +1,24 @@
.button {
svg {
display: flex;
fill: var(--theme-colors-foreground);
}
&:hover {
svg {
fill: var(--theme-colors-foreground);
}
}
}
.button.active {
svg {
fill: var(--primary-color);
}
&:hover {
svg {
fill: var(--primary-color) !important;
}
}
}

View File

@@ -1,53 +1,29 @@
import { Button, type ButtonProps as MantineButtonProps, Tooltip } from '@mantine/core';
import { forwardRef, MouseEvent, ReactNode, Ref } from 'react';
import styled from 'styled-components';
import clsx from 'clsx';
import { forwardRef, ReactNode, Ref } from 'react';
export interface ButtonProps extends StyledButtonProps {
tooltip: string;
}
import styles from './remote-button.module.css';
interface StyledButtonProps extends MantineButtonProps {
$active?: boolean;
import { Button, ButtonProps } from '/@/shared/components/button/button';
interface RemoteButtonProps extends ButtonProps {
children: ReactNode;
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
isActive?: boolean;
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) => {
export const RemoteButton = forwardRef<HTMLButtonElement, RemoteButtonProps>(
({ children, isActive, tooltip, ...props }, ref) => {
return (
<Tooltip
label={tooltip}
withinPortal
>
<StyledButton
<Button
className={clsx(styles.button, {
[styles.active]: isActive,
})}
tooltip={tooltip}
{...props}
ref={ref}
>
{children}
</StyledButton>
</Tooltip>
</Button>
);
},
);

View File

@@ -3,7 +3,7 @@ import { RiMoonLine, RiSunLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useIsDark, useToggleDark } from '/@/remote/store';
import { AppTheme } from '/@/shared/types/domain-types';
import { AppTheme } from '/@/shared/themes/app-theme-types';
export const ThemeButton = () => {
const isDark = useIsDark();
@@ -19,7 +19,9 @@ export const ThemeButton = () => {
mr={5}
onClick={() => toggleDark()}
size="xl"
tooltip="Toggle Theme"
tooltip={{
label: 'Toggle Theme',
}}
variant="default"
>
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}

View File

@@ -1,4 +1,4 @@
import { Group, Image, Rating, Text, Title, Tooltip } from '@mantine/core';
import { Image, Title } from '@mantine/core';
import formatDuration from 'format-duration';
import debounce from 'lodash/debounce';
import { useCallback } from 'react';
@@ -17,6 +17,10 @@ import {
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { Group } from '/@/shared/components/group/group';
import { Rating } from '/@/shared/components/rating/rating';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
export const RemoteContainer = () => {
@@ -44,7 +48,7 @@ export const RemoteContainer = () => {
<Title order={2}>Album: {song.album}</Title>
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Group position="apart">
<Group justify="space-between">
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
{song.releaseDate && (
<Title order={3}>
@@ -56,13 +60,15 @@ export const RemoteContainer = () => {
</>
)}
<Group
gap={0}
grow
spacing={0}
>
<RemoteButton
disabled={!id}
onClick={() => send({ event: 'previous' })}
tooltip="Previous track"
tooltip={{
label: 'Previous track',
}}
variant="default"
>
<RiSkipBackFill size={25} />
@@ -76,7 +82,9 @@ export const RemoteContainer = () => {
send({ event: 'play' });
}
}}
tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
tooltip={{
label: id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play',
}}
variant="default"
>
{id && status === PlayerStatus.PLAYING ? (
@@ -88,34 +96,40 @@ export const RemoteContainer = () => {
<RemoteButton
disabled={!id}
onClick={() => send({ event: 'next' })}
tooltip="Next track"
tooltip={{
label: 'Next track',
}}
variant="default"
>
<RiSkipForwardFill size={25} />
</RemoteButton>
</Group>
<Group
gap={0}
grow
spacing={0}
>
<RemoteButton
$active={shuffle || false}
isActive={shuffle || false}
onClick={() => send({ event: 'shuffle' })}
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
tooltip={{
label: shuffle ? 'Shuffle tracks' : 'Shuffle disabled',
}}
variant="default"
>
<RiShuffleFill size={25} />
</RemoteButton>
<RemoteButton
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
isActive={repeat !== undefined && repeat !== PlayerRepeat.NONE}
onClick={() => send({ event: 'repeat' })}
tooltip={`Repeat ${
tooltip={{
label: `Repeat ${
repeat === PlayerRepeat.ONE
? 'One'
: repeat === PlayerRepeat.ALL
? 'all'
: 'none'
}`}
}`,
}}
variant="default"
>
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
@@ -125,14 +139,16 @@ export const RemoteContainer = () => {
)}
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!id}
isActive={song?.userFavorite}
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
tooltip={{
label: song?.userFavorite ? 'Unfavorite' : 'Favorite',
}}
variant="default"
>
<RiHeartLine size={25} />
@@ -146,7 +162,7 @@ export const RemoteContainer = () => {
<Rating
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
sx={{ margin: 'auto' }}
style={{ margin: 'auto' }}
value={song.userRating ?? 0}
/>
</Tooltip>
@@ -169,8 +185,8 @@ export const RemoteContainer = () => {
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
rightLabel={
<Text
fw={600}
size="xs"
weight={600}
>
{volume ?? 0}
</Text>

View File

@@ -1,14 +1,4 @@
import {
AppShell,
Container,
Flex,
Grid,
Header,
Image,
MediaQuery,
Skeleton,
Title,
} from '@mantine/core';
import { AppShell, Container, Flex, Grid, Image, Skeleton, Title } from '@mantine/core';
import { ImageButton } from '/@/remote/components/buttons/image-button';
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
@@ -20,9 +10,8 @@ export const Shell = () => {
const connected = useConnected();
return (
<AppShell
header={
<Header height={60}>
<AppShell padding="md">
<AppShell.Header>
<Grid>
<Grid.Col span="auto">
<div>
@@ -34,17 +23,9 @@ export const Shell = () => {
/>
</div>
</Grid.Col>
<MediaQuery
smallerThan="sm"
styles={{ display: 'none' }}
>
<Grid.Col
sm={6}
xs={0}
>
<Grid.Col hiddenFrom="md">
<Title ta="center">Feishin Remote</Title>
</Grid.Col>
</MediaQuery>
<Grid.Col span="auto">
<Flex
@@ -57,10 +38,7 @@ export const Shell = () => {
</Flex>
</Grid.Col>
</Grid>
</Header>
}
padding="md"
>
</AppShell.Header>
<Container>
{connected ? (
<RemoteContainer />

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
width: 95%;
height: 20px;
margin: 10px 0;
}
.wrapper {
display: flex;
flex: 6;
align-items: center;
height: 100%;
}
.value-wrapper {
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
}

View File

@@ -1,40 +1,16 @@
import { rem, Slider, SliderProps } from '@mantine/core';
import { ReactNode, useState } from 'react';
import styled from 'styled-components';
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
margin: 10px 0;
`;
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
import styles from './wrapped-slider.module.css';
const PlayerbarSlider = ({ ...props }: SliderProps) => {
return (
<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,7 +35,6 @@ const PlayerbarSlider = ({ ...props }: SliderProps) => {
},
track: {
'&::before': {
backgroundColor: 'var(--playerbar-slider-track-bg)',
right: 'calc(0.1rem * -1)',
},
},
@@ -84,9 +59,9 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
const [seek, setSeek] = useState(0);
return (
<SliderContainer>
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
<SliderWrapper>
<div className={styles.container}>
{leftLabel && <div className={styles.valueWrapper}>{leftLabel}</div>}
<div className={styles.wrapper}>
<PlayerbarSlider
{...props}
min={0}
@@ -102,8 +77,8 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
value={!isSeeking ? (value ?? 0) : seek}
w="100%"
/>
</SliderWrapper>
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
</SliderContainer>
</div>
{rightLabel && <div className={styles.valueWrapper}>{rightLabel}</div>}
</div>
);
};

View File

@@ -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) => ({

View File

@@ -0,0 +1,112 @@
@import url('../../renderer/styles/ag-grid.css');
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body,
html {
position: absolute;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--theme-content-font-family);
font-size: var(--theme-root-font-size);
color: var(--theme-content-text-color);
user-select: none;
background: var(--theme-content-bg);
}
@media only screen and (width < 640px) {
body,
html {
overflow-x: auto;
}
}
#app {
height: inherit;
}
*,
*::before,
*::after {
box-sizing: border-box;
text-rendering: optimizelegibility;
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
text-size-adjust: none;
outline: none;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* ::-webkit-scrollbar-corner {
background: var(--theme-scrollbar-track-background);
}
::-webkit-scrollbar-track {
background: var(--theme-scrollbar-track-background);
}
::-webkit-scrollbar-thumb {
background: var(--theme-scrollbar-handle-background);
}
::-webkit-scrollbar-thumb:hover {
background: var(--theme-scrollbar-handle-hover-background);
} */
a {
text-decoration: none;
}
button {
-webkit-app-region: no-drag;
}
.overlay-scrollbar {
overflow: auto !important;
}
.hide-scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -2,17 +2,21 @@ import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-mod
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import isElectron from 'is-electron';
import { useEffect, useMemo, useRef, useState } from 'react';
import { initSimpleImg } from 'react-simple-img';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import './styles/global.scss';
import './styles/global.css';
import '@ag-grid-community/styles/ag-grid.css';
import 'overlayscrollbars/overlayscrollbars.css';
import './styles/overlayscrollbars.css';
import i18n from '/@/i18n/i18n';
import { toast } from '/@/renderer/components';
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
@@ -20,7 +24,6 @@ import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-co
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { useTheme } from '/@/renderer/hooks';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog';
import { AppRouter } from '/@/renderer/router/app-router';
@@ -34,71 +37,33 @@ import {
useRemoteSettings,
useSettingsStore,
} from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/shared/types/types';
import { toast } from '/@/shared/components/toast/toast';
import { PlaybackType, PlayerStatus, WebAudio } from '/@/shared/types/types';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
const theme = useTheme();
const accent = useSettingsStore((store) => store.general.accent);
const { mode, theme } = useAppTheme();
const language = useSettingsStore((store) => store.general.language);
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { content, enabled } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<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 +86,6 @@ export const App = () => {
return () => {};
}, [content, enabled]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--primary-color', accent);
}, [accent]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--image-fit', nativeImageAspect ? 'contain' : 'cover');
}, [nativeImageAspect]);
const providerValue = useMemo(() => {
return { handlePlayQueueAdd };
}, [handlePlayQueueAdd]);
@@ -237,59 +192,14 @@ export const App = () => {
return (
<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}>

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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);
}
}

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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>
);
})}
</>

View File

@@ -1,2 +0,0 @@
export * from './album-card';
export * from './card-rows';

View File

@@ -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;
}

View File

@@ -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)'}
className={styles.image}
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>
)}
<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
<div
className={styles.container}
key={`placeholder-${uniqueId}-${data.id}`}
>
<ImageContainerSkeleton />
</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}
key={`${index}-${row.arrayProperty}`}
radius="sm"
visible
/>
))}
</Stack>
</DetailContainer>
</PosterCardContainer>
</div>
</div>
);
};

View File

@@ -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}
/>
);
},
);

View File

@@ -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;
}

View File

@@ -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>
);
},
);

View File

@@ -1,127 +0,0 @@
import { Box, Flex, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
import { motion, Variants } from 'framer-motion';
import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react';
import styled from 'styled-components';
interface ContextMenuProps {
children: ReactNode;
maxWidth?: number;
minWidth?: number;
xPos: number;
yPos: number;
}
const ContextMenuContainer = styled(motion.div)<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 align="center">
<Flex>{leftIcon}</Flex>
<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>
);
},
);

View File

@@ -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 }}
/>
);
};

View File

@@ -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} />;
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>
);
},
);

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
</>
);
},

View File

@@ -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;

View File

@@ -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>
</>
);
};

View File

@@ -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;
}

View File

@@ -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>
</>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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>;
};

View File

@@ -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;

View File

@@ -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>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
p={0}
size="sm"
variant="subtle"
>
<RiMore2Line size={20} />
</Button>
/>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<ActionIcon
icon="ellipsisVertical"
size="sm"
style={{
padding: 0,
}}
variant="subtle"
/>
</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

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
},
);

View File

@@ -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];
}
}

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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} />;
};

View File

@@ -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} />;
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 }),
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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)}
>
<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%',
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
margin: controls.itemGap,
}}
>
<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>
);
};

View File

@@ -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;
}

View File

@@ -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"
{isFavorite ? <div className={styles.favoriteBanner} /> : null}
{isHovered && (
<div className={clsx(styles.gridCardControlsContainer)}>
<Button
classNames={{ root: styles.playButton }}
onClick={handlePlay}
variant="filled"
>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<Icon
icon="mediaPlay"
size="xl"
/>
</Button>
<div className={styles.bottomControls}>
{itemType !== LibraryItem.PLAYLIST && (
<SecondaryButton
<ActionIcon
classNames={{ root: styles.secondaryButton }}
icon={isFavorite ? 'favorite' : 'favorite'}
iconProps={{
fill: isFavorite ? 'primary' : undefined,
}}
onClick={(e) => handleFavorites(e, itemData?.serverId)}
p={5}
variant="subtle"
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
size="sm"
variant="transparent"
/>
)}
</FavoriteWrapper>
</SecondaryButton>
)}
<SecondaryButton
<ActionIcon
classNames={{ root: styles.secondaryButton }}
icon="ellipsisHorizontal"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
p={5}
variant="subtle"
>
<RiMoreFill
color="white"
size={20}
size="sm"
variant="transparent"
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
</div>
</div>
)}
</>
);
};

View File

@@ -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%;
}

View File

@@ -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}`}
>
<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%',
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
margin: controls.itemGap,
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
<div
className={styles.linkContainer}
onClick={() => navigate(path)}
>
<div
className={`${styles.imageContainer} ${data?.userFavorite ? styles.isFavorite : ''}`}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
</Center>
)}
<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>
);
};

View File

@@ -0,0 +1,9 @@
.virtual-grid-container {
display: flex;
flex-direction: column;
height: 100%;
}
.virtual-grid-auto-sizer-container {
flex: 1;
}

View File

@@ -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>;
};

View File

@@ -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>
);
};

View File

@@ -5,16 +5,16 @@ import React from 'react';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { Separator } from '/@/renderer/components/separator';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer $position="left">
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
@@ -24,9 +24,9 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
}
return (
<CellContainer $position="left">
<CellContainer position="left">
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>
@@ -35,9 +35,9 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
{index > 0 && <Separator />}
{item.id ? (
<Text
$link
$secondary
component={Link}
isLink
isMuted
overflow="hidden"
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
@@ -48,7 +48,7 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
</Text>
) : (
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>

View File

@@ -5,16 +5,16 @@ import React from 'react';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { Separator } from '/@/renderer/components/separator';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
export const ArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer $position="left">
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
@@ -24,9 +24,9 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
}
return (
<CellContainer $position="left">
<CellContainer position="left">
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>
@@ -35,9 +35,9 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
{index > 0 && <Separator />}
{item.id ? (
<Text
$link
$secondary
component={Link}
isLink
isMuted
overflow="hidden"
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
@@ -48,7 +48,7 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
</Text>
) : (
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>

View File

@@ -0,0 +1,35 @@
.play-button {
position: absolute;
width: 30px;
height: 30px;
background: var(--theme-colors-white);
border: none;
border-radius: 50%;
opacity: 0.7;
transition: scale 0.1s ease-in-out;
svg {
color: var(--theme-colors-black);
fill: var(--theme-colors-black);
}
&:hover {
background-color: var(--theme-colors-white);
opacity: 1;
}
&:active {
opacity: 1;
}
}
.list-controls-container {
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

View File

@@ -1,62 +1,21 @@
import type { UnstyledButtonProps } from '@mantine/core';
import clsx from 'clsx';
import React, { MouseEvent } from 'react';
import { RiPlayFill } from 'react-icons/ri';
import styled from 'styled-components';
import styles from './combined-title-cell-controls.module.css';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
type PlayButtonType = React.ComponentPropsWithoutRef<'button'> & UnstyledButtonProps;
const PlayButton = styled.button<PlayButtonType>`
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background-color: rgb(255 255 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: scale 0.1s ease-in-out;
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
svg {
fill: rgb(0 0 0);
stroke: rgb(0 0 0);
}
`;
const ListConverControlsContainer = styled.div`
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
`;
export const ListCoverControls = ({
className,
context,
itemData,
itemType,
uniqueId,
}: {
className?: string;
context: Record<string, any>;
itemData: any;
itemType: LibraryItem;
@@ -66,7 +25,7 @@ export const ListCoverControls = ({
const handlePlayQueueAdd = usePlayQueueAdd();
const isQueue = Boolean(context?.isQueue);
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
const handlePlay = async (e: React.MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
@@ -88,12 +47,12 @@ export const ListCoverControls = ({
};
return (
<>
<ListConverControlsContainer className="card-controls">
<PlayButton onClick={isQueue ? handlePlayFromQueue : handlePlay}>
<RiPlayFill size={20} />
</PlayButton>
</ListConverControlsContainer>
</>
<div className={clsx(styles.listControlsContainer, className)}>
<ActionIcon
classNames={{ root: styles.playButton }}
icon="mediaPlay"
onClick={isQueue ? handlePlayFromQueue : handlePlay}
/>
</div>
);
};

View File

@@ -0,0 +1,50 @@
.cell-container {
display: grid;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-auto-columns: 1fr;
gap: 0.5rem;
align-items: center;
justify-items: center;
width: 100%;
max-width: 100%;
height: 100%;
letter-spacing: 0.5px;
&:hover {
.play-button {
opacity: 1;
}
}
}
.play-button {
opacity: 0;
}
.image-wrapper {
position: relative;
display: flex;
grid-area: image;
align-items: center;
justify-content: center;
height: 100%;
}
.metadata-wrapper {
display: flex;
flex-direction: column;
grid-area: info;
justify-content: center;
width: 100%;
}
.metadata-wrapper > div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skeleton-metadata {
height: var(--theme-font-size-md);
}

View File

@@ -1,67 +1,19 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Center } from '@mantine/core';
import { motion } from 'framer-motion';
import React, { useMemo } from 'react';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import styles from './combined-title-cell.module.css';
import { ListCoverControls } from '/@/renderer/components/virtual-table/cells/combined-title-cell-controls';
import { AppRoute } from '/@/renderer/router/routes';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
import { AlbumArtist, Artist } from '/@/shared/types/domain-types';
const CellContainer = styled(motion.div)<{ height: number }>`
display: grid;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
grid-auto-columns: 1fr;
gap: 0.5rem;
width: 100%;
max-width: 100%;
height: 100%;
letter-spacing: 0.5px;
.card-controls {
opacity: 0;
}
&:hover {
.card-controls {
opacity: 1;
}
}
`;
const ImageWrapper = styled.div`
position: relative;
display: flex;
grid-area: image;
align-items: center;
justify-content: center;
height: 100%;
`;
const MetadataWrapper = styled.div`
display: flex;
flex-direction: column;
grid-area: info;
justify-content: center;
width: 100%;
`;
const StyledImage = styled(SimpleImg)`
img {
object-fit: var(--image-fit);
}
`;
export const CombinedTitleCell = ({
context,
data,
@@ -76,60 +28,55 @@ export const CombinedTitleCell = ({
if (value === undefined) {
return (
<CellContainer height={node.rowHeight || 40}>
<Skeleton>
<ImageWrapper />
</Skeleton>
<MetadataWrapper>
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton
height="1rem"
mt="0.5rem"
width="60%"
/>
</MetadataWrapper>
</CellContainer>
);
}
return (
<CellContainer height={node.rowHeight || 40}>
<ImageWrapper>
{value.imageUrl ? (
<StyledImage
alt="cover"
height={(node.rowHeight || 40) - 10}
placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={value.imageUrl}
style={{}}
width={(node.rowHeight || 40) - 10}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
<div
className={styles.cellContainer}
style={{ gridTemplateColumns: `${node.rowHeight || 40}px minmax(0, 1fr)` }}
>
<div
className={styles.imageWrapper}
style={{
height: `${(node.rowHeight || 40) - 10}px`,
width: `${(node.rowHeight || 40) - 10}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
<Skeleton className={styles.image} />
</div>
<Skeleton
className={styles.skeletonMetadata}
height="1rem"
width="80%"
/>
</Center>
)}
</div>
);
}
return (
<div
className={styles.cellContainer}
style={{ gridTemplateColumns: `${node.rowHeight || 40}px minmax(0, 1fr)` }}
>
<div
className={styles.imageWrapper}
style={{
height: `${(node.rowHeight || 40) - 10}px`,
width: `${(node.rowHeight || 40) - 10}px`,
}}
>
<Image
alt="cover"
className={styles.image}
src={value.imageUrl}
/>
<ListCoverControls
className={styles.playButton}
context={context}
itemData={value}
itemType={context.itemType}
uniqueId={data?.uniqueId}
/>
</ImageWrapper>
<MetadataWrapper>
</div>
<div className={styles.metadataWrapper}>
<Text
className="current-song-child"
overflow="hidden"
@@ -138,7 +85,7 @@ export const CombinedTitleCell = ({
{value.name}
</Text>
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>
@@ -148,12 +95,12 @@ export const CombinedTitleCell = ({
{index > 0 ? SEPARATOR_STRING : null}
{artist.id ? (
<Text
$link
$secondary
component={Link}
isLink
isMuted
overflow="hidden"
size="md"
sx={{ width: 'fit-content' }}
style={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
@@ -162,10 +109,10 @@ export const CombinedTitleCell = ({
</Text>
) : (
<Text
$secondary
isMuted
overflow="hidden"
size="md"
sx={{ width: 'fit-content' }}
style={{ width: 'fit-content' }}
>
{artist.name}
</Text>
@@ -173,10 +120,10 @@ export const CombinedTitleCell = ({
</React.Fragment>
))
) : (
<Text $secondary></Text>
<Text isMuted></Text>
)}
</Text>
</MetadataWrapper>
</CellContainer>
</div>
</div>
);
};

View File

@@ -1,10 +1,8 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { RiHeartFill, RiHeartLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const FavoriteCell = ({ data, node, value }: ICellRendererParams) => {
const createMutation = useCreateFavorite({});
@@ -47,21 +45,16 @@ export const FavoriteCell = ({ data, node, value }: ICellRendererParams) => {
};
return (
<CellContainer $position="center">
<Button
compact
onClick={handleToggleFavorite}
sx={{
svg: {
fill: !value
? 'var(--main-fg-secondary) !important'
: 'var(--primary-color) !important',
},
<CellContainer position="center">
<ActionIcon
icon="favorite"
iconProps={{
fill: !value ? undefined : 'primary',
}}
onClick={handleToggleFavorite}
size="sm"
variant="subtle"
>
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
</Button>
/>
</CellContainer>
);
};

View File

@@ -0,0 +1,6 @@
.container {
display: flex;
height: 100%;
padding: 0.5rem 1rem;
border: 1px solid transparent;
}

View File

@@ -1,19 +1,12 @@
import { ICellRendererParams } from '@ag-grid-community/core';
import { Group } from '@mantine/core';
import { useState } from 'react';
import { RiCheckboxBlankLine, RiCheckboxLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '/@/renderer/components/button';
import { Paper } from '/@/renderer/components/paper';
import styles from './full-width-disc-cell.module.css';
import { getNodesByDiscNumber, setNodeSelection } from '/@/renderer/components/virtual-table/utils';
const Container = styled(Paper)`
display: flex;
height: 100%;
padding: 0.5rem 1rem;
border: 1px solid transparent;
`;
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => {
const [isSelected, setIsSelected] = useState(false);
@@ -31,21 +24,20 @@ export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => {
};
return (
<Container>
<div className={styles.container}>
<Group
position="apart"
justify="space-between"
w="100%"
>
<Button
compact
leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />}
leftSection={isSelected ? <Icon icon="squareCheck" /> : <Icon icon="square" />}
onClick={handleToggleDiscNodes}
size="md"
size="compact-md"
variant="subtle"
>
{data.name}
</Button>
</Group>
</Container>
</div>
);
};

View File

@@ -0,0 +1,19 @@
.cell-container {
display: flex;
align-items: center;
width: 100%;
height: 100%;
letter-spacing: 0.5px;
}
.cell-container.right {
justify-content: flex-end;
}
.cell-container.center {
justify-content: center;
}
.cell-container.left {
justify-content: flex-start;
}

View File

@@ -1,24 +1,12 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import clsx from 'clsx';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import styles from './generic-cell.module.css';
export const CellContainer = styled.div<{ $position?: 'center' | 'left' | 'right' }>`
display: flex;
align-items: center;
justify-content: ${(props) =>
props.$position === 'right'
? 'flex-end'
: props.$position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
height: 100%;
letter-spacing: 0.5px;
`;
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
type Options = {
array?: boolean;
@@ -36,7 +24,7 @@ export const GenericCell = (
if (value === undefined) {
return (
<CellContainer $position={position || 'left'}>
<CellContainer position={position || 'left'}>
<Skeleton
height="1rem"
width="80%"
@@ -46,12 +34,12 @@ export const GenericCell = (
}
return (
<CellContainer $position={position || 'left'}>
<CellContainer position={position || 'left'}>
{isLink ? (
<Text
$link={isLink}
$secondary={!primary}
component={Link}
isLink={isLink}
isMuted={!primary}
overflow="hidden"
size="md"
to={displayedValue.link}
@@ -60,8 +48,8 @@ export const GenericCell = (
</Text>
) : (
<Text
$noSelect={false}
$secondary={!primary}
isMuted={!primary}
isNoSelect={false}
overflow="hidden"
size="md"
>
@@ -71,3 +59,24 @@ export const GenericCell = (
</CellContainer>
);
};
export const CellContainer = ({
children,
position,
}: {
children: React.ReactNode;
position: 'center' | 'left' | 'right';
}) => {
return (
<div
className={clsx({
[styles.cellContainer]: true,
[styles.center]: position === 'center',
[styles.left]: position === 'left' || !position,
[styles.right]: position === 'right',
})}
>
{children}
</div>
);
};

View File

@@ -4,17 +4,17 @@ import type { ICellRendererParams } from '@ag-grid-community/core';
import React from 'react';
import { generatePath, Link } from 'react-router-dom';
import { Separator } from '/@/renderer/components/separator';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
export const GenreCell = ({ data, value }: ICellRendererParams) => {
const genrePath = useGenreRoute();
return (
<CellContainer $position="left">
<CellContainer position="left">
<Text
$secondary
isMuted
overflow="hidden"
size="md"
>
@@ -22,9 +22,9 @@ export const GenreCell = ({ data, value }: ICellRendererParams) => {
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
<Text
$link
$secondary
component={Link}
isLink
isMuted
overflow="hidden"
size="md"
to={generatePath(genrePath, { genreId: item.id })}

View File

@@ -2,10 +2,10 @@ import type { ICellRendererParams } from '@ag-grid-community/core';
import { useMemo } from 'react';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
export const NoteCell = ({ value }: ICellRendererParams) => {
const formattedValue = useMemo(() => {
@@ -18,7 +18,7 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer $position="left">
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
@@ -28,9 +28,9 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
}
return (
<CellContainer $position="left">
<CellContainer position="left">
<Text
$secondary
isMuted
overflow="hidden"
>
{formattedValue}

View File

@@ -1,8 +1,8 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Rating } from '/@/renderer/components/rating';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useSetRating } from '/@/renderer/features/shared';
import { Rating } from '/@/shared/components/rating/rating';
export const RatingCell = ({ node, value }: ICellRendererParams) => {
const updateRatingMutation = useSetRating({});
@@ -25,7 +25,7 @@ export const RatingCell = ({ node, value }: ICellRendererParams) => {
};
return (
<CellContainer $position="center">
<CellContainer position="center">
<Rating
onChange={handleUpdateRating}
size="xs"

View File

@@ -1,9 +1,8 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { RiPlayFill } from 'react-icons/ri';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
// const AnimatedSvg = () => {
// return (
@@ -14,7 +13,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
// >
// <g>
// <rect
// fill="var(--primary-color)"
// fill="var(--theme-colors-primary-filled)"
// height="80"
// id="bar-1"
// width="12"
@@ -33,7 +32,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
// />
// </rect>
// <rect
// fill="var(--primary-color)"
// fill="var(--theme-colors-primary-filled)"
// height="80"
// id="bar-2"
// width="12"
@@ -52,7 +51,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
// />
// </rect>
// <rect
// fill="var(--primary-color)"
// fill="var(--theme-colors-primary-filled)"
// height="80"
// id="bar-3"
// width="12"
@@ -71,7 +70,7 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
// />
// </rect>
// <rect
// fill="var(--primary-color)"
// fill="var(--theme-colors-primary-filled)"
// height="80"
// id="bar-4"
// width="12"
@@ -143,23 +142,28 @@ export const RowIndexCell = ({ eGridCell, value }: ICellRendererParams) => {
classList.contains('current-song-cell') || classList.contains('current-playlist-song-cell');
return (
<CellContainer $position="right">
{isPlaying &&
(isCurrentSong ? (
<RiPlayFill
color="var(--primary-color)"
size="1.2rem"
<CellContainer position="right">
{isPlaying && isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPlay"
/>
) : null)}
) : isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPause"
/>
) : (
<Text
$secondary
align="right"
className="current-song-child current-song-index"
isMuted
overflow="hidden"
size="md"
style={{ textAlign: 'right' }}
>
{value}
</Text>
)}
</CellContainer>
);
};

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