feat(releases): show details in list view (#1337)

* feat(releases): show details in list view

* fix(releases): activitytable columns type

* fix(releases): incognito mode

* feat(releases): move details button

* do we wanna truncate?

* fix(web): release column width at full size

---------

Co-authored-by: martylukyy <35452459+martylukyy@users.noreply.github.com>
This commit is contained in:
ze0s 2024-01-02 21:53:38 +01:00 committed by GitHub
parent 7eaf499d66
commit 9992675971
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 155 additions and 91 deletions

View file

@ -211,7 +211,7 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
} }
queryBuilder := repo.db.squirrel. queryBuilder := repo.db.squirrel.
Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp", Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.category", "r.season", "r.episode", "r.year", "r.resolution", "r.source", "r.codec", "r.container", "r.release_group", "r.timestamp",
"ras.id", "ras.status", "ras.action", "ras.action_id", "ras.type", "ras.client", "ras.filter", "ras.filter_id", "ras.release_id", "ras.rejections", "ras.timestamp"). "ras.id", "ras.status", "ras.action", "ras.action_id", "ras.type", "ras.client", "ras.filter", "ras.filter_id", "ras.release_id", "ras.rejections", "ras.timestamp").
Column(sq.Alias(countQuery, "page_total")). Column(sq.Alias(countQuery, "page_total")).
From("release r"). From("release r").
@ -245,17 +245,22 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
var rls domain.Release var rls domain.Release
var ras domain.ReleaseActionStatus var ras domain.ReleaseActionStatus
var rlsindexer, rlsfilter, infoUrl, downloadUrl sql.NullString var rlsindexer, rlsfilter, infoUrl, downloadUrl, codec sql.NullString
var rasId, rasFilterId, rasReleaseId, rasActionId sql.NullInt64 var rasId, rasFilterId, rasReleaseId, rasActionId sql.NullInt64
var rasStatus, rasAction, rasType, rasClient, rasFilter sql.NullString var rasStatus, rasAction, rasType, rasClient, rasFilter sql.NullString
var rasRejections []sql.NullString var rasRejections []sql.NullString
var rasTimestamp sql.NullTime var rasTimestamp sql.NullTime
if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasActionId, &rasType, &rasClient, &rasFilter, &rasFilterId, &rasReleaseId, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil { if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Category, &rls.Season, &rls.Episode, &rls.Year, &rls.Resolution, &rls.Source, &codec, &rls.Container, &rls.Group, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasActionId, &rasType, &rasClient, &rasFilter, &rasFilterId, &rasReleaseId, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil {
return res, 0, 0, errors.Wrap(err, "error scanning row") return res, 0, 0, errors.Wrap(err, "error scanning row")
} }
//for _, codec := range codecs {
// rls.Codec = append(rls.Codec, codec.String)
//
//}
ras.ID = rasId.Int64 ras.ID = rasId.Int64
ras.Status = domain.ReleasePushStatus(rasStatus.String) ras.Status = domain.ReleasePushStatus(rasStatus.String)
ras.Action = rasAction.String ras.Action = rasAction.String
@ -291,6 +296,7 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
rls.ActionStatus = make([]domain.ReleaseActionStatus, 0) rls.ActionStatus = make([]domain.ReleaseActionStatus, 0)
rls.InfoURL = infoUrl.String rls.InfoURL = infoUrl.String
rls.DownloadURL = downloadUrl.String rls.DownloadURL = downloadUrl.String
rls.Codec = strings.Split(codec.String, ",")
// only add ActionStatus if it's not empty // only add ActionStatus if it's not empty
if ras.ID > 0 { if ras.ID > 0 {

View file

@ -59,7 +59,7 @@ type Release struct {
TorrentTmpFile string `json:"-"` TorrentTmpFile string `json:"-"`
TorrentDataRawBytes []byte `json:"-"` TorrentDataRawBytes []byte `json:"-"`
TorrentHash string `json:"-"` TorrentHash string `json:"-"`
TorrentName string `json:"torrent_name"` // full release name TorrentName string `json:"name"` // full release name
Size uint64 `json:"size"` Size uint64 `json:"size"`
Title string `json:"title"` // Parsed title Title string `json:"title"` // Parsed title
Description string `json:"-"` Description string `json:"-"`
@ -538,32 +538,6 @@ func (r *Release) HasMagnetUri() bool {
return r.MagnetURI != "" return r.MagnetURI != ""
} }
type magnetRoundTripper struct{}
func (rt *magnetRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
if r.URL.Scheme == "magnet" {
responseBody := r.URL.String()
respReader := io.NopCloser(strings.NewReader(responseBody))
resp := &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Body: respReader,
ContentLength: int64(len(responseBody)),
Header: map[string][]string{
"Content-Type": {"text/plain"},
"Location": {responseBody},
},
Proto: "HTTP/2.0",
ProtoMajor: 2,
}
return resp, nil
}
return http.DefaultTransport.RoundTrip(r)
}
func (r *Release) ResolveMagnetUri(ctx context.Context) error { func (r *Release) ResolveMagnetUri(ctx context.Context) error {
if r.MagnetURI == "" { if r.MagnetURI == "" {
return nil return nil

View file

@ -7,33 +7,110 @@ import * as React from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CellProps } from "react-table";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid"; import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
import { ExternalLink } from "../ExternalLink"; import { ExternalLink } from "../ExternalLink";
import { ClockIcon, XMarkIcon, NoSymbolIcon } from "@heroicons/react/24/outline"; import {
ClockIcon,
XMarkIcon,
NoSymbolIcon,
ArrowDownTrayIcon,
ArrowTopRightOnSquareIcon, DocumentTextIcon
} from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import { classNames, simplifyDate } from "@utils"; import {classNames, humanFileSize, simplifyDate} from "@utils";
import { filterKeys } from "@screens/filters/List"; import { filterKeys } from "@screens/filters/List";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { RingResizeSpinner } from "@components/Icons"; import { RingResizeSpinner } from "@components/Icons";
import { Tooltip } from "@components/tooltips/Tooltip"; import { Tooltip } from "@components/tooltips/Tooltip";
interface CellProps { export const NameCell = (props: CellProps<Release>) => (
value: string; <div
} className={classNames(
"flex justify-between items-center py-2 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
interface LinksCellProps { "max-w-[82px] sm:max-w-[160px] md:max-w-[290px] lg:max-w-[535px] xl:max-w-[775px]"
value: Release; )}
} >
<div className="flex flex-col truncate">
export const AgeCell = ({ value }: CellProps) => ( <span className="truncate">
<div className="text-sm text-gray-500" title={simplifyDate(value)}> {String(props.cell.value)}
{formatDistanceToNowStrict(new Date(value), { addSuffix: false })} </span>
<div className="text-xs truncate">
<span className="text-xs text-gray-500 dark:text-gray-400">Category:</span> {props.row.original.category}
<span
className="text-xs text-gray-500 dark:text-gray-400"> Size:</span> {humanFileSize(props.row.original.size)}
<span
className="text-xs text-gray-500 dark:text-gray-400"> Misc:</span> {`${props.row.original.resolution} ${props.row.original.source} ${props.row.original.codec ?? ""} ${props.row.original.container}`}
</div>
</div>
</div> </div>
); );
export const IndexerCell = ({ value }: CellProps) => ( export const LinksCell = (props: CellProps<Release>) => {
return (
<div className="flex space-x-2 text-blue-400 dark:text-blue-500">
<div>
<Tooltip
requiresClick
label={<DocumentTextIcon
className="h-5 w-5 cursor-pointer text-blue-400 hover:text-blue-500 dark:text-blue-500 dark:hover:text-blue-600"
aria-hidden={true}/>}
title="Details"
>
<div className="mb-1">
<CellLine title="Release">{props.row.original.name}</CellLine>
<CellLine title="Indexer">{props.row.original.indexer}</CellLine>
<CellLine title="Protocol">{props.row.original.protocol}</CellLine>
<CellLine title="Implementation">{props.row.original.implementation}</CellLine>
<CellLine title="Category">{props.row.original.category}</CellLine>
<CellLine title="Uploader">{props.row.original.uploader}</CellLine>
<CellLine title="Size">{humanFileSize(props.row.original.size)}</CellLine>
<CellLine title="Title">{props.row.original.title}</CellLine>
{props.row.original.year > 0 && <CellLine title="Year">{props.row.original.year.toString()}</CellLine>}
{props.row.original.season > 0 &&
<CellLine title="Season">{props.row.original.season.toString()}</CellLine>}
{props.row.original.episode > 0 &&
<CellLine title="Episode">{props.row.original.episode.toString()}</CellLine>}
<CellLine title="Resolution">{props.row.original.resolution}</CellLine>
<CellLine title="Source">{props.row.original.source}</CellLine>
<CellLine title="Codec">{props.row.original.codec}</CellLine>
<CellLine title="HDR">{props.row.original.hdr}</CellLine>
<CellLine title="Group">{props.row.original.group}</CellLine>
<CellLine title="Container">{props.row.original.container}</CellLine>
<CellLine title="Origin">{props.row.original.origin}</CellLine>
</div>
</Tooltip>
</div>
{props.row.original.download_url && (
<ExternalLink href={props.row.original.download_url}>
<ArrowDownTrayIcon
title="Download torrent file"
className="h-5 w-5 hover:text-blue-500 dark:hover:text-blue-600"
aria-hidden="true"
/>
</ExternalLink>
)}
{props.row.original.info_url && (
<ExternalLink href={props.row.original.info_url}>
<ArrowTopRightOnSquareIcon
title="Visit torrentinfo url"
className="h-5 w-5 hover:text-blue-500 dark:hover:text-blue-600"
aria-hidden="true"
/>
</ExternalLink>
)}
</div>
);
};
export const AgeCell = ({value}: CellProps<Release>) => (
<div className="text-sm text-gray-500" title={simplifyDate(value)}>
{formatDistanceToNowStrict(new Date(value), {addSuffix: false})}
</div>
);
export const IndexerCell = ({value}: CellProps<Release>) => (
<div <div
className={classNames( className={classNames(
"py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300", "py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
@ -52,7 +129,7 @@ export const IndexerCell = ({ value }: CellProps) => (
</div> </div>
); );
export const TitleCell = ({ value }: CellProps) => ( export const TitleCell = ({value}: CellProps<Release>) => (
<div <div
className={classNames( className={classNames(
"py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300", "py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
@ -250,19 +327,3 @@ export const ReleaseStatusCell = ({ value }: ReleaseStatusCellProps) => (
</div> </div>
); );
export const LinksCell = ({ value }: LinksCellProps) => {
return (
<div className="flex space-x-2 text-blue-400 dark:text-blue-500">
{value.download_url && (
<ExternalLink href={value.download_url}>
<ArrowDownTrayIcon title="Download torrent file" className="h-5 w-5 hover:text-blue-500 dark:hover:text-blue-600" aria-hidden="true" />
</ExternalLink>
)}
{value.info_url && (
<ExternalLink href={value.info_url}>
<ArrowTopRightOnSquareIcon title="Visit torrentinfo url" className="h-5 w-5 hover:text-blue-500 dark:hover:text-blue-600" aria-hidden="true" />
</ExternalLink>
)}
</div>
);
};

View file

@ -4,4 +4,4 @@
*/ */
export { Button, PageButton } from "./Buttons"; export { Button, PageButton } from "./Buttons";
export { AgeCell, IndexerCell, TitleCell, ReleaseStatusCell, LinksCell } from "./Cells"; export { AgeCell, IndexerCell, NameCell, TitleCell, ReleaseStatusCell, LinksCell } from "./Cells";

View file

@ -168,7 +168,7 @@ export const ActivityTable = () => {
}, },
{ {
Header: "Release", Header: "Release",
accessor: "torrent_name", accessor: "name",
Cell: DataTable.TitleCell Cell: DataTable.TitleCell
}, },
{ {
@ -183,7 +183,7 @@ export const ActivityTable = () => {
Filter: SelectColumnFilter, Filter: SelectColumnFilter,
filter: "includes" filter: "includes"
} }
], []); ] as Column[], []);
const { isLoading, data } = useSuspenseQuery({ const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_recent_releases"], queryKey: ["dash_recent_releases"],
@ -213,7 +213,7 @@ export const ActivityTable = () => {
const randomNames = RandomLinuxIsos(data.data.length); const randomNames = RandomLinuxIsos(data.data.length);
const newData: Release[] = data.data.map((item, index) => ({ const newData: Release[] = data.data.map((item, index) => ({
...item, ...item,
torrent_name: `${randomNames[index]}.iso`, name: `${randomNames[index]}.iso`,
indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker" indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker"
})); }));
setModifiedData(newData); setModifiedData(newData);

View file

@ -6,7 +6,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { CellProps, Column, useFilters, usePagination, useSortBy, useTable } from "react-table"; import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
import { import {
ChevronDoubleLeftIcon, ChevronDoubleLeftIcon,
ChevronDoubleRightIcon, ChevronDoubleRightIcon,
@ -23,8 +23,6 @@ import * as Icons from "@components/Icons";
import * as DataTable from "@components/data-table"; import * as DataTable from "@components/data-table";
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters"; import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters";
import { classNames } from "@utils";
import { Tooltip } from "@components/tooltips/Tooltip";
export const releaseKeys = { export const releaseKeys = {
all: ["releases"] as const, all: ["releases"] as const,
@ -94,27 +92,8 @@ export const ReleaseTable = () => {
}, },
{ {
Header: "Release", Header: "Release",
accessor: "torrent_name", accessor: "name",
Cell: (props: CellProps<Release>) => { Cell: DataTable.NameCell,
return (
<div
className={classNames(
"flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300",
"max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]"
)}
>
<Tooltip
requiresClick
label={props.cell.value}
maxWidth="max-w-[90vw]"
>
<span className="whitespace-pre-wrap break-words">
{String(props.cell.value)}
</span>
</Tooltip>
</div>
);
},
Filter: SearchColumnFilter Filter: SearchColumnFilter
}, },
{ {
@ -156,7 +135,7 @@ export const ReleaseTable = () => {
const randomNames = RandomLinuxIsos(data.data.length); const randomNames = RandomLinuxIsos(data.data.length);
const newData: Release[] = data.data.map((item, index) => ({ const newData: Release[] = data.data.map((item, index) => ({
...item, ...item,
torrent_name: `${randomNames[index]}.iso`, name: `${randomNames[index]}.iso`,
indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker" indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker"
})); }));
setModifiedData(newData); setModifiedData(newData);

View file

@ -10,11 +10,27 @@ interface Release {
indexer: string; indexer: string;
filter: string; filter: string;
protocol: string; protocol: string;
implementation: string;
name: string;
title: string; title: string;
size: number; size: number;
raw: string; raw: string;
info_url: string; info_url: string;
download_url: string; download_url: string;
category: string;
group: string;
season: number;
episode: number;
year: number;
resolution: string;
codec: string;
source: string;
container: string;
hdr: string;
uploader: string;
origin: string;
// freeleech: boolean;
// freeleech_percent:number;
timestamp: Date timestamp: Date
action_status: ReleaseActionStatus[] action_status: ReleaseActionStatus[]
} }

View file

@ -84,6 +84,34 @@ export const get = <T> (obj: T, path: string|Array<any>, defValue?: string) => {
return result === undefined ? defValue : result; return result === undefined ? defValue : result;
}; };
const UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte']
const BYTES_PER_KB = 1000
/**
* Format bytes as human-readable text.
*
* @param sizeBytes Number of bytes.
*
* @return Formatted string.
*/
export function humanFileSize(sizeBytes: number | bigint): string {
let size = Math.abs(Number(sizeBytes))
let u = 0
while (size >= BYTES_PER_KB && u < UNITS.length - 1) {
size /= BYTES_PER_KB
++u
}
return new Intl.NumberFormat([], {
style: 'unit',
unit: UNITS[u],
unitDisplay: 'short',
maximumFractionDigits: 1,
}).format(size)
}
export const RandomLinuxIsos = (count: number) => { export const RandomLinuxIsos = (count: number) => {
const linuxIsos = [ const linuxIsos = [
"ubuntu-20.04.4-lts-focal-fossa-desktop-amd64-secure-boot", "ubuntu-20.04.4-lts-focal-fossa-desktop-amd64-secure-boot",