feat(web): migrate react-table to v8 (#1866)

* feat(web): migrate react-table to v8

* chore(web): cleanup

* chore(web): fix types

* chore(web): ignore unused

* chore(web): fix types ActivityTable.tsx

* chore(web): remove console log
This commit is contained in:
ze0s 2024-12-08 16:50:01 +01:00 committed by GitHub
parent 172fa651af
commit ec85d53d8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 440 additions and 540 deletions

View file

@ -41,10 +41,10 @@
"@tanstack/react-query": "^5.61.4",
"@tanstack/react-query-devtools": "^5.29.2",
"@tanstack/react-router": "^1.82.12",
"@tanstack/react-table": "^8.20.5",
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-table": "^7.7.20",
"@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0",
"@vitejs/plugin-react-swc": "^3.7.2",
@ -63,7 +63,6 @@
"react-popper-tooltip": "^4.4.2",
"react-ridge-state": "4.2.9",
"react-select": "^5.8.3",
"react-table": "^7.8.0",
"react-textarea-autosize": "^8.5.5",
"stacktracey": "^2.1.8",
"tailwind-lerp-colors": "1.2.6",
@ -79,7 +78,6 @@
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-table": "^7.7.20",
"@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0",
"@vitejs/plugin-react-swc": "^3.7.2",

44
web/pnpm-lock.yaml generated
View file

@ -37,6 +37,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.82.12
version: 1.82.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-table':
specifier: ^8.20.5
version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/node':
specifier: ^22.10.0
version: 22.10.0
@ -46,9 +49,6 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.1
'@types/react-table':
specifier: ^7.7.20
version: 7.7.20
'@typescript-eslint/eslint-plugin':
specifier: ^8.16.0
version: 8.16.0(@typescript-eslint/parser@8.16.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2)
@ -103,9 +103,6 @@ importers:
react-select:
specifier: ^5.8.3
version: 5.8.3(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-table:
specifier: ^7.8.0
version: 7.8.0(react@18.3.1)
react-textarea-autosize:
specifier: ^8.5.5
version: 8.5.5(@types/react@18.3.12)(react@18.3.1)
@ -1285,6 +1282,13 @@ packages:
react: ^18.3.1
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-table@8.20.5':
resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.3.1
react-dom: '>=16.8'
'@tanstack/react-virtual@3.10.9':
resolution: {integrity: sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==}
peerDependencies:
@ -1302,6 +1306,10 @@ packages:
'@tanstack/store@0.6.0':
resolution: {integrity: sha512-+m2OBglsjXcLmmKOX6/9v8BDOCtyxhMmZLsRUDswOOSdIIR9mvv6i0XNKsmTh3AlYU8c1mRcodC8/Vyf+69VlQ==}
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.10.9':
resolution: {integrity: sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==}
@ -1344,9 +1352,6 @@ packages:
'@types/react-dom@18.3.1':
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
'@types/react-table@7.7.20':
resolution: {integrity: sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==}
'@types/react-transition-group@4.4.11':
resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==}
@ -2834,11 +2839,6 @@ packages:
react: ^18.3.1
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-table@7.8.0:
resolution: {integrity: sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==}
peerDependencies:
react: ^18.3.1
react-textarea-autosize@8.5.5:
resolution: {integrity: sha512-CVA94zmfp8m4bSHtWwmANaBR8EPsKy2aZ7KwqhoS4Ftib87F9Kvi7XQhOixypPLMc6kVYgOXvKFuuzZDpHGRPg==}
engines: {node: '>=10'}
@ -4709,6 +4709,12 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.2.2(react@18.3.1)
'@tanstack/react-table@8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/table-core': 8.20.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/react-virtual@3.10.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.10.9
@ -4727,6 +4733,8 @@ snapshots:
'@tanstack/store@0.6.0': {}
'@tanstack/table-core@8.20.5': {}
'@tanstack/virtual-core@3.10.9': {}
'@tsconfig/node10@1.0.9': {}
@ -4764,10 +4772,6 @@ snapshots:
dependencies:
'@types/react': 18.3.12
'@types/react-table@7.7.20':
dependencies:
'@types/react': 18.3.12
'@types/react-transition-group@4.4.11':
dependencies:
'@types/react': 18.3.12
@ -6406,10 +6410,6 @@ snapshots:
- '@types/react'
- supports-color
react-table@7.8.0(react@18.3.1):
dependencies:
react: 18.3.1
react-textarea-autosize@8.5.5(@types/react@18.3.12)(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.0

View file

@ -6,6 +6,7 @@
import { baseUrl, sseBaseUrl } from "@utils";
import { GithubRelease } from "@app/types/Update";
import { AuthContext } from "@utils/Context";
import { ColumnFilter } from "@tanstack/react-table";
type RequestBody = BodyInit | object | Record<string, unknown> | null;
type Primitive = string | number | boolean | symbol | undefined;
@ -406,7 +407,7 @@ export const APIClient = {
release: {
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"),
findQuery: (offset?: number, limit?: number, filters?: ReleaseFilter[]) => {
findQuery: (offset?: number, limit?: number, filters?: ColumnFilter[]) => {
const params: Record<string, string[]> = {
indexer: [],
push_status: [],
@ -418,13 +419,25 @@ export const APIClient = {
return;
if (filter.id == "indexer.identifier") {
params["indexer"].push(filter.value);
if (typeof filter.value === "string") {
params["indexer"].push(filter.value);
}
} else if (filter.id == "indexer_identifier") {
if (typeof filter.value === "string") {
params["indexer"].push(filter.value);
}
} else if (filter.id === "action_status") {
params["push_status"].push(filter.value); // push_status is the correct value here otherwise the releases table won't load when filtered by push status
if (typeof filter.value === "string") {
params["push_status"].push(filter.value);
} // push_status is the correct value here otherwise the releases table won't load when filtered by push status
} else if (filter.id === "push_status") {
params["push_status"].push(filter.value);
if (typeof filter.value === "string") {
params["push_status"].push(filter.value);
}
} else if (filter.id == "name") {
params["q"].push(filter.value);
if (typeof filter.value === "string") {
params["q"].push(filter.value);
}
}
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { queryOptions } from "@tanstack/react-query";
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import {
ApiKeys,
@ -15,6 +15,7 @@ import {
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
import { ColumnFilter } from "@tanstack/react-table";
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
queryOptions({
@ -104,10 +105,11 @@ export const ApikeysQueryOptions = () =>
refetchOnWindowFocus: false,
});
export const ReleasesListQueryOptions = (offset: number, limit: number, filters: ReleaseFilter[]) =>
export const ReleasesListQueryOptions = (offset: number, limit: number, filters: ColumnFilter[]) =>
queryOptions({
queryKey: ReleaseKeys.list(offset, limit, filters),
queryFn: () => APIClient.release.findQuery(offset, limit, filters),
placeholderData: keepPreviousData,
staleTime: 5000,
refetchOnWindowFocus: true,
refetchInterval: 15000 // refetch releases table on releases page every 15s

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { ColumnFilter } from "@tanstack/react-table";
export const SettingsKeys = {
all: ["settings"] as const,
updates: () => [...SettingsKeys.all, "updates"] as const,
@ -21,7 +23,7 @@ export const FilterKeys = {
export const ReleaseKeys = {
all: ["releases"] as const,
lists: () => [...ReleaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...ReleaseKeys.lists(), {
list: (pageIndex: number, pageSize: number, filters: ColumnFilter[]) => [...ReleaseKeys.lists(), {
pageIndex,
pageSize,
filters

View file

@ -7,7 +7,7 @@ import * as React from "react";
import { toast } from "react-hot-toast";
import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CellProps } from "react-table";
import { CellContext } from "@tanstack/react-table";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import {
ClockIcon,
@ -25,7 +25,7 @@ import Toast from "@components/notifications/Toast";
import { RingResizeSpinner } from "@components/Icons";
import { Tooltip } from "@components/tooltips/Tooltip";
export const NameCell = (props: CellProps<Release>) => (
export const NameCell = (props: CellContext<Release, unknown>) => (
<div
className={classNames(
"flex justify-between items-center py-2 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
@ -34,7 +34,7 @@ export const NameCell = (props: CellProps<Release>) => (
>
<div className="flex flex-col truncate">
<span className="truncate">
{String(props.cell.value)}
{String(props.cell.getValue())}
</span>
<div className="text-xs truncate">
<span className="text-xs text-gray-500 dark:text-gray-400">Category:</span> {props.row.original.category}
@ -47,7 +47,7 @@ export const NameCell = (props: CellProps<Release>) => (
</div>
);
export const LinksCell = (props: CellProps<Release>) => {
export const LinksCell = (props: CellContext<Release, unknown>) => {
return (
<div className="flex space-x-2 text-blue-400 dark:text-blue-500">
<div>
@ -104,13 +104,13 @@ export const LinksCell = (props: CellProps<Release>) => {
);
};
export const AgeCell = ({value}: CellProps<Release>) => (
<div className="text-sm text-gray-500" title={simplifyDate(value)}>
{formatDistanceToNowStrict(new Date(value), {addSuffix: false})}
export const AgeCell = ({cell}: CellContext<Release, unknown>) => (
<div className="text-sm text-gray-500" title={simplifyDate(cell.getValue() as string)}>
{formatDistanceToNowStrict(new Date(cell.getValue() as string), {addSuffix: false})}
</div>
);
export const IndexerCell = (props: CellProps<Release>) => (
export const IndexerCell = (props: CellContext<Release, unknown>) => (
<div
className={classNames(
"py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
@ -129,7 +129,7 @@ export const IndexerCell = (props: CellProps<Release>) => (
</div>
);
export const TitleCell = ({value}: CellProps<Release>) => (
export const TitleCell = ({cell}: CellContext<Release, string>) => (
<div
className={classNames(
"py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
@ -138,11 +138,11 @@ export const TitleCell = ({value}: CellProps<Release>) => (
>
<Tooltip
requiresClick
label={value}
label={cell.getValue()}
maxWidth="max-w-[90vw]"
>
<span className="whitespace-pre-wrap break-words">
{value}
{cell.getValue()}
</span>
</Tooltip>
</div>
@ -188,10 +188,6 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
);
};
interface ReleaseStatusCellProps {
value: ReleaseActionStatus[];
}
interface StatusCellMapEntry {
colors: string;
icon: React.ReactElement;
@ -295,9 +291,9 @@ const CellLine = ({ title, children }: { title: string; children?: string; }) =>
);
};
export const ReleaseStatusCell = ({ value }: ReleaseStatusCellProps) => (
export const ReleaseStatusCell = ({ row }: CellContext<Release, unknown>) => (
<div className="flex text-sm font-medium text-gray-900 dark:text-gray-300">
{value.map((v, idx) => (
{row.original.action_status.map((v, idx) => (
<div
key={idx}
className={classNames(

View file

@ -6,84 +6,35 @@
import React, { useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
useTable,
useFilters,
useGlobalFilter,
useSortBy,
usePagination, FilterProps, Column
} from "react-table";
useReactTable,
getCoreRowModel,
flexRender,
ColumnDef
} from "@tanstack/react-table";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import * as DataTable from "@components/data-table";
import { RandomLinuxIsos } from "@utils";
import { ReleasesLatestQueryOptions } from "@api/queries";
import { IndexerCell } from "@components/data-table";
// This is a custom filter UI for selecting
// a unique option from a list
function SelectColumnFilter({
column: { filterValue, setFilter, preFilteredRows, id, render }
}: FilterProps<object>) {
// Calculate the options for filtering
// using the preFilteredRows
const options = React.useMemo(() => {
const options = new Set<string>();
preFilteredRows.forEach((row: { values: { [x: string]: string } }) => {
options.add(row.values[id]);
});
return [...options.values()];
}, [id, preFilteredRows]);
// Render a multi-select box
return (
<label className="flex items-baseline gap-x-2">
<span className="text-gray-700"><>{render("Header")}:</></span>
<select
className="border-gray-300 rounded-md shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
name={id}
id={id}
value={filterValue}
onChange={e => {
setFilter(e.target.value || undefined);
}}
>
<option value="">All</option>
{options.map((option, i) => (
<option key={i} value={option}>
{option}
</option>
))}
</select>
</label>
);
}
interface TableProps {
columns: Column[];
columns: ColumnDef<Release>[];
data: Release[];
}
function Table({ columns, data }: TableProps) {
// Use the state and functions returned from useTable to build your UI
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
page // Instead of using 'rows', we'll use page,
} = useTable(
{ columns, data },
useFilters,
useGlobalFilter,
useSortBy,
usePagination
);
const tableInstance = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
})
if (data.length === 0) {
return (
<div className="mt-4 mb-2 bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div
className="mt-4 mb-2 bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div className="flex items-center justify-center py-16">
<EmptyListState text="No recent activity"/>
</div>
@ -91,74 +42,49 @@ function Table({ columns, data }: TableProps) {
)
}
// Render the UI for your table
return (
<div className="inline-block min-w-full mt-4 mb-2 align-middle">
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
return (
<tr key={rowKey} {...rowRest}>
{headerGroup.headers.map((column) => {
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
return (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
key={`${rowKey}-${columnKey}`}
scope="col"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
{...columnRest}
>
<div className="flex items-center justify-between">
<>{column.render("Header")}</>
{/* Add a sort direction indicator */}
<span>
{column.isSorted ? (
column.isSortedDesc ? (
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
) : (
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
)
) : (
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
)}
</span>
</div>
</th>
);
})}
</tr>
);
})}
{tableInstance.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
scope="col"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
colSpan={header.colSpan}
>
<div className="flex items-center justify-between">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
</th>
)
)}
</tr>
))}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
return (
<tr key={bodyRowKey} {...bodyRowRest}>
{row.cells.map((cell) => {
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
return (
<td
key={cellRowKey}
className="first:pl-5 pl-3 pr-3 whitespace-nowrap"
role="cell"
{...cellRowRest}
>
<>{cell.render("Cell")}</>
</td>
);
})}
</tr>
);
})}
<tbody className="divide-y divide-gray-150 dark:divide-gray-750">
{tableInstance.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="first:pl-5 pl-3 pr-3 whitespace-nowrap"
role="cell"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
@ -167,30 +93,28 @@ function Table({ columns, data }: TableProps) {
}
export const ActivityTable = () => {
const columns = React.useMemo(() => [
const columns = React.useMemo<ColumnDef<Release, unknown>[]>(() => [
{
Header: "Age",
accessor: "timestamp",
Cell: DataTable.AgeCell
header: "Age",
accessorKey: "timestamp",
cell: DataTable.AgeCell
},
{
Header: "Release",
accessor: "name",
Cell: DataTable.TitleCell
header: "Release",
accessorKey: "name",
cell: DataTable.TitleCell,
},
{
Header: "Actions",
accessor: "action_status",
Cell: DataTable.ReleaseStatusCell
header: "Actions",
accessorKey: "action_status",
cell: DataTable.ReleaseStatusCell
},
{
Header: "Indexer",
accessor: "indexer.identifier",
Cell: IndexerCell,
Filter: SelectColumnFilter,
filter: "includes"
header: "Indexer",
accessorKey: "indexer.identifier",
cell: IndexerCell,
}
] as Column[], []);
], []);
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
@ -236,7 +160,7 @@ export const ActivityTable = () => {
Recent activity
</h3>
<Table columns={columns} data={displayData} />
<Table columns={columns} data={displayData}/>
<button
onClick={toggleReleaseNames}
@ -245,9 +169,9 @@ export const ActivityTable = () => {
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4" />
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>

View file

@ -5,13 +5,13 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { Column } from "@tanstack/react-table";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
import { DebounceInput } from "react-debounce-input";
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
import { PushStatusOptions } from "@domain/constants";
import { FilterProps } from "react-table";
import { DebounceInput } from "react-debounce-input";
import { ReleasesIndexersQueryOptions } from "@api/queries";
interface ListboxFilterProps {
@ -63,23 +63,19 @@ const ListboxFilter = ({
</div>
);
// a unique option from a list
export const IndexerSelectColumnFilter = ({
column: { filterValue, setFilter, id }
}: FilterProps<object>) => {
export const IndexerSelectColumnFilter = ({ column }: { column: Column<Release, unknown> }) => {
const { data, isSuccess } = useQuery(ReleasesIndexersQueryOptions());
// Assign indexer name based on the filterValue (indexer.identifier)
const currentIndexerName = data?.find(indexer => indexer.identifier === filterValue)?.name ?? "Indexer";
const currentIndexerName = data?.find(indexer => indexer.identifier === column.getFilterValue())?.name ?? "Indexer";
// Render a multi-select box
return (
<ListboxFilter
id={id}
key={id}
id={column.id}
key={column.id}
label={currentIndexerName}
currentValue={filterValue ?? ""}
onChange={setFilter}
currentValue={column.getFilterValue() as string || ""}
onChange={newValue => column.setFilterValue(newValue || undefined)}
>
{isSuccess && data && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
@ -95,9 +91,9 @@ interface FilterOptionProps {
const FilterOption = ({ label, value }: FilterOptionProps) => (
<ListboxOption
className={({ active }) => classNames(
className={({ focus }) => classNames(
"cursor-pointer select-none relative py-2 pl-10 pr-4",
active ? "text-black dark:text-gray-200 bg-gray-100 dark:bg-gray-900" : "text-gray-700 dark:text-gray-400"
focus ? "text-black dark:text-gray-200 bg-gray-100 dark:bg-gray-900" : "text-gray-700 dark:text-gray-400"
)}
value={value}
>
@ -121,23 +117,24 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
</ListboxOption>
);
export const PushStatusSelectColumnFilter = ({
column: { filterValue, setFilter, id },
initialFilterValue
}: FilterProps<object>) => {
React.useEffect(() => {
if (initialFilterValue) {
setFilter(initialFilterValue);
}
}, [initialFilterValue, setFilter]);
const label = filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)?.label : "Push status";
export const PushStatusSelectColumnFilter = ({ column }: { column: Column<Release, unknown> }) => {
// React.useEffect(() => {
// if (initialFilterValue) {
// setFilter(initialFilterValue);
// }
// }, [initialFilterValue, setFilter]);
const label = column.getFilterValue() ? PushStatusOptions.find((o) => o.value === column.getFilterValue() && o.value)?.label : "Push status";
return (
<div className="mr-3" key={id}>
<div className="mr-3" key={column.id}>
<ListboxFilter
id={id}
id={column.id}
label={label ?? "Push status"}
currentValue={filterValue ?? ""}
onChange={setFilter}
currentValue={column.getFilterValue() as string ?? ""}
onChange={value => {
column.setFilterValue(value || undefined);
}}
>
{PushStatusOptions.map((status, idx) => (
<FilterOption key={idx} value={status.value} label={status.label} />
@ -147,17 +144,16 @@ export const PushStatusSelectColumnFilter = ({
);
};
export const SearchColumnFilter = ({
column: { filterValue, setFilter, id }
}: FilterProps<object>) => {
export const SearchColumnFilter = ({ column }: { column: Column<Release, unknown> }) => {
return (
<div className="flex-1 mr-3 mt-1" key={id}>
<div className="flex-1 mr-3 mt-1" key={column.id}>
<DebounceInput
minLength={2}
value={filterValue || undefined}
value={column.getFilterValue() as string || undefined}
debounceTimeout={500}
onChange={e => {
setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely
// Set undefined to remove the filter entirely
column.setFilterValue(e.target.value || undefined)
}}
id="filter"
type="text"

View file

@ -6,7 +6,15 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
import {
useReactTable,
getCoreRowModel,
flexRender,
ColumnDef,
Column,
RowData,
PaginationState,
} from "@tanstack/react-table";
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
@ -16,64 +24,24 @@ import {
EyeSlashIcon
} from "@heroicons/react/24/solid";
import { ReleasesRoute } from "@app/routes";
import { ReleasesListQueryOptions } from "@api/queries";
import { RandomLinuxIsos } from "@utils";
import { RingResizeSpinner, SortDownIcon, SortIcon, SortUpIcon } from "@components/Icons";
import { RingResizeSpinner } from "@components/Icons";
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./ReleaseFilters";
import { EmptyListState } from "@components/emptystates";
import { TableButton, TablePageButton } from "@components/data-table/Buttons.tsx";
import { AgeCell, IndexerCell, LinksCell, NameCell, ReleaseStatusCell } from "@components/data-table";
import { TableButton, TablePageButton, AgeCell, IndexerCell, LinksCell, NameCell, ReleaseStatusCell } from "@components/data-table";
type TableState = {
queryPageIndex: number;
queryPageSize: number;
totalCount: number;
queryFilters: ReleaseFilter[];
};
const initialState: TableState = {
queryPageIndex: 0,
queryPageSize: 10,
totalCount: 0,
queryFilters: []
};
enum ActionType {
PAGE_CHANGED = "PAGE_CHANGED",
PAGE_SIZE_CHANGED = "PAGE_SIZE_CHANGED",
TOTAL_COUNT_CHANGED = "TOTAL_COUNT_CHANGED",
FILTER_CHANGED = "FILTER_CHANGED"
declare module '@tanstack/react-table' {
//allows us to define custom properties for our columns
// @eslint-ignore
interface ColumnMeta<TData extends RowData, TValue> {
filterVariant?: 'text' | 'range' | 'select' | 'search' | 'actionPushStatus' | 'indexerSelect';
}
}
type Actions =
| { type: ActionType.FILTER_CHANGED; payload: ReleaseFilter[]; }
| { type: ActionType.PAGE_CHANGED; payload: number; }
| { type: ActionType.PAGE_SIZE_CHANGED; payload: number; }
| { type: ActionType.TOTAL_COUNT_CHANGED; payload: number; };
const TableReducer = (state: TableState, action: Actions): TableState => {
switch (action.type) {
case ActionType.PAGE_CHANGED: {
return { ...state, queryPageIndex: action.payload };
}
case ActionType.PAGE_SIZE_CHANGED: {
return { ...state, queryPageSize: action.payload };
}
case ActionType.FILTER_CHANGED: {
return { ...state, queryFilters: action.payload };
}
case ActionType.TOTAL_COUNT_CHANGED: {
return { ...state, totalCount: action.payload };
}
default: {
throw new Error(`Unhandled action type: ${action}`);
}
}
};
const EmptyReleaseList = () => (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div
className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
@ -91,50 +59,87 @@ const EmptyReleaseList = () => (
</div>
);
export const ReleaseTable = () => {
const search = ReleasesRoute.useSearch()
function Filter({ column }: { column: Column<Release, unknown> }) {
const { filterVariant } = column.columnDef.meta ?? {}
const columns = React.useMemo(() => [
{
Header: "Age",
accessor: "timestamp",
Cell: AgeCell
},
{
Header: "Release",
accessor: "name",
Cell: NameCell,
Filter: SearchColumnFilter
},
{
Header: "Links",
accessor: (row) => ({ download_url: row.download_url, info_url: row.info_url }),
id: "links",
Cell: LinksCell
},
{
Header: "Actions",
accessor: "action_status",
Cell: ReleaseStatusCell,
Filter: PushStatusSelectColumnFilter
},
{
Header: "Indexer",
accessor: "indexer.identifier",
Cell: IndexerCell,
Filter: IndexerSelectColumnFilter,
filter: "equal"
}
] as Column<Release>[], []);
switch (filterVariant) {
case "search":
return <SearchColumnFilter column={column}/>
if (search.action_status != "") {
initialState.queryFilters = [{id: "action_status", value: search.action_status! }]
case "indexerSelect":
return <IndexerSelectColumnFilter column={column}/>
case "actionPushStatus":
return <PushStatusSelectColumnFilter column={column}/>
default:
return null;
}
}
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
React.useReducer(TableReducer, initialState);
interface ColumnFilter {
id: string
value: unknown
}
const { isLoading, error, data, isSuccess } = useQuery(ReleasesListQueryOptions(queryPageIndex * queryPageSize, queryPageSize, queryFilters));
type ColumnFiltersState = ColumnFilter[];
export const ReleaseTable = () => {
// const search = ReleasesRoute.useSearch()
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const columns = React.useMemo<ColumnDef<Release, unknown>[]>(() => [
{
header: "Age",
accessorKey: "timestamp",
cell: AgeCell
},
{
header: "Release",
accessorKey: "name",
cell: NameCell,
meta: {
filterVariant: 'search',
},
},
{
header: "Links",
accessorFn: (row) => ({ download_url: row.download_url, info_url: row.info_url }),
id: "links",
cell: LinksCell
},
{
header: "Actions",
accessorKey: "action_status",
cell: ReleaseStatusCell,
meta: {
filterVariant: 'actionPushStatus',
},
},
{
header: "Indexer",
accessorKey: "indexer.identifier",
cell: IndexerCell,
meta: {
filterVariant: 'indexerSelect',
},
}
], []);
// if (search.action_status != "") {
// setColumnFilters(prevState => [...prevState, { id: "action_status", value: search.action_status }]);
// }
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const {
isLoading,
error,
data,
} = useQuery(ReleasesListQueryOptions(pagination.pageIndex * pagination.pageSize, pagination.pageSize, columnFilters));
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -163,76 +168,44 @@ export const ReleaseTable = () => {
}
};
const displayData = showLinuxIsos ? modifiedData : (data?.data ?? []);
const defaultData = React.useMemo(() => [], [])
const displayData = showLinuxIsos ? modifiedData : (data?.data ?? defaultData);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
page, // Instead of using 'rows', we'll use page,
// which has only the rows for the active page
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize, filters }
} = useTable(
{
columns,
data: displayData, // Use displayData here
initialState: {
pageIndex: queryPageIndex,
pageSize: queryPageSize,
filters: queryFilters,
},
manualPagination: true,
manualFilters: true,
manualSortBy: true,
pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0,
autoResetSortBy: false,
autoResetExpanded: false,
autoResetPage: false
const tableInstance = useReactTable({
columns,
data: displayData,
getCoreRowModel: getCoreRowModel(),
manualFiltering: true,
manualPagination: true,
manualSorting: true,
rowCount: data?.count,
state: {
columnFilters,
pagination,
},
useFilters,
useSortBy,
usePagination
);
initialState: {
pagination
},
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
});
React.useEffect(() => {
dispatch({ type: ActionType.PAGE_CHANGED, payload: pageIndex });
}, [pageIndex]);
// Manage your own state
// const [state, setState] = React.useState(tableInstance.initialState)
React.useEffect(() => {
dispatch({ type: ActionType.PAGE_SIZE_CHANGED, payload: pageSize });
gotoPage(0);
}, [pageSize, gotoPage]);
React.useEffect(() => {
if (data?.count) {
dispatch({
type: ActionType.TOTAL_COUNT_CHANGED,
payload: data.count
});
}
}, [data?.count]);
React.useEffect(() => {
dispatch({ type: ActionType.FILTER_CHANGED, payload: filters });
gotoPage(0);
}, [filters]);
React.useEffect(() => {
if (search.action_status != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: search.action_status! }] });
}
}, [search.action_status]);
// Override the state managers for the table to your own
// tableInstance.setOptions(prev => ({
// ...prev,
// state,
// onStateChange: setState,
// // These are just table options, so if things
// // need to change based on your state, you can
// // derive them here
//
// // Just for fun, let's debug everything if the pageIndex
// // is greater than 2
// // debugTable: state.pagination.pageIndex > 2,
// }))
if (error) {
return <p>Error</p>;
@ -242,17 +215,19 @@ export const ReleaseTable = () => {
return (
<div>
<div className="flex mb-6 flex-col sm:flex-row">
{ headerGroups.map((headerGroup) => headerGroup.headers.map((column) => (
column.Filter ? (
<React.Fragment key={ column.id }>{ column.render("Filter") }</React.Fragment>
{tableInstance.getHeaderGroups().map((headerGroup) =>
headerGroup.headers.map((header) => (
header.column.getCanFilter() ? (
<Filter key={header.column.id} column={header.column}/>
) : null
))
) }
)}
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
<div className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<div className="flex h-10"/>
</div>
<div
className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
<div className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<div className="flex h-10"/>
</div>
<div className="flex items-center justify-center py-64">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
@ -261,14 +236,13 @@ export const ReleaseTable = () => {
)
}
// Render the UI for your table
return (
<div className="flex flex-col">
<div className="flex mb-6 flex-col sm:flex-row">
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((column) => (
column.Filter ? (
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
{tableInstance.getHeaderGroups().map((headerGroup) =>
headerGroup.headers.map((header) => (
header.column.getCanFilter() ? (
<Filter key={header.column.id} column={header.column}/>
) : null
))
)}
@ -277,156 +251,151 @@ export const ReleaseTable = () => {
{displayData.length === 0
? <EmptyReleaseList/>
: (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => {
const {key: rowKey, ...rowRest} = headerGroup.getHeaderGroupProps();
return (
<tr key={rowKey} {...rowRest}>
{headerGroup.headers.map((column) => {
const {key: columnKey, ...columnRest} = column.getHeaderProps(column.getSortByToggleProps());
return (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
key={`${rowKey}-${columnKey}`}
scope="col"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
{...columnRest}
>
<div className="flex items-center justify-between">
<>{column.render("Header")}</>
{/* Add a sort direction indicator */}
<span>
{column.isSorted ? (
column.isSortedDesc ? (
<SortDownIcon className="w-4 h-4 text-gray-400"/>
) : (
<SortUpIcon className="w-4 h-4 text-gray-400"/>
)
) : (
<SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100"/>
)}
</span>
</div>
</th>
);
})}
<div
className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{tableInstance.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
scope="col"
colSpan={header.colSpan}
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
>
<div className="flex items-center justify-between">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/*<>{header.render("Header")}</>*/}
{/*/!* Add a sort direction indicator *!/*/}
{/*<span>*/}
{/* {header.isSorted ? (*/}
{/* header.isSortedDesc ? (*/}
{/* <SortDownIcon className="w-4 h-4 text-gray-400"/>*/}
{/* ) : (*/}
{/* <SortUpIcon className="w-4 h-4 text-gray-400"/>*/}
{/* )*/}
{/* ) : (*/}
{/* <SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100"/>*/}
{/* )}*/}
{/*</span>*/}
</div>
</th>
))}
</tr>
);
})}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
))}
</thead>
const {key: bodyRowKey, ...bodyRowRest} = row.getRowProps();
return (
<tr key={bodyRowKey} {...bodyRowRest}>
{row.cells.map((cell) => {
const {key: cellRowKey, ...cellRowRest} = cell.getCellProps();
return (
<td
key={cellRowKey}
className="first:pl-5 pl-3 pr-3 whitespace-nowrap"
role="cell"
{...cellRowRest}
>
<>{cell.render("Cell")}</>
</td>
);
})}
<tbody className="divide-y divide-gray-150 dark:divide-gray-750">
{tableInstance.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="first:pl-5 pl-3 pr-3 whitespace-nowrap"
role="cell"
>
<>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</>
</td>
))}
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<TableButton onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</TableButton>
<TableButton onClick={() => nextPage()} disabled={!canNextPage}>Next</TableButton>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span
className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<TableButton onClick={() => tableInstance.previousPage()} disabled={!tableInstance.getCanPreviousPage()}>Previous</TableButton>
<TableButton onClick={() => tableInstance.nextPage()} disabled={!tableInstance.getCanNextPage()}>Next</TableButton>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<TablePageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
</TablePageButton>
<TablePageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
<span>Prev</span>
</TablePageButton>
<TablePageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
</TablePageButton>
<TablePageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
<span className="sr-only">Last</span>
</TablePageButton>
</nav>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{tableInstance.getState().pagination.pageIndex + 1}</span> of <span
className="font-medium">{tableInstance.getPageCount()}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={tableInstance.getState().pagination.pageSize}
onChange={e => {
tableInstance.setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<TablePageButton
className="rounded-l-md"
onClick={() => tableInstance.firstPage()}
disabled={!tableInstance.getCanPreviousPage()}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
</TablePageButton>
<TablePageButton
className="pl-1 pr-2"
onClick={() => tableInstance.previousPage()}
disabled={!tableInstance.getCanPreviousPage()}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
<span>Prev</span>
</TablePageButton>
<TablePageButton
className="pl-2 pr-1"
onClick={() => tableInstance.nextPage()}
disabled={!tableInstance.getCanNextPage()}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
</TablePageButton>
<TablePageButton
className="rounded-r-md"
onClick={() => tableInstance.lastPage()}
disabled={!tableInstance.getCanNextPage()}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
<span className="sr-only">Last</span>
</TablePageButton>
</nav>
</div>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>
</div>
)}
)}
</div>
</div>
);