diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
index 7328e53..8f2ae22 100644
--- a/lib/api/routes/listingsRouter.js
+++ b/lib/api/routes/listingsRouter.js
@@ -26,6 +26,7 @@ export default async function listingsPlugin(fastify) {
providerFilter,
watchListFilter,
statusFilter,
+ hiddenOnly,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
@@ -38,6 +39,7 @@ export default async function listingsPlugin(fastify) {
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
+ const normalizedHidden = toBool(hiddenOnly) === true;
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
@@ -62,6 +64,7 @@ export default async function listingsPlugin(fastify) {
providerFilter,
watchListFilter: normalizedWatch,
statusFilter: normalizedStatus,
+ hiddenOnly: normalizedHidden,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser,
@@ -192,4 +195,21 @@ export default async function listingsPlugin(fastify) {
}
return reply.send();
});
+
+ fastify.post('/restore', async (request, reply) => {
+ const { ids } = request.body || {};
+ const settings = await getSettings();
+ try {
+ if (settings.demoMode && !isAdminFn(request)) {
+ return reply.code(403).send({ error: 'Sorry, but you cannot restore listings in demo mode ;)' });
+ }
+ if (Array.isArray(ids) && ids.length > 0) {
+ listingStorage.restoreListingsById(ids);
+ }
+ } catch (error) {
+ logger.error(error);
+ return reply.code(500).send({ error: error.message });
+ }
+ return reply.send();
+ });
}
diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js
index d409fcf..f00c7f7 100755
--- a/lib/services/storage/listingsStorage.js
+++ b/lib/services/storage/listingsStorage.js
@@ -264,6 +264,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
+ * @param {boolean} [params.hiddenOnly=false] - When true, returns only soft-deleted (manually_deleted = 1) listings.
* @returns {{ totalNumber:number, page:number, result:Object[] }}
*/
export const queryListings = ({
@@ -284,6 +285,7 @@ export const queryListings = ({
maxPrice = null,
userId = null,
isAdmin = false,
+ hiddenOnly = false,
} = {}) => {
// sanitize inputs
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
@@ -365,8 +367,8 @@ export const queryListings = ({
whereParts.push('(l.price <= @maxPrice)');
}
- // Build whereSql (filtering by manually_deleted = 0)
- whereParts.push('(l.manually_deleted = 0)');
+ // Build whereSql: in normal mode hide soft-deleted; in hiddenOnly mode show only soft-deleted.
+ whereParts.push(hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)');
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
@@ -463,6 +465,23 @@ export const deleteListingsById = (ids, hardDelete = false) => {
);
};
+/**
+ * Restore previously soft-deleted listings by clearing their `manually_deleted` flag.
+ *
+ * @param {string[]} ids - Array of DB row IDs to restore.
+ * @returns {any} The result from SqliteConnection.execute.
+ */
+export const restoreListingsById = (ids) => {
+ if (!Array.isArray(ids) || ids.length === 0) return;
+ const placeholders = ids.map(() => '?').join(',');
+ return SqliteConnection.execute(
+ `UPDATE listings
+ SET manually_deleted = 0
+ WHERE id IN (${placeholders})`,
+ ids,
+ );
+};
+
/**
* Return all listings that are active, have an address, and do not yet have geocoordinates.
*
diff --git a/package.json b/package.json
index e5df070..68461f2 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "22.7.0",
+ "version": "22.8.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
diff --git a/test/storage/listingStatus.test.js b/test/storage/listingStatus.test.js
index 6e21fcf..a732162 100644
--- a/test/storage/listingStatus.test.js
+++ b/test/storage/listingStatus.test.js
@@ -120,6 +120,57 @@ describe('listingsStorage.queryListings statusFilter', () => {
});
});
+describe('listingsStorage.queryListings hiddenOnly', () => {
+ let listingsStorage;
+
+ beforeEach(async () => {
+ calls.execute.length = 0;
+ calls.query.length = 0;
+ sqliteMock.__queryHandler = (sql) => {
+ if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
+ return [];
+ };
+ listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
+ });
+
+ it('filters by manually_deleted = 0 by default', () => {
+ listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
+ const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
+ expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 0\)/);
+ });
+
+ it('filters by manually_deleted = 1 when hiddenOnly is true', () => {
+ listingsStorage.queryListings({ userId: 'u1', isAdmin: true, hiddenOnly: true });
+ const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
+ expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 1\)/);
+ expect(pageQuery.sql).not.toMatch(/\(l\.manually_deleted = 0\)/);
+ });
+});
+
+describe('listingsStorage.restoreListingsById', () => {
+ let listingsStorage;
+
+ beforeEach(async () => {
+ calls.execute.length = 0;
+ calls.query.length = 0;
+ sqliteMock.__queryHandler = null;
+ listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
+ });
+
+ it('clears the manually_deleted flag for the given ids', () => {
+ listingsStorage.restoreListingsById(['a', 'b']);
+ expect(calls.execute).toHaveLength(1);
+ expect(calls.execute[0].sql).toMatch(/UPDATE listings\s+SET manually_deleted = 0\s+WHERE id IN \(\?,\?\)/);
+ expect(calls.execute[0].params).toEqual(['a', 'b']);
+ });
+
+ it('is a no-op when ids are missing or empty', () => {
+ listingsStorage.restoreListingsById([]);
+ listingsStorage.restoreListingsById(undefined);
+ expect(calls.execute).toHaveLength(0);
+ });
+});
+
describe('listingsStorage.getListingById', () => {
let listingsStorage;
diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx
index 1f3ab15..c6ea39a 100644
--- a/ui/src/components/grid/listings/ListingsGrid.jsx
+++ b/ui/src/components/grid/listings/ListingsGrid.jsx
@@ -22,9 +22,9 @@ import './ListingsGrid.less';
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
/**
- * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
+ * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/
-const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
+const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => {
const t = useTranslation();
const locale = useLocale();
return (
@@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}}
/>
-
- }
- style={{ color: '#fb7185' }}
- theme="borderless"
- onClick={(e) => {
- e.stopPropagation();
- onDelete(item.id);
- }}
- />
-
+ {isHiddenView ? (
+
+
+
+
+ }
+ style={{ color: '#34d399' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onRestore?.(item.id);
+ }}
+ aria-label={t('listings.tooltipUndelete')}
+ />
+
+ ) : (
+
+ }
+ style={{ color: '#fb7185' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(item.id);
+ }}
+ />
+
+ )}
))}
diff --git a/ui/src/components/grid/listings/ListingsGrid.less b/ui/src/components/grid/listings/ListingsGrid.less
index 924aa08..659a64f 100644
--- a/ui/src/components/grid/listings/ListingsGrid.less
+++ b/ui/src/components/grid/listings/ListingsGrid.less
@@ -139,4 +139,23 @@
border-radius: @radius-chip !important;
}
}
+
+ &__strike {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: -2px;
+ right: -2px;
+ top: 50%;
+ height: 2px;
+ background: currentColor;
+ transform: rotate(-45deg);
+ pointer-events: none;
+ }
+ }
}
diff --git a/ui/src/components/listings/ListingsOverview.jsx b/ui/src/components/listings/ListingsOverview.jsx
index e910d27..bd0b37b 100644
--- a/ui/src/components/listings/ListingsOverview.jsx
+++ b/ui/src/components/listings/ListingsOverview.jsx
@@ -10,7 +10,18 @@ import {
parseString,
parseNullableBoolean,
} from '../../hooks/useSearchParamState.js';
-import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
+import {
+ Button,
+ Pagination,
+ Toast,
+ Input,
+ Select,
+ Empty,
+ Radio,
+ RadioGroup,
+ Tooltip,
+ Banner,
+} 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';
@@ -50,9 +61,12 @@ const ListingsOverview = ({ mode = 'all' }) => {
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
+ const [hiddenOnly, setHiddenOnly] = useSearchParamState(sp, 'hidden', false, parseNullableBoolean);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
+ const isHiddenView = hiddenOnly === true;
+
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
@@ -66,9 +80,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
filter: {
watchListFilter: effectiveWatchListFilter,
jobNameFilter,
- activityFilter,
+ activityFilter: isHiddenView ? null : activityFilter,
providerFilter,
statusFilter,
+ hiddenOnly: isHiddenView ? true : undefined,
},
});
};
@@ -85,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
jobNameFilter,
watchListFilter,
statusFilter,
+ hiddenOnly,
isWatchlistMode,
]);
@@ -138,7 +154,21 @@ const ListingsOverview = ({ mode = 'all' }) => {
setDeleteModalVisible(true);
};
- const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
+ const handleRestore = async (id) => {
+ try {
+ await actions.listingsData.restoreListings([id]);
+ Toast.success(t('listings.toastRestored'));
+ loadData();
+ } catch (e) {
+ console.error(e);
+ Toast.error(t('listings.toastRestoreError'));
+ }
+ };
+
+ const handleNavigate = (id) => {
+ if (isHiddenView) return;
+ navigate(`/listings/listing/${id}`);
+ };
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
try {
@@ -158,118 +188,161 @@ const ListingsOverview = ({ mode = 'all' }) => {
const listings = listingsData?.result || [];
+ const activityRadioValue = isHiddenView ? 'hidden' : activityFilter === null ? 'all' : String(activityFilter);
+
return (
-
}
- showClear
- placeholder={t('listings.searchPlaceholder')}
- defaultValue={freeTextFilter ?? ''}
- onChange={handleFilterChange}
- />
+
+
+ }
+ showClear
+ placeholder={t('listings.searchPlaceholder')}
+ defaultValue={freeTextFilter ?? ''}
+ onChange={handleFilterChange}
+ />
+
+
-
{
- const v = e.target.value;
- setActivityFilter(v === 'all' ? null : v === 'true');
- setPage(1);
- }}
- >
- {t('listings.filterAll')}
- {t('listings.filterActive')}
- {t('listings.filterInactive')}
-
+
+
+ {
+ const v = e.target.value;
+ if (v === 'hidden') {
+ setHiddenOnly(true);
+ setActivityFilter(null);
+ } else {
+ setHiddenOnly(false);
+ setActivityFilter(v === 'all' ? null : v === 'true');
+ }
+ setPage(1);
+ }}
+ >
+ {t('listings.filterAll')}
+ {t('listings.filterActive')}
+ {t('listings.filterInactive')}
+ {t('listings.filterHidden')}
+
+
+
{!isWatchlistMode && (
-
{
- const v = e.target.value;
- setWatchListFilter(v === 'all' ? null : v === 'true');
- setPage(1);
- }}
- >
- {t('listings.filterAll')}
- {t('listings.filterWatched')}
- {t('listings.filterUnwatched')}
-
+
+
+ {
+ const v = e.target.value;
+ setWatchListFilter(v === 'all' ? null : v === 'true');
+ setPage(1);
+ }}
+ >
+ {t('listings.filterAll')}
+ {t('listings.filterWatched')}
+ {t('listings.filterUnwatched')}
+
+
+
)}
-
{
- setStatusFilter(val ?? null);
- setPage(1);
- }}
- value={statusFilter}
- style={{ width: 150 }}
- >
- {t('listings.filterStatusApplied')}
- {t('listings.filterStatusRejected')}
- {t('listings.filterStatusAccepted')}
- {t('listings.filterStatusNone')}
-
+
+
+ {
+ setStatusFilter(val ?? null);
+ setPage(1);
+ }}
+ value={statusFilter}
+ style={{ width: 150 }}
+ >
+ {t('listings.filterStatusApplied')}
+ {t('listings.filterStatusRejected')}
+ {t('listings.filterStatusAccepted')}
+ {t('listings.filterStatusNone')}
+
+
+
-
{
- setProviderFilter(val);
- setPage(1);
- }}
- value={providerFilter}
- style={{ width: 130 }}
- >
- {providers?.map((p) => (
-
- {p.name}
-
- ))}
-
+
+
+ {
+ setProviderFilter(val);
+ setPage(1);
+ }}
+ value={providerFilter}
+ style={{ width: 130 }}
+ >
+ {providers?.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
-
{
- setJobNameFilter(val);
- setPage(1);
- }}
- value={jobNameFilter}
- style={{ width: 130 }}
- >
- {jobs?.map((j) => (
-
- {j.name}
-
- ))}
-
+
+
+ {
+ setJobNameFilter(val);
+ setPage(1);
+ }}
+ value={jobNameFilter}
+ style={{ width: 130 }}
+ >
+ {jobs?.map((j) => (
+
+ {j.name}
+
+ ))}
+
+
+
-
setSortField(val)}
- >
- {t('listings.sortByJobName')}
- {t('listings.sortByDate')}
- {t('listings.sortByPrice')}
- {t('listings.sortByProvider')}
-
+
+
+ setSortField(val)}
+ >
+ {t('listings.sortByJobName')}
+ {t('listings.sortByDate')}
+ {t('listings.sortByPrice')}
+ {t('listings.sortByProvider')}
+
+
+
-
:
}
- onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
- title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
- />
+
+
+ : }
+ onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
+ aria-label={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
+ />
+
+
@@ -293,6 +366,16 @@ const ListingsOverview = ({ mode = 'all' }) => {
+ {isHiddenView && (
+
+ )}
+
{listings.length === 0 && (
}
@@ -307,6 +390,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
+ onRestore={handleRestore}
+ isHiddenView={isHiddenView}
onStatusChange={handleStatusChange}
/>
) : (
@@ -315,6 +400,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
+ onRestore={handleRestore}
+ isHiddenView={isHiddenView}
onStatusChange={handleStatusChange}
/>
)}
diff --git a/ui/src/components/listings/ListingsOverview.less b/ui/src/components/listings/ListingsOverview.less
index fe9b137..17efff0 100644
--- a/ui/src/components/listings/ListingsOverview.less
+++ b/ui/src/components/listings/ListingsOverview.less
@@ -8,6 +8,15 @@
margin-bottom: @space-4;
flex-wrap: wrap;
+ &__tooltipWrap {
+ display: inline-flex;
+ align-items: center;
+
+ > * {
+ width: 100%;
+ }
+ }
+
&__search {
min-width: 200px;
flex: 1;
diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx
index 1447dc7..0605785 100644
--- a/ui/src/components/table/ListingsTable.jsx
+++ b/ui/src/components/table/ListingsTable.jsx
@@ -22,9 +22,17 @@ import './ListingsTable.less';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
/**
- * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
+ * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/
-const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
+const ListingsTable = ({
+ listings,
+ onWatch,
+ onNavigate,
+ onDelete,
+ onRestore,
+ isHiddenView = false,
+ onStatusChange,
+}) => {
const t = useTranslation();
const locale = useLocale();
return (
@@ -123,18 +131,38 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}}
/>
-
- }
- style={{ color: '#fb7185' }}
- theme="borderless"
- onClick={(e) => {
- e.stopPropagation();
- onDelete(item.id);
- }}
- />
-
+ {isHiddenView ? (
+
+
+
+
+ }
+ style={{ color: '#34d399' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onRestore?.(item.id);
+ }}
+ aria-label={t('listings.tooltipUndelete')}
+ />
+
+ ) : (
+
+ }
+ style={{ color: '#fb7185' }}
+ theme="borderless"
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(item.id);
+ }}
+ />
+
+ )}
))}
diff --git a/ui/src/components/table/ListingsTable.less b/ui/src/components/table/ListingsTable.less
index 160667b..9bcf8fc 100644
--- a/ui/src/components/table/ListingsTable.less
+++ b/ui/src/components/table/ListingsTable.less
@@ -5,6 +5,25 @@
flex-direction: column;
gap: 4px;
+ &__strike {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: -2px;
+ right: -2px;
+ top: 50%;
+ height: 2px;
+ background: currentColor;
+ transform: rotate(-45deg);
+ pointer-events: none;
+ }
+ }
+
&__row {
display: grid;
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json
index d77a514..67ecda3 100644
--- a/ui/src/locales/de.json
+++ b/ui/src/locales/de.json
@@ -135,6 +135,7 @@
"listings.filterAll": "Alle",
"listings.filterActive": "Aktiv",
"listings.filterInactive": "Inaktiv",
+ "listings.filterHidden": "Versteckt",
"listings.filterWatched": "Beobachtet",
"listings.filterUnwatched": "Nicht beobachtet",
"listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "Kein Status",
"listings.filterProviderPlaceholder": "Anbieter",
"listings.filterJobPlaceholder": "Job",
+ "listings.filterSearchHelp": "Volltextsuche über Titel, Adresse, Anbieter und Link.",
+ "listings.filterActivityHelp": "Filtert nach Inseratsstatus: 'Alle' zeigt jedes Inserat, 'Aktiv' nur noch online verfügbare, 'Inaktiv' beim Anbieter verschwundene, 'Versteckt' zeigt deine manuell gelöschten (soft-deleted) Inserate, damit du sie wiederherstellen kannst.",
+ "listings.filterWatchHelp": "Filtert nach Watchlist-Zugehörigkeit: 'Alle' zeigt jedes Inserat, 'Beobachtet' nur die auf deiner Watchlist gespeicherten, 'Nicht beobachtet' die anderen.",
+ "listings.filterStatusHelp": "Filtert nach dem persönlichen Status (Beworben, Abgelehnt, Angenommen) oder zeigt nur Inserate ohne Status.",
+ "listings.filterProviderHelp": "Zeigt nur Inserate des ausgewählten Anbieters (ImmoScout24, Kleinanzeigen, ...).",
+ "listings.filterJobHelp": "Zeigt nur Inserate des ausgewählten Jobs.",
+ "listings.filterSortHelp": "Wählt das Sortierkriterium. Mit dem Pfeil-Button schaltet man zwischen aufsteigend und absteigend.",
+ "listings.hiddenViewBanner": "Du siehst gerade versteckte (soft-gelöschte) Inserate. Sie werden in den normalen Ansichten ausgeblendet. Über den Wiederherstellen-Button kannst du sie zurückholen.",
+ "listings.toastRestored": "Inserat wiederhergestellt",
+ "listings.toastRestoreError": "Wiederherstellung fehlgeschlagen",
+ "listings.tooltipUndelete": "Inserat wiederherstellen",
"listings.sortByJobName": "Job-Name",
"listings.sortByDate": "Inserat-Datum",
"listings.sortByPrice": "Preis",
diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json
index fcc62d9..ae5dbf8 100644
--- a/ui/src/locales/en.json
+++ b/ui/src/locales/en.json
@@ -135,6 +135,7 @@
"listings.filterAll": "All",
"listings.filterActive": "Active",
"listings.filterInactive": "Inactive",
+ "listings.filterHidden": "Hidden",
"listings.filterWatched": "Watched",
"listings.filterUnwatched": "Unwatched",
"listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "No status",
"listings.filterProviderPlaceholder": "Provider",
"listings.filterJobPlaceholder": "Job",
+ "listings.filterSearchHelp": "Free-text search across title, address, provider and link.",
+ "listings.filterActivityHelp": "Filter by listing activity: All shows every listing, Active only those still online, Inactive those that disappeared from the provider, Hidden shows your manually deleted (soft-deleted) listings so you can restore them.",
+ "listings.filterWatchHelp": "Filter by watchlist membership: All shows every listing, Watched only those you saved to your watchlist, Unwatched only those you have not saved.",
+ "listings.filterStatusHelp": "Filter by the personal status you set on a listing (Applied, Rejected, Accepted) or show only listings with no status yet.",
+ "listings.filterProviderHelp": "Show only listings coming from the selected real-estate provider (ImmoScout24, Kleinanzeigen, ...).",
+ "listings.filterJobHelp": "Show only listings produced by the selected job.",
+ "listings.filterSortHelp": "Choose the column to sort listings by. Use the arrow button to toggle ascending and descending order.",
+ "listings.hiddenViewBanner": "You are viewing hidden (soft-deleted) listings. They are excluded from the regular views. Use the restore button on a card to bring it back.",
+ "listings.toastRestored": "Listing restored",
+ "listings.toastRestoreError": "Failed to restore listing",
+ "listings.tooltipUndelete": "Restore Listing",
"listings.sortByJobName": "Job Name",
"listings.sortByDate": "Listing Date",
"listings.sortByPrice": "Price",
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index 20967fe..a9dc70b 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -276,6 +276,14 @@ export const useFredyState = create(
throw Exception;
}
},
+ async restoreListings(ids) {
+ try {
+ await xhrPost('/api/listings/restore', { ids });
+ } catch (Exception) {
+ console.error('Error while trying to restore listings. Error:', Exception);
+ throw Exception;
+ }
+ },
},
userSettings: {
async getUserSettings() {