mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
@@ -109,4 +109,21 @@ export default async function userSettingsPlugin(fastify) {
|
|||||||
return reply.code(500).send({ error: error.message });
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post('/listings-view-mode', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listings_view_mode } = request.body;
|
||||||
|
|
||||||
|
if (listings_view_mode !== 'grid' && listings_view_mode !== 'table') {
|
||||||
|
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listings_view_mode }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listings view mode setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
|
||||||
import {
|
|
||||||
useSearchParamState,
|
|
||||||
parseNumber,
|
|
||||||
parseString,
|
|
||||||
parseNullableBoolean,
|
|
||||||
} from '../../../hooks/useSearchParamState.js';
|
|
||||||
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconCart,
|
IconCart,
|
||||||
@@ -19,212 +12,28 @@ import {
|
|||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
|
||||||
IconEyeOpened,
|
IconEyeOpened,
|
||||||
IconArrowUp,
|
|
||||||
IconArrowDown,
|
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
|
||||||
import no_image from '../../../assets/no_image.png';
|
import no_image from '../../../assets/no_image.png';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
|
||||||
import { debounce } from '../../../utils';
|
|
||||||
|
|
||||||
import './ListingsGrid.less';
|
import './ListingsGrid.less';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
||||||
|
|
||||||
const ListingsGrid = () => {
|
/**
|
||||||
const listingsData = useSelector((state) => state.listingsData);
|
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
|
||||||
const providers = useSelector((state) => state.provider);
|
*/
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||||
const actions = useActions();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const sp = useSearchParams();
|
|
||||||
|
|
||||||
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
|
||||||
const pageSize = 40;
|
|
||||||
|
|
||||||
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
|
||||||
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
|
||||||
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
|
||||||
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
|
||||||
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
|
||||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
|
||||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
|
||||||
|
|
||||||
const loadData = () => {
|
|
||||||
actions.listingsData.getListingsData({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
sortfield: sortField,
|
|
||||||
sortdir: sortDir,
|
|
||||||
freeTextFilter,
|
|
||||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
|
||||||
|
|
||||||
const handleFilterChange = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((value) => {
|
|
||||||
setFreeTextFilter(value || null);
|
|
||||||
setPage(1);
|
|
||||||
}, 500),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// cleanup debounced handler to avoid memory leaks
|
|
||||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
|
||||||
};
|
|
||||||
}, [handleFilterChange]);
|
|
||||||
|
|
||||||
const handleWatch = async (e, item) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await xhrPost('/api/listings/watch', { listingId: item.id });
|
|
||||||
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
|
||||||
loadData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
Toast.error('Failed to operate Watchlist');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (_page) => {
|
|
||||||
setPage(_page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
|
||||||
Toast.success('Listing successfully removed');
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error.message || 'Error deleting listing');
|
|
||||||
} finally {
|
|
||||||
setDeleteModalVisible(false);
|
|
||||||
setListingToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="listingsGrid">
|
|
||||||
<div className="listingsGrid__topbar">
|
|
||||||
<Input
|
|
||||||
className="listingsGrid__topbar__search"
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
showClear
|
|
||||||
placeholder="Search"
|
|
||||||
defaultValue={freeTextFilter ?? ''}
|
|
||||||
onChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioGroup
|
|
||||||
type="button"
|
|
||||||
buttonSize="middle"
|
|
||||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio value="all">All</Radio>
|
|
||||||
<Radio value="true">Active</Radio>
|
|
||||||
<Radio value="false">Inactive</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
<RadioGroup
|
|
||||||
type="button"
|
|
||||||
buttonSize="middle"
|
|
||||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio value="all">All</Radio>
|
|
||||||
<Radio value="true">Watched</Radio>
|
|
||||||
<Radio value="false">Unwatched</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
placeholder="Provider"
|
|
||||||
showClear
|
|
||||||
onChange={(val) => {
|
|
||||||
setProviderFilter(val);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
value={providerFilter}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
>
|
|
||||||
{providers?.map((p) => (
|
|
||||||
<Select.Option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
placeholder="Job"
|
|
||||||
showClear
|
|
||||||
onChange={(val) => {
|
|
||||||
setJobNameFilter(val);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
value={jobNameFilter}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
>
|
|
||||||
{jobs?.map((j) => (
|
|
||||||
<Select.Option key={j.id} value={j.id}>
|
|
||||||
{j.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
|
||||||
<Select.Option value="job_name">Job Name</Select.Option>
|
|
||||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
|
||||||
<Select.Option value="price">Price</Select.Option>
|
|
||||||
<Select.Option value="provider">Provider</Select.Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
|
||||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
|
||||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(listingsData?.result || []).length === 0 && (
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description="No listings available yet..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="listingsGrid__grid">
|
<div className="listingsGrid__grid">
|
||||||
{(listingsData?.result || []).map((item) => (
|
{listings.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="listingsGrid__card"
|
className="listingsGrid__card"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
onClick={() => onNavigate(item.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') navigate(`/listings/listing/${item.id}`);
|
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="listingsGrid__card__image-wrapper">
|
<div className="listingsGrid__card__image-wrapper">
|
||||||
@@ -243,7 +52,7 @@ const ListingsGrid = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="listingsGrid__card__star"
|
className="listingsGrid__card__star"
|
||||||
onClick={(e) => handleWatch(e, item)}
|
onClick={(e) => onWatch(e, item)}
|
||||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||||
>
|
>
|
||||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||||
@@ -294,7 +103,7 @@ const ListingsGrid = () => {
|
|||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`/listings/listing/${item.id}`);
|
onNavigate(item.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -306,8 +115,7 @@ const ListingsGrid = () => {
|
|||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setListingToDelete(item.id);
|
onDelete(item.id);
|
||||||
setDeleteModalVisible(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -315,27 +123,6 @@ const ListingsGrid = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(listingsData?.result || []).length > 0 && (
|
);
|
||||||
<div className="listingsGrid__pagination">
|
|
||||||
<Pagination
|
|
||||||
currentPage={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
total={listingsData?.totalNumber || 0}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
showSizeChanger={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ListingDeletionModal
|
|
||||||
visible={deleteModalVisible}
|
|
||||||
onConfirm={confirmDeletion}
|
|
||||||
onCancel={() => {
|
|
||||||
setDeleteModalVisible(false);
|
|
||||||
setListingToDelete(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ListingsGrid;
|
export default ListingsGrid;
|
||||||
|
|||||||
@@ -1,43 +1,12 @@
|
|||||||
@import '../../../tokens.less';
|
@import '../../../tokens.less';
|
||||||
|
|
||||||
.listingsGrid {
|
.listingsGrid__grid {
|
||||||
&__topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: @space-3;
|
|
||||||
margin-bottom: @space-4;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
&__search {
|
|
||||||
min-width: 200px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.listingsGrid__topbar__search {
|
|
||||||
width: 100%;
|
|
||||||
flex: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-radio-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
.listingsGrid__card {
|
||||||
background: @color-elevated !important;
|
background: @color-elevated !important;
|
||||||
border: 1px solid @color-border !important;
|
border: 1px solid @color-border !important;
|
||||||
border-radius: @radius-card !important;
|
border-radius: @radius-card !important;
|
||||||
@@ -171,11 +140,4 @@
|
|||||||
border-radius: @radius-chip !important;
|
border-radius: @radius-chip !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__pagination {
|
|
||||||
margin-top: @space-4;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
264
ui/src/components/listings/ListingsOverview.jsx
Normal file
264
ui/src/components/listings/ListingsOverview.jsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useSearchParamState,
|
||||||
|
parseNumber,
|
||||||
|
parseString,
|
||||||
|
parseNullableBoolean,
|
||||||
|
} from '../../hooks/useSearchParamState.js';
|
||||||
|
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
||||||
|
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
||||||
|
import { xhrDelete, xhrPost } from '../../services/xhr.js';
|
||||||
|
import { useActions, useSelector } from '../../services/state/store.js';
|
||||||
|
import { debounce } from '../../utils';
|
||||||
|
import ListingsGrid from '../grid/listings/ListingsGrid.jsx';
|
||||||
|
import ListingsTable from '../table/ListingsTable.jsx';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
import './ListingsOverview.less';
|
||||||
|
|
||||||
|
const ListingsOverview = () => {
|
||||||
|
const listingsData = useSelector((state) => state.listingsData);
|
||||||
|
const providers = useSelector((state) => state.provider);
|
||||||
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const actions = useActions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
|
||||||
|
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
||||||
|
|
||||||
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
|
const pageSize = 40;
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
||||||
|
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
||||||
|
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
||||||
|
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||||
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
actions.listingsData.getListingsData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield: sortField,
|
||||||
|
sortdir: sortDir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((value) => {
|
||||||
|
setFreeTextFilter(value || null);
|
||||||
|
setPage(1);
|
||||||
|
}, 500),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
|
const handleWatch = async (e, item) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/watch', { listingId: item.id });
|
||||||
|
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to operate Watchlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
setListingToDelete(id);
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listings = listingsData?.result || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="listingsOverview">
|
||||||
|
<div className="listingsOverview__topbar">
|
||||||
|
<Input
|
||||||
|
className="listingsOverview__topbar__search"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
showClear
|
||||||
|
placeholder="Search"
|
||||||
|
defaultValue={freeTextFilter ?? ''}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="middle"
|
||||||
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">All</Radio>
|
||||||
|
<Radio value="true">Active</Radio>
|
||||||
|
<Radio value="false">Inactive</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="middle"
|
||||||
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">All</Radio>
|
||||||
|
<Radio value="true">Watched</Radio>
|
||||||
|
<Radio value="false">Unwatched</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Provider"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => {
|
||||||
|
setProviderFilter(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={providerFilter}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
>
|
||||||
|
{providers?.map((p) => (
|
||||||
|
<Select.Option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Job"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => {
|
||||||
|
setJobNameFilter(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={jobNameFilter}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
>
|
||||||
|
{jobs?.map((j) => (
|
||||||
|
<Select.Option key={j.id} value={j.id}>
|
||||||
|
{j.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
||||||
|
<Select.Option value="job_name">Job Name</Select.Option>
|
||||||
|
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||||
|
<Select.Option value="price">Price</Select.Option>
|
||||||
|
<Select.Option value="provider">Provider</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||||
|
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||||
|
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="listingsOverview__topbar__view-toggle">
|
||||||
|
<Tooltip content="Grid view">
|
||||||
|
<Button
|
||||||
|
icon={<IconGridView />}
|
||||||
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
aria-pressed={viewMode === 'grid'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Table view">
|
||||||
|
<Button
|
||||||
|
icon={<IconList />}
|
||||||
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
||||||
|
aria-label="Table view"
|
||||||
|
aria-pressed={viewMode === 'table'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listings.length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No listings available yet..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
|
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||||
|
) : (
|
||||||
|
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listings.length > 0 && (
|
||||||
|
<div className="listingsOverview__pagination">
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={listingsData?.totalNumber || 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingsOverview;
|
||||||
45
ui/src/components/listings/ListingsOverview.less
Normal file
45
ui/src/components/listings/ListingsOverview.less
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
|
.listingsOverview {
|
||||||
|
&__topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: @space-3;
|
||||||
|
margin-bottom: @space-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
min-width: 200px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.listingsOverview__topbar__search {
|
||||||
|
width: 100%;
|
||||||
|
flex: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-radio-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
margin-top: @space-4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
ui/src/components/table/ListingsTable.jsx
Normal file
132
ui/src/components/table/ListingsTable.jsx
Normal 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;
|
||||||
142
ui/src/components/table/ListingsTable.less
Normal file
142
ui/src/components/table/ListingsTable.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,6 +321,20 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setListingsViewMode(listings_view_mode) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: { ...state.userSettings.settings, listings_view_mode },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to update listings view mode setting. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
|
|
||||||
export default function Listings() {
|
export default function Listings() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Headline text="Listings" />
|
<Headline text="Listings" />
|
||||||
<ListingsGrid />
|
<ListingsOverview />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user