From f60c5859f920e5da69dc67902806c797bcc853f1 Mon Sep 17 00:00:00 2001 From: datenwurm <41708465+datenwurm@users.noreply.github.com> Date: Thu, 7 May 2026 12:12:49 +0200 Subject: [PATCH] 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 --- lib/api/routes/userSettingsRoute.js | 17 + .../components/grid/listings/ListingsGrid.jsx | 423 +++++------------- .../grid/listings/ListingsGrid.less | 272 +++++------ .../components/listings/ListingsOverview.jsx | 264 +++++++++++ .../components/listings/ListingsOverview.less | 45 ++ ui/src/components/table/ListingsTable.jsx | 132 ++++++ ui/src/components/table/ListingsTable.less | 142 ++++++ ui/src/services/state/store.js | 14 + ui/src/views/listings/Listings.jsx | 4 +- 9 files changed, 838 insertions(+), 475 deletions(-) create mode 100644 ui/src/components/listings/ListingsOverview.jsx create mode 100644 ui/src/components/listings/ListingsOverview.less create mode 100644 ui/src/components/table/ListingsTable.jsx create mode 100644 ui/src/components/table/ListingsTable.less 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 - - - - - - - - -
- - {(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}`); - }} - > -
- {item.title} { - 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()}> - -
-
- ))} -
- {(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); }} - /> -
- ); -}; + > +
+ {item.title} { + 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()}> + +
+
+ ))} +
+); 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 + + + + + + + + +
+
+ + {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); + }} + > +
+ {item.title} { + 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()}> + + +
+
+ ))} +
+); + +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 ( <> - + ); }