Files
feishin/src/renderer/features/albums/components/joined-artists.tsx
T
2025-12-30 03:39:53 -08:00

158 lines
4.8 KiB
TypeScript

import { Fragment } from 'react';
import { generatePath, Link } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { Text, TextProps } from '/@/shared/components/text/text';
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
interface JoinedArtistsProps {
artistName: string;
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
}
export const JoinedArtists = ({
artistName,
artists,
linkProps,
rootTextProps,
}: JoinedArtistsProps) => {
const parts: (
| string
| {
artist: AlbumArtist | RelatedAlbumArtist | RelatedArtist;
end: number;
start: number;
text: string;
}
)[] = [];
const matches: Array<{
artist: AlbumArtist | RelatedAlbumArtist | RelatedArtist;
end: number;
name: string;
start: number;
}> = [];
for (const artist of artists) {
const name = artist.name;
const regex = new RegExp(escapeRegex(name), 'gi');
let match: null | RegExpExecArray = null;
while ((match = regex.exec(artistName)) !== null) {
matches.push({
artist,
end: match.index + match[0].length,
name: match[0],
start: match.index,
});
}
}
matches.sort((a, b) => {
const lengthDiff = b.end - b.start - (a.end - a.start);
if (lengthDiff !== 0) return lengthDiff;
return a.start - b.start;
});
const nonOverlappingMatches: typeof matches = [];
for (const match of matches) {
const overlaps = nonOverlappingMatches.some(
(existing) =>
(match.start >= existing.start && match.start < existing.end) ||
(match.end > existing.start && match.end <= existing.end) ||
(match.start <= existing.start && match.end >= existing.end),
);
if (!overlaps) {
nonOverlappingMatches.push(match);
}
}
nonOverlappingMatches.sort((a, b) => a.start - b.start);
let lastIndex = 0;
for (const match of nonOverlappingMatches) {
if (match.start > lastIndex) {
parts.push(artistName.substring(lastIndex, match.start));
}
parts.push({
artist: match.artist,
end: match.end,
start: match.start,
text: match.name,
});
lastIndex = match.end;
}
if (lastIndex < artistName?.length) {
parts.push(artistName.substring(lastIndex));
}
const hasArtistMatches = parts.some((part) => typeof part !== 'string');
// If no matches found and there are album artists, return the album artists
if (!hasArtistMatches && artists.length > 0) {
return (
<Text component="span" {...rootTextProps}>
{artists.map((artist, index) => (
<Fragment key={artist.id}>
{index > 0 && ', '}
<Text
component={Link}
fw={600}
isLink
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
{...linkProps}
>
{artist.name}
</Text>
</Fragment>
))}
</Text>
);
}
// If no matches found and no albumArtists, return the original string
if (!hasArtistMatches) {
return (
<Text fw={400} isNoSelect {...rootTextProps}>
{artistName}
</Text>
);
}
return (
<Text component="span" fw={400} {...rootTextProps}>
{parts.map((part, index) => {
if (typeof part === 'string') {
return <Fragment key={index}>{part}</Fragment>;
}
const { artist, text } = part;
return (
<Text
component={Link}
fw={600}
isLink
key={`${artist.id}-${index}`}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
{...linkProps}
>
{text}
</Text>
);
})}
</Text>
);
};
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}