feat: Add grid/table view toggle to listings overview (#305)

* feat: Add delete button to listing detail view

* feat: Add grid/table view toggle to listings overview

---------

Co-authored-by: datenwurm <git@datenwurm.net>
This commit is contained in:
datenwurm
2026-05-07 12:12:49 +02:00
committed by GitHub
parent ee54cc495b
commit f60c5859f9
9 changed files with 838 additions and 475 deletions

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
IconDelete,
IconLink,
IconMapPin,
IconStar,
IconStarStroked,
IconEyeOpened,
} from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js';
import './ListingsTable.less';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
*/
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable">
{listings.map((item) => (
<div
key={item.id}
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsTable__row__thumb">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
</div>
<div className="listingsTable__row__title" title={item.title}>
{item.title}
</div>
<div className="listingsTable__row__price">
{item.price ? (
<>
<IconCart size="small" />
{item.price}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__address">
{item.address ? (
<>
<IconMapPin size="small" />
{item.address}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__meta">
<IconBriefcase size="small" />
{item.provider}
</div>
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
<Tooltip content="Original Listing">
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
</div>
</div>
))}
</div>
);
export default ListingsTable;

View File

@@ -0,0 +1,142 @@
@import '../../tokens.less';
.listingsTable {
display: flex;
flex-direction: column;
gap: 4px;
&__row {
display: grid;
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
align-items: center;
gap: @space-3;
padding: 8px 12px;
background: @color-elevated;
border: 1px solid @color-border;
border-radius: @radius-chip;
cursor: pointer;
transition: background @transition-fast;
&:hover {
background: #252525;
}
&--inactive {
opacity: 0.6;
}
&__thumb {
width: 56px;
height: 40px;
flex-shrink: 0;
border-radius: @radius-chip;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__title {
font-weight: 600;
font-size: @text-sm;
color: @color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__price {
font-size: @text-sm;
font-weight: 600;
color: @color-success;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
&__address {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__date {
font-size: @text-xs;
color: @color-faint;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background @transition-fast;
flex-shrink: 0;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
svg {
color: @color-accent;
font-size: 14px;
}
}
&__empty {
color: @color-faint;
}
@media (max-width: 900px) {
grid-template-columns: 56px 1fr 120px auto;
.listingsTable__row__address,
.listingsTable__row__meta,
.listingsTable__row__date {
display: none;
}
}
@media (max-width: 560px) {
grid-template-columns: 56px 1fr auto;
.listingsTable__row__price {
display: none;
}
}
}
}