mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
* improving footer * improve ui * upgrading dependencies * adding glow to all boxes on dashboard * introducing single listing view * next release version * improve screenshots and login page
349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
/*
|
|
* Copyright (c) 2026 by Christian Kellner.
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Card,
|
|
Col,
|
|
Row,
|
|
Image,
|
|
Button,
|
|
Space,
|
|
Typography,
|
|
Pagination,
|
|
Toast,
|
|
Divider,
|
|
Input,
|
|
Select,
|
|
Popover,
|
|
Empty,
|
|
} from '@douyinfe/semi-ui-19';
|
|
import {
|
|
IconBriefcase,
|
|
IconCart,
|
|
IconClock,
|
|
IconDelete,
|
|
IconLink,
|
|
IconMapPin,
|
|
IconStar,
|
|
IconStarStroked,
|
|
IconSearch,
|
|
IconFilter,
|
|
IconActivity,
|
|
IconEyeOpened,
|
|
} from '@douyinfe/semi-icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import no_image from '../../../assets/no_image.jpg';
|
|
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 'lodash/debounce';
|
|
|
|
import './ListingsGrid.less';
|
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
|
|
const { Text } = Typography;
|
|
|
|
const ListingsGrid = () => {
|
|
const listingsData = useSelector((state) => state.listingsData);
|
|
const providers = useSelector((state) => state.provider);
|
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
|
const actions = useActions();
|
|
const navigate = useNavigate();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 40;
|
|
|
|
const [sortField, setSortField] = useState('created_at');
|
|
const [sortDir, setSortDir] = useState('desc');
|
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
|
const [watchListFilter, setWatchListFilter] = useState(null);
|
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
|
const [activityFilter, setActivityFilter] = useState(null);
|
|
const [providerFilter, setProviderFilter] = useState(null);
|
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
|
|
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), 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 cap = (val) => {
|
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
|
};
|
|
|
|
return (
|
|
<div className="listingsGrid">
|
|
<div className="listingsGrid__searchbar">
|
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
|
<div>
|
|
<Button
|
|
icon={<IconFilter />}
|
|
onClick={() => {
|
|
setShowFilterBar(!showFilterBar);
|
|
}}
|
|
/>
|
|
</div>
|
|
</Popover>
|
|
</div>
|
|
{showFilterBar && (
|
|
<div className="listingsGrid__toolbar">
|
|
<Space wrap style={{ marginBottom: '1rem' }}>
|
|
<div className="listingsGrid__toolbar__card">
|
|
<div>
|
|
<Text strong>Filter by:</Text>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
|
<Select
|
|
placeholder="Status"
|
|
showClear
|
|
onChange={(val) => setActivityFilter(val)}
|
|
value={activityFilter}
|
|
>
|
|
<Select.Option value={true}>Active</Select.Option>
|
|
<Select.Option value={false}>Not Active</Select.Option>
|
|
</Select>
|
|
|
|
<Select
|
|
placeholder="Watchlist"
|
|
showClear
|
|
onChange={(val) => setWatchListFilter(val)}
|
|
value={watchListFilter}
|
|
>
|
|
<Select.Option value={true}>Watched</Select.Option>
|
|
<Select.Option value={false}>Not Watched</Select.Option>
|
|
</Select>
|
|
|
|
<Select
|
|
placeholder="Provider"
|
|
showClear
|
|
onChange={(val) => setProviderFilter(val)}
|
|
value={providerFilter}
|
|
>
|
|
{providers?.map((p) => (
|
|
<Select.Option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
|
|
<Select
|
|
placeholder="Job Name"
|
|
showClear
|
|
onChange={(val) => setJobNameFilter(val)}
|
|
value={jobNameFilter}
|
|
>
|
|
{jobs?.map((j) => (
|
|
<Select.Option key={j.id} value={j.id}>
|
|
{j.name}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<Divider layout="vertical" />
|
|
|
|
<div className="listingsGrid__toolbar__card">
|
|
<div>
|
|
<Text strong>Sort by:</Text>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
|
<Select
|
|
placeholder="Sort By"
|
|
style={{ width: 140 }}
|
|
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>
|
|
|
|
<Select
|
|
placeholder="Direction"
|
|
style={{ width: 120 }}
|
|
value={sortDir}
|
|
onChange={(val) => setSortDir(val)}
|
|
>
|
|
<Select.Option value="asc">Ascending</Select.Option>
|
|
<Select.Option value="desc">Descending</Select.Option>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
|
|
{(listingsData?.result || []).length === 0 && (
|
|
<Empty
|
|
image={<IllustrationNoResult />}
|
|
darkModeImage={<IllustrationNoResultDark />}
|
|
description="No listings available yet..."
|
|
/>
|
|
)}
|
|
<Row gutter={[16, 16]}>
|
|
{(listingsData?.result || []).map((item) => (
|
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
|
<Card
|
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
|
cover={
|
|
<div style={{ position: 'relative' }}>
|
|
<div className="listingsGrid__imageContainer">
|
|
<Image
|
|
src={item.image_url || no_image}
|
|
fallback={no_image}
|
|
width="100%"
|
|
height={180}
|
|
style={{ objectFit: 'cover' }}
|
|
preview={false}
|
|
/>
|
|
<Button
|
|
icon={
|
|
item.isWatched === 1 ? (
|
|
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
|
) : (
|
|
<IconStarStroked />
|
|
)
|
|
}
|
|
theme="light"
|
|
shape="circle"
|
|
size="small"
|
|
className="listingsGrid__watchButton"
|
|
onClick={(e) => handleWatch(e, item)}
|
|
/>
|
|
</div>
|
|
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
|
|
</div>
|
|
}
|
|
bodyStyle={{ padding: '12px' }}
|
|
>
|
|
<div className="listingsGrid__content">
|
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
|
{cap(item.title)}
|
|
</Text>
|
|
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
|
<Text type="secondary" icon={<IconCart />} size="small">
|
|
{item.price} €
|
|
</Text>
|
|
<Text
|
|
type="secondary"
|
|
icon={<IconMapPin />}
|
|
size="small"
|
|
ellipsis={{ showTooltip: true }}
|
|
style={{ width: '100%' }}
|
|
>
|
|
{item.address || 'No address provided'}
|
|
</Text>
|
|
<Text type="tertiary" size="small" icon={<IconClock />}>
|
|
{timeService.format(item.created_at, false)}
|
|
</Text>
|
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
|
</Text>
|
|
{item.distance_to_destination ? (
|
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
|
{item.distance_to_destination} m to chosen address
|
|
</Text>
|
|
) : (
|
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
|
Distance cannot be calculated, provide an address
|
|
</Text>
|
|
)}
|
|
</Space>
|
|
<Divider margin=".6rem" />
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
|
<IconLink />
|
|
</a>
|
|
</div>
|
|
|
|
<Button
|
|
type="secondary"
|
|
size="small"
|
|
title="View Details"
|
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
|
icon={<IconEyeOpened />}
|
|
/>
|
|
|
|
<Button
|
|
title="Remove"
|
|
type="danger"
|
|
size="small"
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await xhrDelete('/api/listings/', { ids: [item.id] });
|
|
Toast.success('Listing(s) successfully removed');
|
|
loadData();
|
|
} catch (error) {
|
|
Toast.error(error);
|
|
}
|
|
}}
|
|
icon={<IconDelete />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
{(listingsData?.result || []).length > 0 && (
|
|
<div className="listingsGrid__pagination">
|
|
<Pagination
|
|
currentPage={page}
|
|
pageSize={pageSize}
|
|
total={listingsData?.totalNumber || 0}
|
|
onPageChange={handlePageChange}
|
|
showSizeChanger={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ListingsGrid;
|