diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 294dadd..03dd0f2 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -109,4 +109,21 @@ export default async function userSettingsPlugin(fastify) {
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 });
+ }
+ });
}
diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx
index 2505649..e76e962 100644
--- a/ui/src/components/grid/listings/ListingsGrid.jsx
+++ b/ui/src/components/grid/listings/ListingsGrid.jsx
@@ -3,14 +3,7 @@
* 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 { Button, Tooltip } from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
@@ -19,323 +12,117 @@ import {
IconMapPin,
IconStar,
IconStarStroked,
- IconSearch,
IconEyeOpened,
- IconArrowUp,
- IconArrowDown,
} 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 * 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 { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
-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 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 (
-
-
- }
- showClear
- placeholder="Search"
- defaultValue={freeTextFilter ?? ''}
- onChange={handleFilterChange}
- />
-
- {
- const v = e.target.value;
- setActivityFilter(v === 'all' ? null : v === 'true');
- setPage(1);
- }}
- >
- All
- Active
- Inactive
-
-
- {
- const v = e.target.value;
- setWatchListFilter(v === 'all' ? null : v === 'true');
- setPage(1);
- }}
- >
- All
- Watched
- Unwatched
-
-
-
-
-
-
-
-
- : }
- onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
- title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
- />
-
-
- {(listingsData?.result || []).length === 0 && (
-
}
- darkModeImage={
}
- description="No listings available yet..."
- />
- )}
-
- {(listingsData?.result || []).map((item) => (
-
navigate(`/listings/listing/${item.id}`)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') navigate(`/listings/listing/${item.id}`);
- }}
- >
-
-

{
- e.target.src = no_image;
- }}
- />
- {!item.is_active && (
-
- Inactive
-
- )}
-
-
-
-
-
- {item.title}
-
- {item.price && (
-
-
- {item.price}
-
- )}
- {item.address && (
-
-
- {item.address}
-
- )}
-
-
- {item.provider}
-
-
{timeService.format(item.created_at, false)}
-
-
-
e.stopPropagation()}>
-
- }
- style={{ color: '#60a5fa' }}
- theme="borderless"
- onClick={(e) => {
- e.stopPropagation();
- window.open(item.link, '_blank');
- }}
- />
-
-
- }
- style={{ color: '#34d399' }}
- theme="borderless"
- onClick={(e) => {
- e.stopPropagation();
- navigate(`/listings/listing/${item.id}`);
- }}
- />
-
-
- }
- style={{ color: '#fb7185' }}
- theme="borderless"
- onClick={(e) => {
- e.stopPropagation();
- setListingToDelete(item.id);
- setDeleteModalVisible(true);
- }}
- />
-
-
-
- ))}
-
- {(listingsData?.result || []).length > 0 && (
-
- )}
-
{
- setDeleteModalVisible(false);
- setListingToDelete(null);
+/**
+ * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
+ */
+const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
+
+ {listings.map((item) => (
+
onNavigate(item.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
- />
-
- );
-};
+ >
+
+

{
+ e.target.src = no_image;
+ }}
+ />
+ {!item.is_active && (
+
+ Inactive
+
+ )}
+
+
+
+
+
+ {item.title}
+
+ {item.price && (
+
+
+ {item.price}
+
+ )}
+ {item.address && (
+
+
+ {item.address}
+
+ )}
+
+
+ {item.provider}
+
+
{timeService.format(item.created_at, false)}
+
+
+
e.stopPropagation()}>
+
+ }
+ style={{ color: '#60a5fa' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ window.open(item.link, '_blank');
+ }}
+ />
+
+
+ }
+ style={{ color: '#34d399' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onNavigate(item.id);
+ }}
+ />
+
+
+ }
+ style={{ color: '#fb7185' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(item.id);
+ }}
+ />
+
+
+
+ ))}
+
+);
export default ListingsGrid;
diff --git a/ui/src/components/grid/listings/ListingsGrid.less b/ui/src/components/grid/listings/ListingsGrid.less
index 9f85aac..66aa204 100644
--- a/ui/src/components/grid/listings/ListingsGrid.less
+++ b/ui/src/components/grid/listings/ListingsGrid.less
@@ -1,181 +1,143 @@
@import '../../../tokens.less';
-.listingsGrid {
- &__topbar {
+.listingsGrid__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.listingsGrid__card {
+ background: @color-elevated !important;
+ border: 1px solid @color-border !important;
+ border-radius: @radius-card !important;
+ overflow: hidden;
+ transition: transform @transition-card, box-shadow @transition-card;
+ display: flex;
+ flex-direction: column;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
+ }
+
+ &__image-wrapper {
+ position: relative;
+ height: 160px;
+ overflow: hidden;
+ flex-shrink: 0;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+
+ &__inactive-watermark {
+ position: absolute;
+ inset: 0;
display: flex;
align-items: center;
- gap: @space-3;
- margin-bottom: @space-4;
- flex-wrap: wrap;
+ justify-content: center;
+ background: rgba(0,0,0,0.35);
- &__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;
- }
+ span {
+ font-size: 18px;
+ font-weight: 800;
+ color: rgba(251,113,133,0.9);
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ transform: rotate(-30deg);
+ border: 2px solid rgba(251,113,133,0.5);
+ padding: 4px 12px;
+ border-radius: @radius-chip;
+ backdrop-filter: blur(2px);
}
}
- &__grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
- gap: 12px;
- }
-
- &__card {
- background: @color-elevated !important;
- border: 1px solid @color-border !important;
- border-radius: @radius-card !important;
- overflow: hidden;
- transition: transform @transition-card, box-shadow @transition-card;
+ &__star {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: rgba(0,0,0,0.5);
+ border: none;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
display: flex;
- flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background @transition-fast;
+ padding: 0;
&:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
+ background: rgba(0,0,0,0.75);
}
- &__image-wrapper {
- position: relative;
- height: 160px;
- overflow: hidden;
- flex-shrink: 0;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
+ svg {
+ color: @color-accent;
+ font-size: 14px;
}
+ }
- &__inactive-watermark {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0,0,0,0.35);
+ &__body {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ }
- span {
- font-size: 18px;
- font-weight: 800;
- color: rgba(251,113,133,0.9);
- text-transform: uppercase;
- letter-spacing: 0.15em;
- transform: rotate(-30deg);
- border: 2px solid rgba(251,113,133,0.5);
- padding: 4px 12px;
- border-radius: @radius-chip;
- backdrop-filter: blur(2px);
- }
- }
+ &__title {
+ font-weight: 700;
+ font-size: @text-sm;
+ color: @color-text;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
- &__star {
- position: absolute;
- top: 8px;
- right: 8px;
- background: rgba(0,0,0,0.5);
- border: none;
- border-radius: 50%;
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: background @transition-fast;
- padding: 0;
+ &__price {
+ font-size: @text-base;
+ font-weight: 600;
+ color: @color-success;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
- &:hover {
- background: rgba(0,0,0,0.75);
- }
+ &__meta {
+ font-size: @text-xs;
+ color: @color-muted;
+ display: flex;
+ align-items: center;
+ gap: 4px;
- svg {
- color: @color-accent;
- font-size: 14px;
- }
- }
-
- &__body {
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 4px;
- flex: 1;
- }
-
- &__title {
- font-weight: 700;
- font-size: @text-sm;
- color: @color-text;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- &__price {
- font-size: @text-base;
- font-weight: 600;
- color: @color-success;
- display: flex;
- align-items: center;
- gap: 4px;
- }
-
- &__meta {
- font-size: @text-xs;
- color: @color-muted;
- display: flex;
- align-items: center;
- gap: 4px;
-
- .semi-icon {
- font-size: 11px;
- color: @color-faint;
- }
- }
-
- &__provider {
- font-size: @text-xs;
+ .semi-icon {
+ font-size: 11px;
color: @color-faint;
}
+ }
- &__actions {
- display: flex;
- justify-content: space-around;
- padding: 8px 12px;
- border-top: 1px solid @color-border;
- gap: 4px;
- margin-top: auto;
+ &__provider {
+ font-size: @text-xs;
+ color: @color-faint;
+ }
- button {
- flex: 1;
- border: none !important;
- border-radius: @radius-chip !important;
- }
+ &__actions {
+ display: flex;
+ justify-content: space-around;
+ padding: 8px 12px;
+ border-top: 1px solid @color-border;
+ gap: 4px;
+ margin-top: auto;
+
+ button {
+ flex: 1;
+ border: none !important;
+ border-radius: @radius-chip !important;
}
}
-
- &__pagination {
- margin-top: @space-4;
- display: flex;
- justify-content: center;
- }
}
diff --git a/ui/src/components/listings/ListingsOverview.jsx b/ui/src/components/listings/ListingsOverview.jsx
new file mode 100644
index 0000000..eb9c8f6
--- /dev/null
+++ b/ui/src/components/listings/ListingsOverview.jsx
@@ -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 (
+
+
+
}
+ showClear
+ placeholder="Search"
+ defaultValue={freeTextFilter ?? ''}
+ onChange={handleFilterChange}
+ />
+
+
{
+ const v = e.target.value;
+ setActivityFilter(v === 'all' ? null : v === 'true');
+ setPage(1);
+ }}
+ >
+ All
+ Active
+ Inactive
+
+
+
{
+ const v = e.target.value;
+ setWatchListFilter(v === 'all' ? null : v === 'true');
+ setPage(1);
+ }}
+ >
+ All
+ Watched
+ Unwatched
+
+
+
+
+
+
+
+
+
:
}
+ onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
+ title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
+ />
+
+
+
+ }
+ theme={viewMode === 'grid' ? 'solid' : 'borderless'}
+ onClick={() => actions.userSettings.setListingsViewMode('grid')}
+ aria-label="Grid view"
+ aria-pressed={viewMode === 'grid'}
+ />
+
+
+ }
+ theme={viewMode === 'table' ? 'solid' : 'borderless'}
+ onClick={() => actions.userSettings.setListingsViewMode('table')}
+ aria-label="Table view"
+ aria-pressed={viewMode === 'table'}
+ />
+
+
+
+
+ {listings.length === 0 && (
+
}
+ darkModeImage={
}
+ description="No listings available yet..."
+ />
+ )}
+
+ {viewMode === 'grid' ? (
+
+ ) : (
+
+ )}
+
+ {listings.length > 0 && (
+
+ )}
+
+
{
+ setDeleteModalVisible(false);
+ setListingToDelete(null);
+ }}
+ />
+
+ );
+};
+
+export default ListingsOverview;
diff --git a/ui/src/components/listings/ListingsOverview.less b/ui/src/components/listings/ListingsOverview.less
new file mode 100644
index 0000000..5fb2f8f
--- /dev/null
+++ b/ui/src/components/listings/ListingsOverview.less
@@ -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;
+ }
+}
diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx
new file mode 100644
index 0000000..60faaf6
--- /dev/null
+++ b/ui/src/components/table/ListingsTable.jsx
@@ -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 }) => (
+
+ {listings.map((item) => (
+
onNavigate(item.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
+ }}
+ >
+
+

{
+ e.target.src = no_image;
+ }}
+ />
+
+
+
+ {item.title}
+
+
+
+ {item.price ? (
+ <>
+
+ {item.price}
+ >
+ ) : (
+ —
+ )}
+
+
+
+ {item.address ? (
+ <>
+
+ {item.address}
+ >
+ ) : (
+ —
+ )}
+
+
+
+
+ {item.provider}
+
+
+
{timeService.format(item.created_at, false)}
+
+
e.stopPropagation()}>
+
+
+ }
+ style={{ color: '#60a5fa' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ window.open(item.link, '_blank');
+ }}
+ />
+
+
+ }
+ style={{ color: '#34d399' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onNavigate(item.id);
+ }}
+ />
+
+
+ }
+ style={{ color: '#fb7185' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(item.id);
+ }}
+ />
+
+
+
+ ))}
+
+);
+
+export default ListingsTable;
diff --git a/ui/src/components/table/ListingsTable.less b/ui/src/components/table/ListingsTable.less
new file mode 100644
index 0000000..5e19426
--- /dev/null
+++ b/ui/src/components/table/ListingsTable.less
@@ -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;
+ }
+ }
+ }
+}
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index 8a4d174..5de6ec4 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -321,6 +321,20 @@ export const useFredyState = create(
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;
+ }
+ },
},
};
diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx
index 5d057c5..b9d744b 100644
--- a/ui/src/views/listings/Listings.jsx
+++ b/ui/src/views/listings/Listings.jsx
@@ -3,14 +3,14 @@
* 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';
export default function Listings() {
return (
<>
-
+
>
);
}