mirror of
https://github.com/Radiquum/AniX.git
synced 2025-04-19 16:04:41 +00:00
feat: add a menu for selecting voiceover, source and episode inside of a player
This commit is contained in:
parent
0a5b8a59e6
commit
5264534693
5 changed files with 266 additions and 142 deletions
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ENDPOINTS } from "#/api/config";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { _fetchAPI } from "./PlayerParsing";
|
||||||
|
|
||||||
|
import { Voiceover } from "./VoiceoverSelectorMenu";
|
||||||
|
import { Source } from "./SourceSelectorMenu";
|
||||||
|
import { getAnonEpisodesWatched } from "./ReleasePlayer";
|
||||||
|
|
||||||
|
export interface Episode {
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
is_watched: boolean;
|
||||||
|
}
|
||||||
|
interface EpisodeSelectorMenuProps {
|
||||||
|
release_id: number;
|
||||||
|
voiceover: Voiceover;
|
||||||
|
source: Source;
|
||||||
|
token: string | null;
|
||||||
|
setEpisode: (state) => void;
|
||||||
|
episode: Episode;
|
||||||
|
episodeList: Episode[];
|
||||||
|
setPlayerError: (state) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EpisodeSelectorMenu = ({
|
||||||
|
release_id,
|
||||||
|
token,
|
||||||
|
voiceover,
|
||||||
|
source,
|
||||||
|
setEpisode,
|
||||||
|
episode,
|
||||||
|
episodeList,
|
||||||
|
setPlayerError,
|
||||||
|
}: EpisodeSelectorMenuProps) => {
|
||||||
|
const [watchedEpisodes, setWatchedEpisodes] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const __getInfo = async () => {
|
||||||
|
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover.id}/${source.id}`;
|
||||||
|
if (token) {
|
||||||
|
url += `?token=${token}`;
|
||||||
|
}
|
||||||
|
const episodes = await _fetchAPI(
|
||||||
|
url,
|
||||||
|
"Не удалось получить информацию о эпизодах",
|
||||||
|
setPlayerError
|
||||||
|
);
|
||||||
|
if (episodes) {
|
||||||
|
let anonEpisodesWatched = getAnonEpisodesWatched(
|
||||||
|
release_id,
|
||||||
|
source.id,
|
||||||
|
voiceover.id
|
||||||
|
);
|
||||||
|
let lastEpisodeWatched = Math.max.apply(
|
||||||
|
0,
|
||||||
|
Object.keys(anonEpisodesWatched[release_id][source.id][voiceover.id])
|
||||||
|
);
|
||||||
|
let selectedEpisode =
|
||||||
|
episodes.episodes.find(
|
||||||
|
(episode: Episode) => episode.position == lastEpisodeWatched
|
||||||
|
) || episodes.episodes[0];
|
||||||
|
|
||||||
|
setEpisode({
|
||||||
|
selected: selectedEpisode,
|
||||||
|
available: episodes.episodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (source) {
|
||||||
|
__getInfo();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (release_id && source && voiceover) {
|
||||||
|
const anonEpisodesWatched = getAnonEpisodesWatched(
|
||||||
|
release_id,
|
||||||
|
source.id,
|
||||||
|
voiceover.id
|
||||||
|
);
|
||||||
|
setWatchedEpisodes(
|
||||||
|
anonEpisodesWatched[release_id][source.id][voiceover.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [release_id, source, voiceover]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start justify-start gap-4">
|
||||||
|
<p className="text-[20px] px-2 pt-2 pb-1 font-bold">Эпизод</p>
|
||||||
|
<div className="max-h-full flex flex-col gap-4 items-start justify-start overflow-x-hidden overflow-y-auto px-2 pb-2 scrollbar-thin scrollbar-thumb-[rgb(60_60_60_/_.8)] scrollbar-track-[rgb(30_30_30_/_.8)]">
|
||||||
|
{episodeList && episodeList.length > 0 ?
|
||||||
|
episodeList.map((epis: Episode) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`release-${release_id}-voiceover-${voiceover.id}-source-${source.id}-episode-${epis.position}`}
|
||||||
|
className={`h-fit px-2 justify-start items-start ${episode.position == epis.position ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
|
||||||
|
onClick={() => {
|
||||||
|
setEpisode({
|
||||||
|
selected: epis,
|
||||||
|
available: episodeList,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 min-w-32">
|
||||||
|
<p className="text-[16px] leading-none whitespace-nowrap">
|
||||||
|
{epis.name ?
|
||||||
|
epis.name
|
||||||
|
: ["Sibnet"].includes(source.name) ?
|
||||||
|
`${epis.position + 1} Серия`
|
||||||
|
: `${epis.position} Серия`}
|
||||||
|
</p>
|
||||||
|
{(
|
||||||
|
epis.is_watched ||
|
||||||
|
Object.keys(watchedEpisodes).includes(
|
||||||
|
epis.position.toString()
|
||||||
|
)
|
||||||
|
) ?
|
||||||
|
<span className="w-4 h-4 ml-2 iconify material-symbols--check-circle"></span>
|
||||||
|
: <span className="w-4 h-4 ml-2 iconify material-symbols--check-circle-outline"></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
--media-primary-color: #fff;
|
--media-primary-color: #fff;
|
||||||
--media-secondary-color: transparent;
|
--media-secondary-color: transparent;
|
||||||
--media-menu-background: rgba(28, 28, 28, 0.6);
|
--media-menu-background: rgba(28, 28, 28, 0.8);
|
||||||
--media-text-color: var(--_primary-color);
|
--media-text-color: var(--_primary-color);
|
||||||
--media-control-hover-background: var(--media-secondary-color);
|
--media-control-hover-background: var(--media-secondary-color);
|
||||||
|
|
||||||
|
@ -116,11 +116,12 @@
|
||||||
--media-settings-menu-min-width: calc(10 * var(--base));
|
--media-settings-menu-min-width: calc(10 * var(--base));
|
||||||
--media-menu-transform-in: translateY(0) scale(1);
|
--media-menu-transform-in: translateY(0) scale(1);
|
||||||
--media-menu-transform-out: translateY(20px) rotate(3deg) scale(1);
|
--media-menu-transform-out: translateY(20px) rotate(3deg) scale(1);
|
||||||
background: rgb(30 30 30 / .6);
|
background: rgba(28, 28, 28, 0.8);
|
||||||
min-width: var(--media-settings-menu-min-width, 170px);
|
min-width: var(--media-settings-menu-min-width, 170px);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: calc(3 * var(--base));
|
bottom: calc(3 * var(--base));
|
||||||
|
padding: 0;
|
||||||
padding-block: calc(0.15 * var(--base));
|
padding-block: calc(0.15 * var(--base));
|
||||||
padding-inline: calc(0.6 * var(--base));
|
padding-inline: calc(0.6 * var(--base));
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
@ -131,6 +132,10 @@
|
||||||
max-height: 50%;
|
max-height: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-controller media-chrome-dialog > div {
|
||||||
|
word-wrap: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
.media-settings-menu[hidden] {
|
.media-settings-menu[hidden] {
|
||||||
display: block;
|
display: block;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
|
|
@ -43,6 +43,12 @@ import {
|
||||||
} from "media-chrome/react/menu";
|
} from "media-chrome/react/menu";
|
||||||
import { VoiceoverSelectorMenu } from "./VoiceoverSelectorMenu";
|
import { VoiceoverSelectorMenu } from "./VoiceoverSelectorMenu";
|
||||||
import { SourceSelectorMenu } from "./SourceSelectorMenu";
|
import { SourceSelectorMenu } from "./SourceSelectorMenu";
|
||||||
|
import {
|
||||||
|
_fetchAnilibriaManifest,
|
||||||
|
_fetchKodikManifest,
|
||||||
|
_fetchSibnetManifest,
|
||||||
|
} from "./PlayerParsing";
|
||||||
|
import { EpisodeSelectorMenu } from "./EpisodeSelectorMenu";
|
||||||
|
|
||||||
export const ReleasePlayerCustom = (props: {
|
export const ReleasePlayerCustom = (props: {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -60,120 +66,87 @@ export const ReleasePlayerCustom = (props: {
|
||||||
selected: null,
|
selected: null,
|
||||||
available: null,
|
available: null,
|
||||||
});
|
});
|
||||||
const [playerProps, SetPlayerProps] = useState({
|
const [playerProps, SetPlayerProps] = useState<{
|
||||||
|
src: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
type: "hls" | "mp4" | null;
|
||||||
|
}>({
|
||||||
src: null,
|
src: null,
|
||||||
poster: null,
|
poster: null,
|
||||||
type: null,
|
type: null,
|
||||||
useCustom: false,
|
|
||||||
});
|
});
|
||||||
const [playerError, setPlayerError] = useState(null);
|
const [playerError, setPlayerError] = useState(null);
|
||||||
// const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
// const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false);
|
// const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false);
|
||||||
// const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// const __getInfo = async () => {
|
const __getInfo = async () => {
|
||||||
// let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`;
|
if (source.selected.name == "Kodik") {
|
||||||
// if (props.token) {
|
const { manifest, poster } = await _fetchKodikManifest(
|
||||||
// url += `?token=${props.token}`;
|
episode.selected.url,
|
||||||
// }
|
setPlayerError
|
||||||
// const episodes = await _fetchAPI(
|
);
|
||||||
// url,
|
if (manifest) {
|
||||||
// "Не удалось получить информацию о эпизодах"
|
SetPlayerProps({
|
||||||
// );
|
src: manifest,
|
||||||
// if (episodes) {
|
poster: poster,
|
||||||
// let anonEpisodesWatched = getAnonEpisodesWatched(
|
type: "hls",
|
||||||
// props.id,
|
});
|
||||||
// source.selected.id,
|
setIsLoading(false);
|
||||||
// voiceover.selected.id
|
}
|
||||||
// );
|
return;
|
||||||
// let lastEpisodeWatched = Math.max.apply(
|
}
|
||||||
// 0,
|
if (source.selected.name == "Libria") {
|
||||||
// Object.keys(
|
const { manifest, poster } = await _fetchAnilibriaManifest(
|
||||||
// anonEpisodesWatched[props.id][source.selected.id][
|
episode.selected.url,
|
||||||
// voiceover.selected.id
|
setPlayerError
|
||||||
// ]
|
);
|
||||||
// )
|
if (manifest) {
|
||||||
// );
|
SetPlayerProps({
|
||||||
// let selectedEpisode =
|
src: manifest,
|
||||||
// episodes.episodes.find(
|
poster: poster,
|
||||||
// (episode: any) => episode.position == lastEpisodeWatched
|
type: "hls",
|
||||||
// ) || episodes.episodes[0];
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.selected.name == "Sibnet") {
|
||||||
|
const { manifest, poster } = await _fetchSibnetManifest(
|
||||||
|
episode.selected.url,
|
||||||
|
setPlayerError
|
||||||
|
);
|
||||||
|
if (manifest) {
|
||||||
|
SetPlayerProps({
|
||||||
|
src: manifest,
|
||||||
|
poster: poster,
|
||||||
|
type: "mp4",
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SetPlayerProps({
|
||||||
|
src: episode.selected.url,
|
||||||
|
poster: null,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
if (episode.selected) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setPlayerError(null);
|
||||||
|
__getInfo();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [episode.selected]);
|
||||||
|
|
||||||
// setEpisode({
|
useEffect(() => {
|
||||||
// selected: selectedEpisode,
|
if (document && document.querySelector("media-chrome-dialog")) {
|
||||||
// available: episodes.episodes,
|
document.querySelector("media-chrome-dialog").shadowRoot.querySelector("slot").style.width = "100%";
|
||||||
// });
|
}
|
||||||
// }
|
}, [])
|
||||||
// };
|
|
||||||
// if (source.selected) {
|
|
||||||
// __getInfo();
|
|
||||||
// }
|
|
||||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
// }, [source.selected]);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// const __getInfo = async () => {
|
|
||||||
// if (source.selected.name == "Kodik") {
|
|
||||||
// const { manifest, poster } = await _fetchKodikManifest(
|
|
||||||
// episode.selected.url
|
|
||||||
// );
|
|
||||||
// if (manifest) {
|
|
||||||
// SetPlayerProps({
|
|
||||||
// src: manifest,
|
|
||||||
// poster: poster,
|
|
||||||
// useCustom: true,
|
|
||||||
// type: "hls",
|
|
||||||
// });
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// if (source.selected.name == "Libria") {
|
|
||||||
// const { manifest, poster } = await _fetchAnilibriaManifest(
|
|
||||||
// episode.selected.url
|
|
||||||
// );
|
|
||||||
// if (manifest) {
|
|
||||||
// SetPlayerProps({
|
|
||||||
// src: manifest,
|
|
||||||
// poster: poster,
|
|
||||||
// useCustom: true,
|
|
||||||
// type: "hls",
|
|
||||||
// });
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// if (source.selected.name == "Sibnet") {
|
|
||||||
// const { manifest, poster } = await _fetchSibnetManifest(
|
|
||||||
// episode.selected.url
|
|
||||||
// );
|
|
||||||
// if (manifest) {
|
|
||||||
// SetPlayerProps({
|
|
||||||
// src: manifest,
|
|
||||||
// poster: poster,
|
|
||||||
// useCustom: true,
|
|
||||||
// type: "mp4",
|
|
||||||
// });
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// SetPlayerProps({
|
|
||||||
// src: episode.selected.url,
|
|
||||||
// poster: null,
|
|
||||||
// useCustom: false,
|
|
||||||
// type: null,
|
|
||||||
// });
|
|
||||||
// setIsLoading(false);
|
|
||||||
// };
|
|
||||||
// if (episode.selected) {
|
|
||||||
// setIsLoading(true);
|
|
||||||
// setPlayerError(null);
|
|
||||||
// __getInfo();
|
|
||||||
// }
|
|
||||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
// }, [episode.selected]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
|
@ -255,35 +228,40 @@ export const ReleasePlayerCustom = (props: {
|
||||||
defaultStreamType="on-demand"
|
defaultStreamType="on-demand"
|
||||||
className={`relative w-full overflow-hidden ${Styles["media-controller"]}`}
|
className={`relative w-full overflow-hidden ${Styles["media-controller"]}`}
|
||||||
>
|
>
|
||||||
{/* <div className="absolute flex flex-wrap w-full gap-2 top-2 left-2">
|
{playerProps.type == "hls" && (
|
||||||
{voiceover.selected && (
|
<HlsVideo
|
||||||
<VoiceoverSelector
|
className="object-contain h-full aspect-video"
|
||||||
availableVoiceover={voiceover.available}
|
slot="media"
|
||||||
voiceover={voiceover.selected}
|
src={playerProps.src}
|
||||||
setVoiceover={setVoiceover}
|
poster={playerProps.poster}
|
||||||
release_id={props.id}
|
defaultPlaybackRate={playbackRate}
|
||||||
/>
|
onRateChange={(e) => {
|
||||||
)}
|
// @ts-ignore
|
||||||
{source.selected && (
|
setPlaybackRate(e.target.playbackRate || 1);
|
||||||
<SourceSelector
|
}}
|
||||||
availableSource={source.available}
|
/>
|
||||||
source={source.selected}
|
)}
|
||||||
setSource={setSource}
|
{playerProps.type == "mp4" && (
|
||||||
release_id={props.id}
|
<VideoJS
|
||||||
/>
|
className="object-contain h-full aspect-video"
|
||||||
)}
|
slot="media"
|
||||||
</div> */}
|
src={playerProps.src}
|
||||||
<HlsVideo
|
poster={playerProps.poster}
|
||||||
className="object-contain h-full aspect-video"
|
defaultPlaybackRate={playbackRate}
|
||||||
slot="media"
|
onRateChange={(e) => {
|
||||||
src={playerProps.src}
|
// @ts-ignore
|
||||||
poster={playerProps.poster}
|
setPlaybackRate(e.target.playbackRate || 1);
|
||||||
// defaultPlaybackRate={playbackRate}
|
}}
|
||||||
// onRateChange={(e) => {
|
></VideoJS>
|
||||||
// // @ts-ignore
|
)}
|
||||||
// setPlaybackRate(e.target.playbackRate || 1);
|
{(playerProps.type == null || playerProps.src == null) && (
|
||||||
// }}
|
<VideoJS
|
||||||
/>
|
src={null}
|
||||||
|
slot="media"
|
||||||
|
poster="https://wallpapers.com/images/featured/cute-red-panda-pictures-sererbq0fdjum7rn.jpg"
|
||||||
|
className="object-contain h-full aspect-video"
|
||||||
|
></VideoJS>
|
||||||
|
)}
|
||||||
<div className={`${Styles["media-gradient-bottom"]}`}></div>
|
<div className={`${Styles["media-gradient-bottom"]}`}></div>
|
||||||
<MediaSettingsMenu
|
<MediaSettingsMenu
|
||||||
id="settings"
|
id="settings"
|
||||||
|
@ -333,7 +311,17 @@ export const ReleasePlayerCustom = (props: {
|
||||||
sourceList={source.available}
|
sourceList={source.available}
|
||||||
setSource={setSource}
|
setSource={setSource}
|
||||||
setPlayerError={setPlayerError}
|
setPlayerError={setPlayerError}
|
||||||
/>
|
/>
|
||||||
|
<EpisodeSelectorMenu
|
||||||
|
release_id={props.id}
|
||||||
|
token={props.token}
|
||||||
|
voiceover={voiceover.selected}
|
||||||
|
source={source.selected}
|
||||||
|
episode={episode.selected}
|
||||||
|
episodeList={episode.available}
|
||||||
|
setEpisode={setEpisode}
|
||||||
|
setPlayerError={setPlayerError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MediaChromeDialog>
|
</MediaChromeDialog>
|
||||||
<MediaControlBar className={`${Styles["media-control-bar"]}`}>
|
<MediaControlBar className={`${Styles["media-control-bar"]}`}>
|
||||||
|
|
|
@ -77,7 +77,7 @@ export const SourceSelectorMenu = ({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`release-${release_id}-voiceover-${voiceover.id}-source-${src.id}`}
|
key={`release-${release_id}-voiceover-${voiceover.id}-source-${src.id}`}
|
||||||
className={`h-fit justify-start items-start ${source.id == src.id ? "text-white" : "text-gray-500 hover:text-gray-300"} transition-colors`}
|
className={`h-fit ${source.id == src.id ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSource({
|
setSource({
|
||||||
selected: src,
|
selected: src,
|
||||||
|
|
|
@ -69,7 +69,7 @@ export const VoiceoverSelectorMenu = ({
|
||||||
voiceoverList.map((vo: Voiceover) => {
|
voiceoverList.map((vo: Voiceover) => {
|
||||||
return (
|
return (
|
||||||
<button key={`release-${release_id}-voiceover-${vo.id}`}
|
<button key={`release-${release_id}-voiceover-${vo.id}`}
|
||||||
className={`h-fit justify-start items-start ${voiceover.id == vo.id ? "text-white" : "text-gray-500 hover:text-gray-300"} transition-colors`}
|
className={`h-fit px-2 ${voiceover.id == vo.id ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVoiceover({
|
setVoiceover({
|
||||||
selected: vo,
|
selected: vo,
|
||||||
|
@ -86,9 +86,9 @@ export const VoiceoverSelectorMenu = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{vo.icon ? <img alt="" className="w-6 h-6 rounded-full" src={vo.icon}></img> : ""}
|
{vo.icon ? <img alt="" className="w-6 h-6 rounded-full" src={vo.icon}></img> : ""}
|
||||||
<span className="text-[16px] leading-none">{vo.name}</span>
|
<span className="text-[16px] leading-none whitespace-nowrap">{vo.name}</span>
|
||||||
{vo.pinned && (
|
{vo.pinned && (
|
||||||
<span className={`h-4 iconify material-symbols--push-pin ${voiceover.id == vo.id ? "bg-white" : "bg-gray-500 hover:bg-gray-300"} transition-colors`}></span>
|
<span className={`h-4 iconify material-symbols--push-pin ${voiceover.id == vo.id ? "bg-white" : "bg-gray-400 hover:bg-gray-100"} transition-colors`}></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
Loading…
Add table
Reference in a new issue