{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
) : (
)}
{resolvedImages.length > 1 && currentImg && (
<>
-
@@ -87,10 +206,24 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite,
)}
-
@@ -98,11 +231,21 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite,
{isNew && !isBlacklistView &&
@@ -122,7 +265,16 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite,
)}
{l.address && (
)}
@@ -132,43 +284,118 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite,
);
diff --git a/client/src/components/ListingDetailDrawer.jsx b/client/src/components/ListingDetailDrawer.jsx
new file mode 100644
index 0000000..567b883
--- /dev/null
+++ b/client/src/components/ListingDetailDrawer.jsx
@@ -0,0 +1,433 @@
+import { useEffect, useMemo, useState } from 'react';
+import { api } from '../api.js';
+import { formatAvailableFrom } from '../utils/formatting.js';
+import ListingMap from './ListingMap.jsx';
+
+function Field({ label, value }) {
+ if (value === null || value === undefined || value === '') return null;
+ return (
+
+ );
+}
+
+function Feature({ label, value }) {
+ if (value === null || value === undefined) return null;
+ return
;
+}
+
+function formatBool(value) {
+ if (value === 1 || value === true) return 'Ja';
+ if (value === 0 || value === false) return 'Nein';
+ return value;
+}
+
+function findAttribute(detail, pattern) {
+ for (const group of detail?.attribute_groups ?? []) {
+ for (const attr of group.attributes ?? []) {
+ if (attr.label && pattern.test(attr.label)) {
+ return attr.value === true ? 'Ja' : attr.value;
+ }
+ }
+ }
+ return null;
+}
+
+function visibleAttributeGroups(detail) {
+ const covered = [
+ /warmmiete|kaltmiete|nebenkosten|kaution|preis\/m/i,
+ /wohnfläche|zimmer|verfügbar ab/i,
+ /etage|schlafzimmer|badezimmer|haustiere/i,
+ /art der unterkunft|mietart|rauchen|anzahl mitbewohner|online-besichtigung/i,
+ /einbauküche|küche|kühlschrank|backofen|herd|spülmaschine|keller|balkon|garten|aufzug|stufenlos/i,
+ ];
+
+ return (detail?.attribute_groups ?? [])
+ .map((group) => ({
+ ...group,
+ attributes: (group.attributes ?? []).filter((attr) => {
+ if (!attr?.label || attr.value === null || attr.value === undefined || attr.value === '') {
+ return false;
+ }
+ return !covered.some((pattern) => pattern.test(attr.label));
+ }),
+ }))
+ .filter((group) => group.attributes.length > 0);
+}
+
+function mapLinks(location) {
+ if (!location) return null;
+ const lat = Number(location.lat);
+ const lon = Number(location.lon);
+ if (Number.isFinite(lat) && Number.isFinite(lon)) {
+ const marker = `${lat.toFixed(6)},${lon.toFixed(6)}`;
+ return {
+ osmUrl: `https://www.openstreetmap.org/?mlat=${lat.toFixed(6)}&mlon=${lon.toFixed(6)}#map=15/${lat.toFixed(6)}/${lon.toFixed(6)}`,
+ googleUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(marker)}`,
+ };
+ }
+
+ const query = location.query || location.label;
+ if (!query) return null;
+ const encodedQuery = encodeURIComponent(query);
+ return {
+ osmUrl: `https://www.openstreetmap.org/search?query=${encodedQuery}`,
+ googleUrl: `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`,
+ };
+}
+
+function mapPrecisionLabel(precision) {
+ return {
+ exact: 'Exakte Lage',
+ street: 'Ungefähre Lage',
+ postcode: 'PLZ-Gebiet',
+ district: 'Stadtteil',
+ city: 'Stadtgebiet',
+ }[precision] ?? 'Kartenlage';
+}
+
+export default function ListingDetailDrawer({ listing, open, onClose, showToast }) {
+ const [loading, setLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [payload, setPayload] = useState(null);
+ const [mapLocation, setMapLocation] = useState(null);
+ const [mapLoading, setMapLoading] = useState(false);
+ const [mapError, setMapError] = useState('');
+ const [error, setError] = useState('');
+
+ const detail = payload?.detail ?? null;
+ const currentListing = payload?.listing ?? listing;
+ const links = mapLinks(mapLocation);
+ const detailError = payload?.detail_error ?? null;
+ const detailSize = findAttribute(detail, /wohnfläche|wohnflaeche|gesamtfläche/i);
+ const detailRooms = findAttribute(detail, /^zimmer$/i);
+ const extraGroups = visibleAttributeGroups(detail);
+
+ useEffect(() => {
+ if (!open || !listing?.id) return;
+ let cancelled = false;
+ setLoading(true);
+ setError('');
+ setPayload(null);
+ setMapLocation(null);
+ setMapError('');
+ setMapLoading(false);
+
+ api.listings
+ .getDetails(listing.id)
+ .then((data) => {
+ if (!cancelled) setPayload(data);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(err.message);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, listing?.id]);
+
+ useEffect(() => {
+ if (!open || !listing?.id || !payload) return;
+ let cancelled = false;
+ setMapLoading(true);
+ setMapError('');
+ setMapLocation(null);
+
+ api.listings
+ .getMapLocation(listing.id)
+ .then((data) => {
+ if (!cancelled) setMapLocation(data.map_location ?? null);
+ })
+ .catch((err) => {
+ if (!cancelled) setMapError(err.message);
+ })
+ .finally(() => {
+ if (!cancelled) setMapLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, listing?.id, payload]);
+
+ const images = useMemo(() => {
+ if (Array.isArray(detail?.images) && detail.images.length > 0) return detail.images;
+ try {
+ const parsed = currentListing?.images ? JSON.parse(currentListing.images) : [];
+ if (Array.isArray(parsed)) return parsed;
+ } catch {}
+ return currentListing?.image ? [currentListing.image] : [];
+ }, [detail, currentListing]);
+
+ if (!open || !listing) return null;
+
+ const handleRefresh = async () => {
+ setRefreshing(true);
+ setError('');
+ setMapLocation(null);
+ setMapError('');
+ try {
+ const data = await api.listings.refreshDetails(listing.id);
+ setPayload(data);
+ showToast?.('Detaildaten aktualisiert', 'success');
+ } catch (err) {
+ setError(err.message);
+ showToast?.(`Detailabruf fehlgeschlagen: ${err.message}`, 'error');
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/client/src/components/ListingMap.jsx b/client/src/components/ListingMap.jsx
new file mode 100644
index 0000000..cf186b1
--- /dev/null
+++ b/client/src/components/ListingMap.jsx
@@ -0,0 +1,97 @@
+import { useEffect, useRef } from 'react';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+
+function boundsFromBbox(bbox) {
+ if (!bbox) return null;
+ const { south, north, west, east } = bbox;
+ if (![south, north, west, east].every(Number.isFinite)) return null;
+ return [
+ [south, west],
+ [north, east],
+ ];
+}
+
+function radiusForPrecision(precision) {
+ return {
+ exact: 40,
+ street: 250,
+ postcode: 700,
+ district: 1000,
+ city: 2500,
+ }[precision] ?? 600;
+}
+
+export default function ListingMap({ location }) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current || !location) return undefined;
+
+ const map = L.map(containerRef.current, {
+ attributionControl: true,
+ scrollWheelZoom: false,
+ zoomControl: true,
+ });
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap',
+ }).addTo(map);
+
+ const shapeStyle = {
+ color: '#2563eb',
+ weight: 2,
+ opacity: 0.9,
+ fillColor: '#3b82f6',
+ fillOpacity: 0.16,
+ };
+
+ const lat = Number(location.lat);
+ const lon = Number(location.lon);
+ const hasPoint = Number.isFinite(lat) && Number.isFinite(lon);
+ const canRenderArea = ['postcode', 'district', 'city'].includes(location.precision);
+ let fitted = false;
+
+ if (canRenderArea && location.geometry_geojson) {
+ const layer = L.geoJSON(location.geometry_geojson, { style: shapeStyle }).addTo(map);
+ const bounds = layer.getBounds();
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [14, 14], maxZoom: 15 });
+ fitted = true;
+ }
+ } else {
+ const bboxBounds = canRenderArea ? boundsFromBbox(location.bbox) : null;
+ if (bboxBounds) {
+ const rectangle = L.rectangle(bboxBounds, shapeStyle).addTo(map);
+ map.fitBounds(rectangle.getBounds(), { padding: [14, 14], maxZoom: 15 });
+ fitted = true;
+ }
+ }
+
+ if (hasPoint) {
+ if (location.precision === 'exact') {
+ L.marker([lat, lon], {
+ icon: L.divIcon({
+ className: 'detail-map-pin',
+ iconSize: [18, 18],
+ }),
+ }).addTo(map);
+ } else if (!fitted) {
+ const circle = L.circle([lat, lon], {
+ ...shapeStyle,
+ radius: radiusForPrecision(location.precision),
+ }).addTo(map);
+ map.fitBounds(circle.getBounds(), { padding: [14, 14], maxZoom: 15 });
+ fitted = true;
+ }
+ }
+
+ if (!fitted && hasPoint) map.setView([lat, lon], location.precision === 'exact' ? 17 : 14);
+ setTimeout(() => map.invalidateSize(), 0);
+
+ return () => map.remove();
+ }, [location]);
+
+ return
;
+}
diff --git a/client/src/components/ListingsGrid.jsx b/client/src/components/ListingsGrid.jsx
index 3c5ceef..2024299 100644
--- a/client/src/components/ListingsGrid.jsx
+++ b/client/src/components/ListingsGrid.jsx
@@ -4,71 +4,149 @@ import Pagination from './Pagination.jsx';
import { api } from '../api.js';
function preloadImages(urls) {
- for (const url of urls) { const img = new Image(); img.src = url; }
+ for (const url of urls) {
+ const img = new Image();
+ img.src = url;
+ }
}
export default function ListingsGrid({
- listings, allCount, loading, page, pages, onPageChange,
- onSeen, onFavorite, onBlacklist, onUnblacklist, onScrape,
- canScrape, allFiltered, itemsPerPage, isBlacklistView,
+ listings,
+ allCount,
+ loading,
+ page,
+ pages,
+ onPageChange,
+ onSeen,
+ onFavorite,
+ onBlacklist,
+ onUnblacklist,
+ onScrape,
+ onDetails,
+ canScrape,
+ allFiltered,
+ itemsPerPage,
+ isBlacklistView,
}) {
const [imageCache, setImageCache] = useState({});
const fetchedRef = useRef(new Set());
const batchFetchImages = useCallback(async (listingsToFetch) => {
- const needFetch = listingsToFetch.filter(l => {
+ const needFetch = listingsToFetch.filter((l) => {
if (fetchedRef.current.has(l.id)) return false;
- try { const cached = l.images ? JSON.parse(l.images) : null; if (cached?.length) { setImageCache(prev => ({ ...prev, [l.id]: cached })); preloadImages([cached[0]]); fetchedRef.current.add(l.id); return false; } } catch {}
+ try {
+ const cached = l.images ? JSON.parse(l.images) : null;
+ if (cached?.length) {
+ setImageCache((prev) => ({ ...prev, [l.id]: cached }));
+ preloadImages([cached[0]]);
+ fetchedRef.current.add(l.id);
+ return false;
+ }
+ } catch {}
return true;
});
if (needFetch.length === 0) return;
for (const l of needFetch) fetchedRef.current.add(l.id);
try {
- const { results } = await api.listings.batchImages(needFetch.map(l => l.id));
- if (results) { setImageCache(prev => ({ ...prev, ...results })); for (const imgs of Object.values(results)) { if (imgs?.[0]) preloadImages([imgs[0]]); } }
+ const { results } = await api.listings.batchImages(needFetch.map((l) => l.id));
+ if (results) {
+ setImageCache((prev) => ({ ...prev, ...results }));
+ for (const imgs of Object.values(results)) {
+ if (imgs?.[0]) preloadImages([imgs[0]]);
+ }
+ }
} catch {}
}, []);
- useEffect(() => { if (listings.length > 0) batchFetchImages(listings); }, [listings, batchFetchImages]);
+ useEffect(() => {
+ if (listings.length > 0) batchFetchImages(listings);
+ }, [listings, batchFetchImages]);
useEffect(() => {
if (!allFiltered || !itemsPerPage || page >= pages) return;
const next = allFiltered.slice(page * itemsPerPage, (page + 1) * itemsPerPage);
- if (next.length > 0) { const t = setTimeout(() => batchFetchImages(next), 300); return () => clearTimeout(t); }
+ if (next.length > 0) {
+ const t = setTimeout(() => batchFetchImages(next), 300);
+ return () => clearTimeout(t);
+ }
}, [page, pages, allFiltered, itemsPerPage, batchFetchImages]);
- if (loading) return
-
-
+ if (allCount === 0)
+ return (
+
+
+
Keine Anzeigen gefunden
+
Erstelle eine Suchkonfiguration und starte einen Scraping-Lauf.
+
+ {canScrape ? 'Jetzt scrapen' : 'Kein aktiver Agent'}
+
-
Keine Anzeigen gefunden
-
Erstelle eine Suchkonfiguration und starte einen Scraping-Lauf.
-
- {canScrape ? 'Jetzt scrapen' : 'Kein aktiver Agent'}
-
-
- );
+ );
return (
-
{allCount} Ergebnis{allCount !== 1 ? 'se' : ''} · Seite {page}/{pages}
+
+ {allCount} Ergebnis{allCount !== 1 ? 'se' : ''}{' '}
+ · Seite {page}/{pages}
+
{listings.map((l) => (
-
+
))}
- {pages > 1 &&
}
+ {pages > 1 && (
+
+ )}
);
}
diff --git a/client/src/hooks/useListings.js b/client/src/hooks/useListings.js
index 70b8502..c2bf2ba 100644
--- a/client/src/hooks/useListings.js
+++ b/client/src/hooks/useListings.js
@@ -68,19 +68,11 @@ export function useListings(showToast) {
await Promise.all([loadStats(), loadConfigStats()]);
}, [loadStats, loadConfigStats]);
- const handleSeen = useCallback(async (id) => {
- const current = listings.find((l) => l.id === id);
- if (!current) return;
+ const applySeenState = useCallback((current, isSeen) => {
+ if (!current || Boolean(current.is_seen) === Boolean(isSeen)) return;
- const willBeSeen = !current.is_seen;
- if (willBeSeen) {
- await api.listings.markSeen(id);
- } else {
- await api.listings.markUnseen(id);
- }
-
- setListings((prev) => prev.map((l) => (l.id === id ? { ...l, is_seen: willBeSeen ? 1 : 0 } : l)));
- const delta = willBeSeen ? -1 : 1;
+ setListings((prev) => prev.map((l) => (l.id === current.id ? { ...l, is_seen: isSeen ? 1 : 0 } : l)));
+ const delta = isSeen ? -1 : 1;
setStats((prev) => ({ ...prev, unseen: Math.max(0, (prev.unseen ?? 0) + delta) }));
const agentIds = current.agent_ids || [];
if (agentIds.length > 0) {
@@ -94,7 +86,34 @@ export function useListings(showToast) {
return next;
});
}
- }, [listings]);
+ }, []);
+
+ const handleSeen = useCallback(async (id) => {
+ const current = listings.find((l) => l.id === id);
+ if (!current) return;
+
+ const willBeSeen = !current.is_seen;
+ if (willBeSeen) {
+ await api.listings.markSeen(id);
+ } else {
+ await api.listings.markUnseen(id);
+ }
+
+ applySeenState(current, willBeSeen);
+ }, [applySeenState, listings]);
+
+ const handleMarkSeen = useCallback(async (id) => {
+ const current = listings.find((l) => l.id === id);
+ if (!current || current.is_seen) return;
+
+ applySeenState(current, true);
+ try {
+ await api.listings.markSeen(id);
+ } catch (err) {
+ applySeenState({ ...current, is_seen: 1 }, false);
+ throw err;
+ }
+ }, [applySeenState, listings]);
const handleFavorite = useCallback(async (id) => {
const current = listings.find((l) => l.id === id);
@@ -207,7 +226,7 @@ export function useListings(showToast) {
return {
listings, setListings, loading, stats, configStats, orphanStats,
loadListings, loadStats, loadConfigStats,
- handleSeen, handleFavorite, handleBlacklist, handleUnblacklist,
+ handleSeen, handleMarkSeen, handleFavorite, handleBlacklist, handleUnblacklist,
handleMarkAllSeen, handleReset, handleResetConfig,
handleClearFavorites, handleClearFavoritesByConfig,
handleClearBlacklist, handleClearBlacklistByConfig,
diff --git a/client/src/index.css b/client/src/index.css
index 0371106..58d9886 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -4,7 +4,7 @@
/* ── Design Tokens ─────────────────────────────────────────── */
:root {
- --primary-50: #eef2ff;
+ --primary-50: #eef2ff;
--primary-100: #e0e7ff;
--primary-200: #c7d2fe;
--primary-300: #a5b4fc;
@@ -12,7 +12,7 @@
--primary-500: #6366f1;
--primary-600: #4f46e5;
--primary-700: #4338ca;
- --gray-50: #f8fafc;
+ --gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
@@ -22,93 +22,295 @@
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
- --success-50: #ecfdf5;
+ --success-50: #ecfdf5;
--success-500: #10b981;
--success-600: #059669;
- --danger-50: #fef2f2;
- --danger-400: #f87171;
- --danger-500: #ef4444;
- --warning-50: #fffbeb;
+ --danger-50: #fef2f2;
+ --danger-400: #f87171;
+ --danger-500: #ef4444;
+ --warning-50: #fffbeb;
--warning-500: #f59e0b;
- --violet-50: #f5f3ff;
- --violet-100: #ede9fe;
- --violet-500: #8b5cf6;
- --violet-600: #7c3aed;
- --surface: #ffffff;
- --bg: #f8fafc;
- --border: #e2e8f0;
- --text: #0f172a;
+ --violet-50: #f5f3ff;
+ --violet-100: #ede9fe;
+ --violet-500: #8b5cf6;
+ --violet-600: #7c3aed;
+ --surface: #ffffff;
+ --bg: #f8fafc;
+ --border: #e2e8f0;
+ --text: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
- --header-h: 60px;
+ --header-h: 60px;
--sidebar-w: 400px;
--agent-sidebar-w: 316px;
--agent-sidebar-collapsed-w: 56px;
- --max-w: 1400px;
+ --max-w: 1400px;
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
- --shadow-xs: 0 1px 2px 0 rgba(0,0,0,.05);
- --shadow-sm: 0 1px 3px 0 rgba(0,0,0,.06), 0 1px 2px -1px rgba(0,0,0,.06);
- --shadow-md: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -2px rgba(0,0,0,.05);
- --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -4px rgba(0,0,0,.04);
- --shadow-xl: 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.06);
- --shadow-card: 0 0 0 1px rgba(0,0,0,.03), 0 2px 4px rgba(0,0,0,.04);
- --shadow-card-hover: 0 0 0 1px rgba(0,0,0,.03), 0 8px 24px rgba(0,0,0,.08);
- --transition: 200ms cubic-bezier(.4,0,.2,1);
+ --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
+ --shadow-card: 0 0 0 1px rgba(0, 0, 0, 0.03), 0 2px 4px rgba(0, 0, 0, 0.04);
+ --shadow-card-hover: 0 0 0 1px rgba(0, 0, 0, 0.03), 0 8px 24px rgba(0, 0, 0, 0.08);
+ --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset ─────────────────────────────────────────────────── */
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-html { font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; scroll-behavior: smooth; }
-body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
-img { display: block; max-width: 100%; }
-button, input, select, textarea { font: inherit; color: inherit; }
-button { cursor: pointer; border: none; background: none; }
-a { color: inherit; text-decoration: none; }
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ scroll-behavior: smooth;
+}
+body {
+ font-family:
+ 'Inter',
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ Roboto,
+ sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+}
+img {
+ display: block;
+ max-width: 100%;
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+button {
+ cursor: pointer;
+ border: none;
+ background: none;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
/* ── Layout ────────────────────────────────────────────────── */
-.app { display: flex; flex-direction: column; min-height: 100vh; }
-.app-body { display: flex; flex: 1; margin-top: var(--header-h); }
-.main { flex: 1; min-width: 0; padding: 20px 28px 64px; }
+.app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+.app-body {
+ display: flex;
+ flex: 1;
+ margin-top: var(--header-h);
+}
+.main {
+ flex: 1;
+ min-width: 0;
+ padding: 20px 28px 64px;
+}
/* ── Header ────────────────────────────────────────────────── */
-.header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: var(--gray-900); border-bottom: 1px solid rgba(255,255,255,.06); height: var(--header-h); }
-.header-inner { height: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0 28px; }
-.header-brand { display: flex; align-items: center; gap: 12px; transition: opacity var(--transition); }
-.header-brand:hover { opacity: .85; }
-.header-logo { width: 34px; height: 34px; display: grid; place-items: center; background: linear-gradient(135deg, var(--primary-500), var(--violet-500)); border-radius: var(--radius-sm); box-shadow: 0 2px 8px rgba(99,102,241,.35); }
-.logo-icon { width: 18px; height: 18px; color: #fff; }
-.header-title { font-size: 1.05rem; font-weight: 700; letter-spacing: -.02em; color: #fff; }
-.header-subtitle { font-size: .65rem; color: var(--gray-400); font-weight: 500; letter-spacing: .01em; line-height: 1.3; }
-.header-actions { display: flex; align-items: center; gap: 6px; }
-.scrape-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: var(--radius-full); background: rgba(99,102,241,.15); color: var(--primary-300); font-size: .78rem; font-weight: 600; border: 1px solid rgba(99,102,241,.2); max-width: 380px; }
-.scrape-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
-.scrape-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.scrape-progress-bar { height: 3px; background: rgba(255,255,255,.15); border-radius: 2px; overflow: hidden; min-width: 80px; }
-.scrape-progress-fill { height: 100%; background: var(--primary-400); border-radius: 2px; transition: width 300ms ease; }
-.scrape-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--primary-400); animation: pulse-dot 1.2s infinite; }
-@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
+.header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ background: var(--gray-900);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ height: var(--header-h);
+}
+.header-inner {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 28px;
+}
+.header-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: opacity var(--transition);
+}
+.header-brand:hover {
+ opacity: 0.85;
+}
+.header-logo {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ background: linear-gradient(135deg, var(--primary-500), var(--violet-500));
+ border-radius: var(--radius-sm);
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
+}
+.logo-icon {
+ width: 18px;
+ height: 18px;
+ color: #fff;
+}
+.header-title {
+ font-size: 1.05rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: #fff;
+}
+.header-subtitle {
+ font-size: 0.65rem;
+ color: var(--gray-400);
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ line-height: 1.3;
+}
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.scrape-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 12px;
+ border-radius: var(--radius-full);
+ background: rgba(99, 102, 241, 0.15);
+ color: var(--primary-300);
+ font-size: 0.78rem;
+ font-weight: 600;
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ max-width: 380px;
+}
+.scrape-info {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+.scrape-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.scrape-progress-bar {
+ height: 3px;
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 2px;
+ overflow: hidden;
+ min-width: 80px;
+}
+.scrape-progress-fill {
+ height: 100%;
+ background: var(--primary-400);
+ border-radius: 2px;
+ transition: width 300ms ease;
+}
+.scrape-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--primary-400);
+ animation: pulse-dot 1.2s infinite;
+}
+@keyframes pulse-dot {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
/* ── Buttons ───────────────────────────────────────────────── */
-.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: .8125rem; font-weight: 600; transition: all var(--transition); white-space: nowrap; }
-.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
-.btn--primary { background: var(--primary-600); color: #fff; box-shadow: 0 1px 3px rgba(79,70,229,.3); }
-.btn--primary:hover { background: var(--primary-700); box-shadow: 0 2px 6px rgba(79,70,229,.4); }
-.btn--danger { background: var(--danger-500); color: #fff; }
-.btn--danger:hover { background: var(--danger-400); }
-.btn--ghost { background: transparent; color: var(--gray-500); border: 1px solid var(--border); }
-.btn--ghost:hover { background: var(--gray-50); color: var(--gray-700); border-color: var(--gray-300); }
-.btn--ghost-light { background: rgba(255,255,255,.08); color: var(--gray-300); border: 1px solid rgba(255,255,255,.1); }
-.btn--ghost-light:hover { background: rgba(255,255,255,.12); color: #fff; }
-.btn--icon { padding: 7px; border-radius: var(--radius-sm); color: var(--gray-400); }
-.btn--icon:hover { background: rgba(255,255,255,.08); color: #fff; }
-.btn--sm { padding: 5px 10px; font-size: .78rem; }
-.btn--lg { padding: 11px 22px; font-size: .9rem; }
-.btn--full { width: 100%; justify-content: center; }
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: var(--radius-sm);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+.btn-icon {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
+}
+.btn--primary {
+ background: var(--primary-600);
+ color: #fff;
+ box-shadow: 0 1px 3px rgba(79, 70, 229, 0.3);
+}
+.btn--primary:hover {
+ background: var(--primary-700);
+ box-shadow: 0 2px 6px rgba(79, 70, 229, 0.4);
+}
+.btn--danger {
+ background: var(--danger-500);
+ color: #fff;
+}
+.btn--danger:hover {
+ background: var(--danger-400);
+}
+.btn--ghost {
+ background: transparent;
+ color: var(--gray-500);
+ border: 1px solid var(--border);
+}
+.btn--ghost:hover {
+ background: var(--gray-50);
+ color: var(--gray-700);
+ border-color: var(--gray-300);
+}
+.btn--ghost-light {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--gray-300);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+.btn--ghost-light:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: #fff;
+}
+.btn--icon {
+ padding: 7px;
+ border-radius: var(--radius-sm);
+ color: var(--gray-400);
+}
+.btn--icon:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+.btn--sm {
+ padding: 5px 10px;
+ font-size: 0.78rem;
+}
+.btn--lg {
+ padding: 11px 22px;
+ font-size: 0.9rem;
+}
+.btn--full {
+ width: 100%;
+ justify-content: center;
+}
/* ── Active Agent Indicator ────────────────────────────────── */
.active-agent-indicator {
@@ -121,9 +323,11 @@ a { color: inherit; text-decoration: none; }
background: linear-gradient(180deg, #f3f4f6 0%, #ebedef 100%);
border: 1.5px solid rgba(156, 163, 175, 0.35);
border-radius: 18px;
- font-size: .8125rem;
+ font-size: 0.8125rem;
color: var(--gray-700);
- box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.1), 0 10px 22px -18px rgba(15,23,42,.14);
+ box-shadow:
+ 0 0 0 2px rgba(107, 114, 128, 0.1),
+ 0 10px 22px -18px rgba(15, 23, 42, 0.14);
}
.active-agent-indicator::before {
content: '';
@@ -135,21 +339,42 @@ a { color: inherit; text-decoration: none; }
border-radius: 999px;
background: var(--active-agent-accent, var(--gray-400));
}
-.active-agent-info { flex: 1; min-width: 0; }
-.active-agent-name { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: .98rem; letter-spacing: -.01em; color: var(--gray-800); margin-bottom: 8px; }
-.active-agent-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: .75rem; color: var(--text-secondary); row-gap: 8px; width: 100%; }
+.active-agent-info {
+ flex: 1;
+ min-width: 0;
+}
+.active-agent-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 800;
+ font-size: 0.98rem;
+ letter-spacing: -0.01em;
+ color: var(--gray-800);
+ margin-bottom: 8px;
+}
+.active-agent-details {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ row-gap: 8px;
+ width: 100%;
+}
.active-agent-badge {
display: inline-flex;
align-items: center;
min-height: 26px;
- font-size: .64rem;
+ font-size: 0.64rem;
padding: 3px 10px;
border-radius: 999px;
font-weight: 700;
- letter-spacing: .02em;
+ letter-spacing: 0.02em;
text-transform: uppercase;
white-space: nowrap;
- box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.active-agent-detail {
display: inline-flex;
@@ -159,9 +384,9 @@ a { color: inherit; text-decoration: none; }
border-radius: 999px;
font-weight: 600;
white-space: nowrap;
- font-size: .63rem;
- letter-spacing: .02em;
- box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
+ font-size: 0.63rem;
+ letter-spacing: 0.02em;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.active-agent-url-wrap {
display: flex;
@@ -174,25 +399,25 @@ a { color: inherit; text-decoration: none; }
margin-top: 2px;
padding: 7px 10px;
border-radius: 10px;
- background: linear-gradient(180deg, rgba(249,250,251,.97), rgba(243,244,246,.97));
+ background: linear-gradient(180deg, rgba(249, 250, 251, 0.97), rgba(243, 244, 246, 0.97));
border: 1px solid rgba(209, 213, 219, 0.65);
}
.active-agent-url-label {
flex-shrink: 0;
- font-size: .68rem;
+ font-size: 0.68rem;
font-weight: 700;
color: var(--gray-500);
- letter-spacing: .02em;
+ letter-spacing: 0.02em;
white-space: nowrap;
}
.active-agent-url-input {
flex: 1 1 0;
min-width: 0;
- background: rgba(255,255,255,.82);
- border: 1px solid rgba(209,213,219,.7);
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid rgba(209, 213, 219, 0.7);
border-radius: 10px;
color: var(--gray-600);
- font-size: .68rem;
+ font-size: 0.68rem;
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
cursor: text;
padding: 4px 8px;
@@ -200,8 +425,11 @@ a { color: inherit; text-decoration: none; }
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- transition: border-color .15s, background .15s, color .15s;
- box-shadow: inset 0 1px 2px rgba(0,0,0,.03);
+ transition:
+ border-color 0.15s,
+ background 0.15s,
+ color 0.15s;
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
.active-agent-url-input:focus {
border-color: var(--active-agent-accent, var(--primary-400));
@@ -209,7 +437,9 @@ a { color: inherit; text-decoration: none; }
color: var(--text);
white-space: normal;
overflow-wrap: break-word;
- box-shadow: 0 0 0 3px rgba(99,102,241,.08), inset 0 1px 2px rgba(0,0,0,.04);
+ box-shadow:
+ 0 0 0 3px rgba(99, 102, 241, 0.08),
+ inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.active-agent-close {
margin-left: auto;
@@ -220,48 +450,171 @@ a { color: inherit; text-decoration: none; }
gap: 6px;
padding: 8px 12px;
border-radius: 12px;
- background: rgba(255,255,255,.88);
- border: 1px solid rgba(209,213,219,.7);
+ background: rgba(255, 255, 255, 0.88);
+ border: 1px solid rgba(209, 213, 219, 0.7);
color: var(--gray-600);
- font-size: .78rem;
+ font-size: 0.78rem;
font-weight: 700;
- box-shadow: 0 1px 2px rgba(15,23,42,.05);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: all var(--transition);
}
.active-agent-close:hover {
color: var(--active-agent-accent, var(--primary-600));
- border-color: rgba(99,102,241,.2);
+ border-color: rgba(99, 102, 241, 0.2);
background: #fff;
}
/* ══════════════════════════════════════════════════════════════
Agent Sidebar (Left Panel)
══════════════════════════════════════════════════════════════ */
-.agent-sidebar { width: var(--agent-sidebar-w); flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; height: calc(100vh - var(--header-h)); position: sticky; top: var(--header-h); overflow: hidden; transition: width 250ms cubic-bezier(.4,0,.2,1); }
-.agent-sidebar--collapsed { width: var(--agent-sidebar-collapsed-w); }
+.agent-sidebar {
+ width: var(--agent-sidebar-w);
+ flex-shrink: 0;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - var(--header-h));
+ position: sticky;
+ top: var(--header-h);
+ overflow: hidden;
+ transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+.agent-sidebar--collapsed {
+ width: var(--agent-sidebar-collapsed-w);
+}
-.agent-sidebar-header { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--border); min-height: 50px; }
-.agent-sidebar-title { display: flex; align-items: center; gap: 6px; font-size: .8125rem; font-weight: 600; color: var(--text-secondary); flex: 1; white-space: nowrap; }
-.agent-sidebar-count { font-size: .65rem; padding: 0 6px; border-radius: var(--radius-full); background: var(--gray-200); font-weight: 700; color: var(--gray-600); min-width: 18px; text-align: center; line-height: 1.6; }
-.agent-sidebar-collapse-btn { display: grid; place-items: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--gray-400); transition: all var(--transition); flex-shrink: 0; }
-.agent-sidebar-collapse-btn:hover { background: var(--gray-100); color: var(--gray-600); }
+.agent-sidebar-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ min-height: 50px;
+}
+.agent-sidebar-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ flex: 1;
+ white-space: nowrap;
+}
+.agent-sidebar-count {
+ font-size: 0.65rem;
+ padding: 0 6px;
+ border-radius: var(--radius-full);
+ background: var(--gray-200);
+ font-weight: 700;
+ color: var(--gray-600);
+ min-width: 18px;
+ text-align: center;
+ line-height: 1.6;
+}
+.agent-sidebar-collapse-btn {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-sm);
+ color: var(--gray-400);
+ transition: all var(--transition);
+ flex-shrink: 0;
+}
+.agent-sidebar-collapse-btn:hover {
+ background: var(--gray-100);
+ color: var(--gray-600);
+}
-.agent-sidebar-body { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
+.agent-sidebar-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
/* Collapsed mode icons */
-.agent-sidebar-collapsed-icons { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 0; flex: 1; overflow-y: auto; }
-.agent-collapsed-icon { width: 36px; height: 36px; border-radius: var(--radius-sm); border: 1.5px solid var(--border); display: grid; place-items: center; position: relative; transition: all var(--transition); cursor: pointer; }
-.agent-collapsed-icon:hover { border-color: var(--gray-300); box-shadow: var(--shadow-sm); }
-.agent-collapsed-icon--active { border-width: 2px; box-shadow: var(--shadow-sm); }
-.agent-collapsed-icon--disabled { opacity: .4; }
-.agent-collapsed-letter { font-size: .75rem; font-weight: 700; text-transform: uppercase; }
-.agent-collapsed-badge { position: absolute; top: -4px; right: -4px; font-size: .55rem; font-weight: 700; padding: 0 4px; border-radius: var(--radius-full); color: #fff; min-width: 14px; text-align: center; line-height: 1.5; }
-.agent-collapsed-add { width: 36px; height: 36px; border-radius: var(--radius-sm); border: 1.5px dashed var(--gray-300); display: grid; place-items: center; color: var(--gray-400); transition: all var(--transition); cursor: pointer; }
-.agent-collapsed-add:hover { border-color: var(--primary-400); color: var(--primary-600); background: var(--primary-50); }
+.agent-sidebar-collapsed-icons {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 0;
+ flex: 1;
+ overflow-y: auto;
+}
+.agent-collapsed-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-sm);
+ border: 1.5px solid var(--border);
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: all var(--transition);
+ cursor: pointer;
+}
+.agent-collapsed-icon:hover {
+ border-color: var(--gray-300);
+ box-shadow: var(--shadow-sm);
+}
+.agent-collapsed-icon--active {
+ border-width: 2px;
+ box-shadow: var(--shadow-sm);
+}
+.agent-collapsed-icon--disabled {
+ opacity: 0.4;
+}
+.agent-collapsed-letter {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+.agent-collapsed-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ font-size: 0.55rem;
+ font-weight: 700;
+ padding: 0 4px;
+ border-radius: var(--radius-full);
+ color: #fff;
+ min-width: 14px;
+ text-align: center;
+ line-height: 1.5;
+}
+.agent-collapsed-add {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-sm);
+ border: 1.5px dashed var(--gray-300);
+ display: grid;
+ place-items: center;
+ color: var(--gray-400);
+ transition: all var(--transition);
+ cursor: pointer;
+}
+.agent-collapsed-add:hover {
+ border-color: var(--primary-400);
+ color: var(--primary-600);
+ background: var(--primary-50);
+}
/* Agent Card */
-.agent-list { display: flex; flex-direction: column; gap: 6px; }
-.agent-list { display: flex; flex-direction: column; gap: 10px; }
+.agent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.agent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
.agent-card {
position: relative;
padding: 14px;
@@ -270,7 +623,9 @@ a { color: inherit; text-decoration: none; }
border-radius: 18px;
cursor: pointer;
transition: all var(--transition);
- box-shadow: 0 10px 22px -18px rgba(15,23,42,.14), 0 1px 2px rgba(15,23,42,.04);
+ box-shadow:
+ 0 10px 22px -18px rgba(15, 23, 42, 0.14),
+ 0 1px 2px rgba(15, 23, 42, 0.04);
}
.agent-card::before {
content: '';
@@ -285,21 +640,46 @@ a { color: inherit; text-decoration: none; }
}
.agent-card:hover {
border-color: rgba(107, 114, 128, 0.35);
- box-shadow: 0 18px 32px -22px rgba(15,23,42,.18), 0 8px 18px -14px rgba(107,114,128,.14);
+ box-shadow:
+ 0 18px 32px -22px rgba(15, 23, 42, 0.18),
+ 0 8px 18px -14px rgba(107, 114, 128, 0.14);
transform: translateY(-1px);
}
.agent-card--active {
background: linear-gradient(180deg, #f3f4f6 0%, #ebedef 100%);
border-color: rgba(107, 114, 128, 0.4);
- box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.12), 0 20px 36px -20px rgba(15,23,42,.14);
+ box-shadow:
+ 0 0 0 2px rgba(107, 114, 128, 0.12),
+ 0 20px 36px -20px rgba(15, 23, 42, 0.14);
+}
+.agent-card--active::before {
+ background: var(--agent-accent, var(--gray-400));
+ width: 4px;
+}
+.agent-card--active .agent-card-city {
+ color: var(--gray-900);
+}
+.agent-card--disabled {
+ opacity: 0.45;
}
-.agent-card--active::before { background: var(--agent-accent, var(--gray-400)); width: 4px; }
-.agent-card--active .agent-card-city { color: var(--gray-900); }
-.agent-card--disabled { opacity: .45; }
-.agent-card-header { display: block; margin-bottom: 10px; }
-.agent-card-main { min-width: 0; display: flex; flex-direction: column; gap: 8px; }
-.agent-card-title-row { display: flex; align-items: flex-start; gap: 8px; flex: 1; min-width: 0; }
+.agent-card-header {
+ display: block;
+ margin-bottom: 10px;
+}
+.agent-card-main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.agent-card-title-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
.agent-toggle {
width: 22px;
height: 22px;
@@ -307,16 +687,23 @@ a { color: inherit; text-decoration: none; }
place-items: center;
flex-shrink: 0;
border-radius: 999px;
- background: rgba(255,255,255,.88);
- border: 1px solid rgba(148,163,184,.16);
- box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(15,23,42,.04);
+ background: rgba(255, 255, 255, 0.88);
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.75),
+ 0 1px 2px rgba(15, 23, 42, 0.04);
+}
+.agent-toggle-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ transition: background var(--transition);
}
-.agent-toggle-dot { width: 9px; height: 9px; border-radius: 50%; transition: background var(--transition); }
.agent-card-city {
font-weight: 800;
- font-size: .96rem;
+ font-size: 0.96rem;
line-height: 1.3;
- letter-spacing: -.01em;
+ letter-spacing: -0.01em;
color: var(--gray-800);
text-transform: none;
flex: 1;
@@ -333,15 +720,22 @@ a { color: inherit; text-decoration: none; }
display: inline-flex;
align-items: center;
min-height: 24px;
- font-size: .63rem;
+ font-size: 0.63rem;
padding: 3px 9px;
border-radius: 999px;
- letter-spacing: .02em;
+ letter-spacing: 0.02em;
backdrop-filter: blur(4px);
- box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
+}
+.agent-card-type {
+ font-weight: 700;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.agent-card-provider {
+ font-weight: 600;
+ white-space: nowrap;
}
-.agent-card-type { font-weight: 700; text-transform: uppercase; white-space: nowrap; }
-.agent-card-provider { font-weight: 600; white-space: nowrap; }
.agent-card-actions {
display: inline-flex;
align-items: center;
@@ -350,9 +744,9 @@ a { color: inherit; text-decoration: none; }
margin-top: 12px;
padding: 6px;
border: 1px solid rgba(209, 213, 219, 0.7);
- background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(249,250,251,.96));
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(249, 250, 251, 0.96));
border-radius: 14px;
- box-shadow: inset 0 1px 0 rgba(255,255,255,.75);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.agent-action {
display: grid;
@@ -362,24 +756,58 @@ a { color: inherit; text-decoration: none; }
color: var(--gray-500);
border-radius: 10px;
transition: all var(--transition);
- background: rgba(255,255,255,.92);
+ background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(209, 213, 219, 0.55);
- box-shadow: 0 1px 2px rgba(15,23,42,.05);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.agent-action:hover {
color: var(--agent-accent, var(--primary-600));
background: #fff;
transform: translateY(-1px);
- box-shadow: 0 8px 16px -12px rgba(15,23,42,.18);
+ box-shadow: 0 8px 16px -12px rgba(15, 23, 42, 0.18);
+}
+.agent-action:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+.agent-action--danger:hover {
+ color: var(--danger-500);
+ background: var(--danger-50);
+ border-color: rgba(248, 113, 113, 0.18);
}
-.agent-action:disabled { opacity: .3; cursor: default; }
-.agent-action--danger:hover { color: var(--danger-500); background: var(--danger-50); border-color: rgba(248,113,113,.18); }
-.agent-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
-.agent-meta-item { display: flex; align-items: center; gap: 3px; font-size: .7rem; color: var(--text-muted); }
+.agent-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.agent-meta-item {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
-.agent-card-url { display: flex; align-items: center; gap: 4px; font-size: .6rem; color: var(--gray-400); margin-bottom: 6px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 3px 6px; background: var(--gray-100); border-radius: var(--radius-xs); }
-.agent-card-url span { overflow: hidden; text-overflow: ellipsis; }
+.agent-card-url {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.6rem;
+ color: var(--gray-400);
+ margin-bottom: 6px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ padding: 3px 6px;
+ background: var(--gray-100);
+ border-radius: var(--radius-xs);
+}
+.agent-card-url span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
.agent-card-stats {
display: flex;
@@ -393,336 +821,1767 @@ a { color: inherit; text-decoration: none; }
display: inline-flex;
align-items: center;
gap: 5px;
- font-size: .72rem;
+ font-size: 0.72rem;
color: var(--gray-600);
font-weight: 700;
padding: 4px 9px;
border-radius: 999px;
- background: rgba(243,244,246,.95);
+ background: rgba(243, 244, 246, 0.95);
border: 1px solid rgba(209, 213, 219, 0.65);
}
-.agent-stat--new { font-weight: 700; }
+.agent-stat--new {
+ font-weight: 700;
+}
/* Agent Form */
-.agent-form { padding: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); }
-.agent-form-title { font-size: .85rem; font-weight: 700; margin-bottom: 12px; }
-.agent-form-mode { display: flex; gap: 4px; margin-bottom: 12px; padding: 3px; background: var(--gray-100); border-radius: var(--radius-sm); }
-.mode-tab { flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 5px 8px; border-radius: var(--radius-xs); font-size: .73rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); }
-.mode-tab:hover { color: var(--text); }
-.mode-tab--active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: var(--shadow-xs); }
-
-.agent-form-row { display: flex; gap: 8px; }
-.agent-form-field { display: flex; flex-direction: column; gap: 3px; margin-bottom: 10px; flex: 1; }
-.agent-form-label { font-size: .7rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .03em; }
-.agent-form-hint { font-size: .65rem; color: var(--text-muted); line-height: 1.4; }
-.agent-form-url-error { font-size: .65rem; color: #dc2626; line-height: 1.4; display: block; margin-top: 3px; }
-.agent-form-resolved { font-size: .65rem; color: var(--success-600); font-weight: 500; }
-.agent-input { padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); width: 100%; }
-.agent-input:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); }
-.agent-input--sm { width: 72px; text-align: center; }
-.agent-select { padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); width: 100%; }
-.agent-select:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); }
-.agent-input-with-suffix { display: flex; align-items: center; gap: 4px; }
-.agent-input-suffix { font-size: .73rem; color: var(--text-muted); }
-.agent-form-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
-.agent-form-detected { display: flex; align-items: flex-start; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--gray-50); border: 1px solid var(--border); font-size: .72rem; min-height: 28px; margin-bottom: 4px; }
-.agent-form-detected-ok { display: flex; align-items: center; gap: 5px; color: var(--success-600, #16a34a); flex-wrap: wrap; }
-.agent-form-detected-fallback { display: flex; align-items: center; gap: 5px; color: var(--text-secondary); flex-wrap: wrap; }
-.agent-form-type-badge { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 99px; font-size: .68rem; font-weight: 600; }
-.agent-form-provider-badge { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 99px; font-size: .68rem; font-weight: 600; }
-.agent-select-inline { padding: 2px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .72rem; }
-
-/* City Autocomplete */
-.city-input-wrap { position: relative; width: 100%; }
-.city-suggestions { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); max-height: 200px; overflow-y: auto; margin-top: 2px; list-style: none; }
-.city-suggestion { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; cursor: pointer; font-size: .8rem; transition: background var(--transition); }
-.city-suggestion:hover, .city-suggestion--active { background: var(--primary-50); }
-.city-suggestion-name { font-weight: 500; }
-.city-suggestion-slug { font-size: .68rem; color: var(--text-muted); }
-
-/* Agent Add Button */
-.agent-add-btn { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: var(--radius-md); border: 1.5px dashed var(--gray-300); color: var(--text-muted); font-size: .8rem; font-weight: 500; transition: all var(--transition); margin-top: auto; }
-.agent-add-btn:hover { border-color: var(--primary-400); color: var(--primary-600); background: var(--primary-50); border-style: solid; }
-
-/* ── Filter Bar ────────────────────────────────────────────── */
-.filter-bar { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-xs); margin-bottom: 20px; }
-.tabs { display: flex; gap: 2px; background: var(--gray-100); border-radius: var(--radius-sm); padding: 3px; }
-.tab { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-xs); font-size: .8125rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); white-space: nowrap; }
-.tab:hover { color: var(--text); background: rgba(255,255,255,.5); }
-.tab--active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: var(--shadow-xs); }
-.tab-icon { width: 14px; height: 14px; flex-shrink: 0; }
-.tab-badge { font-size: .65rem; padding: 0 6px; border-radius: var(--radius-full); background: var(--gray-200); font-weight: 700; color: var(--gray-600); min-width: 18px; text-align: center; line-height: 1.6; }
-.tab--active .tab-badge { background: var(--primary-100); color: var(--primary-700); }
-.filter-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
-.filter-select { padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); }
-.filter-select:focus { outline: none; border-color: var(--primary-400); }
-.search-wrap { position: relative; flex: 1; min-width: 160px; }
-.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--gray-400); pointer-events: none; }
-.input-search { width: 100%; padding: 6px 10px 6px 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: all var(--transition); }
-.input-search:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); }
-.filter-group { display: flex; gap: 6px; align-items: center; }
-.filter-label { display: flex; align-items: center; gap: 3px; font-size: .8rem; color: var(--text-secondary); }
-.filter-label--range { display: flex; align-items: center; gap: 4px; }
-.filter-prefix { font-weight: 600; font-size: .8rem; color: var(--text-secondary); }
-.filter-suffix, .filter-suffix--sep { font-size: .73rem; color: var(--text-muted); }
-.input-small { width: 64px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; text-align: center; transition: border-color var(--transition); }
-.input-small:focus { outline: none; border-color: var(--primary-400); }
-.filter-label--date { gap: 6px; }
-.filter-date-icon { width: 14px; height: 14px; color: var(--gray-400); flex-shrink: 0; }
-.filter-date-label { font-size: .8rem; color: var(--text-secondary); white-space: nowrap; }
-.input-date { padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); color: var(--text-primary); }
-.input-date:focus { outline: none; border-color: var(--primary-400); }
-.input-date::-webkit-calendar-picker-indicator { opacity: 0.5; cursor: pointer; }
-.filter-reset { margin-left: auto; }
-
-/* ── Grid ──────────────────────────────────────────────────── */
-.grid-info-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
-.grid-info { font-size: .8125rem; color: var(--text-secondary); }
-.grid-info strong { color: var(--text); font-weight: 700; }
-.grid-info-sep { margin: 0 5px; color: var(--gray-300); }
-.listings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
-/* Compact grid/card sizing (desktop) */
-@media (min-width: 1100px) {
- .listings-grid--compact { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
+.agent-form {
+ padding: 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
}
-
-/* Card compact tweaks for denser layout (~20% smaller visual footprint) */
-.listings-grid--compact .card { border-radius: var(--radius-md); }
-.listings-grid--compact .card-body { padding: 12px 14px 8px; }
-.listings-grid--compact .card-footer { padding: 8px 14px; }
-
-.listings-grid--compact .card-price { font-size: 1rem; }
-.listings-grid--compact .card-title { font-size: .82rem; }
-.listings-grid--compact .card-address,
-.listings-grid--compact .card-desc { font-size: .72rem; }
-
-.listings-grid--compact .pill { font-size: .6rem; padding: 1px 6px; }
-
-.listings-grid--compact .card-fav-btn,
-.listings-grid--compact .card-blacklist-btn { width: 26px; height: 26px; }
-.listings-grid--compact .heart-svg { width: 12px; height: 12px; }
-
-.listings-grid--compact .carousel-btn { width: 24px; height: 24px; }
-.listings-grid--compact .chev-icon { width: 11px; height: 11px; }
-
-.listings-grid--compact .card-badge { font-size: .58rem; padding: 1px 8px; }
-.listings-grid--compact .card-badge--new { top: 7px; left: 7px; }
-.listings-grid--compact .card-badge--type { bottom: 7px; left: 7px; }
-
-.listings-grid--compact .date-icon { width: 10px; height: 10px; }
-.listings-grid--compact .addr-icon { width: 12px; height: 12px; }
-.listings-grid--compact .card-open-btn { font-size: .75rem; padding: 4px 10px; }
-.grid-pagination-bottom { margin-top: 28px; display: flex; justify-content: center; }
-.grid-state { text-align: center; padding: 80px 24px; }
-.spinner { width: 32px; height: 32px; margin: 0 auto 16px; border: 2.5px solid var(--gray-200); border-top-color: var(--primary-500); border-radius: 50%; animation: spin .7s linear infinite; }
-@keyframes spin { to { transform: rotate(360deg); } }
-.empty-illustration { margin-bottom: 20px; }
-.empty-svg { width: 100px; height: 100px; margin: 0 auto; }
-.empty-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 6px; color: var(--gray-800); }
-.empty-sub { color: var(--text-muted); margin-bottom: 20px; font-size: .9rem; }
-
-/* ── Card ──────────────────────────────────────────────────── */
-.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-card); transition: box-shadow var(--transition), transform var(--transition); }
-.card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-3px); }
-.card--new { border-left: 3px solid var(--primary-500); }
-.card--seen { opacity: 1; }
-.card--blacklisted { border-left: 3px solid var(--danger-400); opacity: .65; }
-.card-img { position: relative; aspect-ratio: 4/3; overflow: hidden; background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-50) 100%); }
-.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 500ms ease; }
-.card:hover .card-img img { transform: scale(1.03); }
-.card-img-placeholder { width: 100%; height: 100%; display: grid; place-items: center; font-size: 2.2rem; background: linear-gradient(135deg, var(--gray-100) 0%, var(--primary-50) 100%); }
-.carousel-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,.9); color: var(--gray-700); display: grid; place-items: center; box-shadow: var(--shadow-sm); opacity: 0; transition: opacity var(--transition); backdrop-filter: blur(4px); }
-.card:hover .carousel-btn { opacity: 1; }
-.carousel-btn:hover { background: #fff; }
-.carousel-btn--prev { left: 8px; }
-.carousel-btn--next { right: 8px; }
-.chev-icon { width: 13px; height: 13px; }
-.carousel-counter { position: absolute; bottom: 8px; right: 8px; padding: 2px 8px; border-radius: var(--radius-full); background: rgba(0,0,0,.55); color: #fff; font-size: .65rem; font-weight: 600; backdrop-filter: blur(4px); }
-.card-actions-overlay { position: absolute; top: 8px; right: 8px; display: flex; flex-direction: column; gap: 4px; }
-.card-seen-btn { width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.9); display: grid; place-items: center; box-shadow: var(--shadow-sm); transition: all var(--transition); backdrop-filter: blur(4px); color: var(--gray-400); }
-.card-seen-btn:hover { transform: scale(1.1); color: var(--primary-600); background: #fff; }
-.card-seen-btn--active { color: var(--primary-600); background: #fff; }
-.card-fav-btn, .card-blacklist-btn { width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.9); display: grid; place-items: center; box-shadow: var(--shadow-sm); transition: all var(--transition); backdrop-filter: blur(4px); }
-.card-fav-btn:hover, .card-blacklist-btn:hover { transform: scale(1.1); }
-.heart-svg { width: 14px; height: 14px; }
-.card-fav-btn { color: var(--gray-400); }
-.card-fav-btn:hover { color: var(--danger-500); background: #fff; }
-.card-fav-btn--active { color: var(--danger-500); background: #fff; }
-.card-blacklist-btn { color: var(--gray-400); }
-.card-blacklist-btn:hover { color: var(--gray-800); background: #fff; }
-.card-blacklist-btn--active { color: var(--danger-500); }
-.card-badge { position: absolute; font-size: .625rem; font-weight: 700; padding: 2px 9px; border-radius: var(--radius-full); letter-spacing: .02em; text-transform: uppercase; }
-.card-badge--new { top: 8px; left: 8px; background: var(--primary-600); color: #fff; box-shadow: 0 2px 6px rgba(79,70,229,.3); }
-.card-badge-stack { position: absolute; left: 8px; bottom: 8px; display: flex; flex-wrap: wrap; gap: 6px; max-width: calc(100% - 16px); }
-.card-badge-stack .card-badge { position: static; }
-.card-badge--provider { border: 1px solid rgba(255,255,255,.72); backdrop-filter: blur(6px); }
-.card-body { padding: 14px 16px 10px; flex: 1; display: flex; flex-direction: column; }
-.card-meta { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px; }
-.card-price { font-size: 1.1rem; font-weight: 800; color: var(--gray-900); letter-spacing: -.02em; }
-.card-pills { display: flex; gap: 4px; }
-.pill { font-size: .65rem; padding: 2px 7px; border-radius: var(--radius-full); background: var(--gray-100); color: var(--gray-600); font-weight: 600; }
-.card-title { font-size: .875rem; font-weight: 600; line-height: 1.4; display: -webkit-box; line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 4px; color: var(--gray-800); }
-.card-address { display: flex; align-items: flex-start; gap: 4px; font-size: .78rem; color: var(--text-muted); margin-bottom: 6px; }
-.card-expose-id { font-size: .72rem; color: var(--text-muted); opacity: .65; margin: -2px 0 6px; font-family: monospace; }
-.addr-icon { width: 13px; height: 13px; flex-shrink: 0; margin-top: 1px; color: var(--gray-400); }
-.card-desc { font-size: .78rem; color: var(--gray-400); line-height: 1.45; display: -webkit-box; line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1; }
-.card-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-top: 1px solid var(--gray-100); }
-.card-dates { font-size: .7rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 3px; min-width: 0; }
-.card-date { display: flex; align-items: center; gap: 4px; min-width: 0; }
-.card-date-label { font-weight: 700; color: var(--gray-600); white-space: nowrap; }
-.card-date--publisher { max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.date-icon { width: 11px; height: 11px; }
-.card-open-btn { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: var(--radius-sm); background: var(--primary-50); color: var(--primary-600); font-size: .78rem; font-weight: 600; transition: all var(--transition); }
-.card-open-btn:hover { background: var(--primary-100); }
-.open-icon { width: 12px; height: 12px; }
-
-/* ── Pagination ────────────────────────────────────────────── */
-.pagination { display: flex; align-items: center; gap: 3px; }
-.page-btn { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-sm); font-size: .8125rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); }
-.page-btn:hover:not(:disabled) { background: var(--gray-100); color: var(--text); }
-.page-btn--active { background: var(--primary-600); color: #fff; box-shadow: 0 1px 4px rgba(79,70,229,.25); }
-.page-btn--active:hover { background: var(--primary-700); color: #fff; }
-.page-btn:disabled { opacity: .3; cursor: default; }
-.page-ellipsis { padding: 0 3px; color: var(--gray-400); font-size: .8rem; }
-
-/* ── Settings Sidebar (Right) ──────────────────────────────── */
-.sidebar { position: fixed; top: 0; right: 0; z-index: 200; width: var(--sidebar-w); max-width: 92vw; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); box-shadow: var(--shadow-xl); transform: translateX(100%); transition: transform 300ms cubic-bezier(.4,0,.2,1); display: flex; flex-direction: column; }
-.sidebar--open { transform: translateX(0); }
-.sidebar-overlay { position: fixed; inset: 0; z-index: 199; background: rgba(15,23,42,.18); }
-.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
-.sidebar-title { font-size: 1rem; font-weight: 700; }
-.sidebar-close { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-sm); font-size: 1.15rem; color: var(--text-muted); transition: all var(--transition); }
-.sidebar-close:hover { background: var(--gray-100); color: var(--text); }
-.sidebar-body { flex: 1; overflow-y: auto; padding: 20px; }
-.sidebar-footer { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
-.s-section { margin-bottom: 24px; }
-.s-heading { font-size: .875rem; font-weight: 600; margin-bottom: 4px; }
-.s-hint { font-size: .78rem; color: var(--text-muted); margin-bottom: 10px; line-height: 1.5; }
-.tag-input-wrap { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-height: 40px; cursor: text; transition: border-color var(--transition); }
-.tag-input-wrap:focus-within { border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); }
-.tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: var(--radius-full); background: var(--gray-100); font-size: .78rem; font-weight: 500; }
-.tag-remove { display: inline-flex; align-items: center; margin-left: 2px; color: var(--gray-400); font-size: .73rem; }
-.tag-remove:hover { color: var(--danger-500); }
-.tag-input { flex: 1; min-width: 100px; border: none; outline: none; font-size: .78rem; background: transparent; }
-.reset-confirm { text-align: center; }
-.reset-confirm p { font-size: .8125rem; color: var(--danger-500); font-weight: 500; margin-bottom: 8px; }
-.reset-confirm-actions { display: flex; gap: 8px; justify-content: center; }
-
-/* ── ScrapeLog ─────────────────────────────────────────────── */
-.log-section { margin-top: 36px; padding: 20px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-xs); }
-.log-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
-.log-title { display: flex; align-items: center; gap: 8px; font-size: .9375rem; font-weight: 700; }
-.log-title-icon { width: 16px; height: 16px; color: var(--primary-500); }
-.log-count { font-size: .78rem; color: var(--text-muted); }
-.log-table-wrap { overflow-x: auto; }
-.log-table { width: 100%; border-collapse: collapse; font-size: .78rem; }
-.log-table th { text-align: left; padding: 8px 12px; font-weight: 600; color: var(--text-muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid var(--border); }
-.log-table td { padding: 8px 12px; border-bottom: 1px solid var(--gray-100); }
-.source-label { background: var(--gray-100); padding: 2px 8px; border-radius: var(--radius-full); font-size: .7rem; font-weight: 600; }
-.duration-label { color: var(--text-muted); }
-.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: var(--radius-full); font-size: .7rem; font-weight: 600; }
-.status-icon { font-size: .65rem; }
-.badge--success { background: var(--success-50); color: var(--success-600); }
-.badge--error { background: var(--danger-50); color: var(--danger-500); }
-.badge--running { background: var(--primary-50); color: var(--primary-700); }
-.log-error { display: block; font-size: .7rem; color: var(--danger-500); margin-top: 3px; }
-.log-toggle { margin-top: 10px; }
-
-/* ── Toast ─────────────────────────────────────────────────── */
-.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column-reverse; gap: 8px; pointer-events: none; }
-.toast { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-radius: var(--radius-md); background: var(--gray-900); color: #fff; font-size: .8125rem; font-weight: 500; box-shadow: var(--shadow-xl); opacity: 0; transform: translateY(8px) scale(.96); transition: all 250ms cubic-bezier(.4,0,.2,1); pointer-events: auto; backdrop-filter: blur(12px); }
-.toast--visible { opacity: 1; transform: translateY(0) scale(1); }
-.toast--success { background: #065f46; }
-.toast--error { background: #991b1b; }
-.toast--info { background: var(--gray-800); }
-.toast-icon { width: 16px; height: 16px; flex-shrink: 0; }
-.toast-msg { flex: 1; }
-.toast-action { flex-shrink: 0; padding: 3px 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,.15); color: #fff; font-size: .78rem; font-weight: 700; border: 1px solid rgba(255,255,255,.2); transition: background var(--transition); white-space: nowrap; }
-.toast-action:hover { background: rgba(255,255,255,.25); }
-
-/* ── Error Boundary ────────────────────────────────────────── */
-.error-boundary { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 40px; }
-.error-boundary-content { text-align: center; max-width: 480px; }
-.error-icon { font-size: 3rem; margin-bottom: 16px; }
-.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
-.error-message { color: var(--text-muted); margin-bottom: 20px; }
-.error-details { text-align: left; margin: 16px 0; }
-.error-details summary { cursor: pointer; font-size: .8125rem; color: var(--text-muted); }
-.error-stack { margin-top: 8px; padding: 12px; background: var(--gray-100); border-radius: var(--radius-sm); font-family: monospace; font-size: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
-
-/* ── Responsive ────────────────────────────────────────────── */
-@media (max-width: 1024px) {
- .agent-sidebar { width: var(--agent-sidebar-collapsed-w); }
- .agent-sidebar .agent-sidebar-body { display: none; }
- .agent-sidebar .agent-sidebar-collapsed-icons { display: flex; }
- .agent-sidebar .agent-sidebar-title { display: none; }
+.agent-form-title {
+ font-size: 0.85rem;
+ font-weight: 700;
+ margin-bottom: 12px;
}
-@media (max-width: 768px) {
- .main { padding: 14px 14px 48px; }
- .listings-grid { grid-template-columns: 1fr; gap: 14px; }
- .filter-controls { flex-direction: column; }
- .filter-group { width: 100%; }
- .search-wrap { min-width: 100%; }
- .tabs { gap: 1px; }
- .tab { padding: 5px 10px; font-size: .78rem; }
- .btn-label-desktop { display: none; }
- .header-subtitle { display: none; }
- .header-inner { padding: 0 14px; }
- .agent-sidebar { position: fixed; left: 0; top: var(--header-h); z-index: 150; width: var(--agent-sidebar-collapsed-w); box-shadow: var(--shadow-xl); }
- .agent-sidebar:not(.agent-sidebar--collapsed) { width: min(300px, 82vw); }
- .active-agent-indicator { font-size: .75rem; padding: 6px 10px; }
+.agent-form-mode {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 12px;
+ padding: 3px;
+ background: var(--gray-100);
+ border-radius: var(--radius-sm);
}
-
-@media (max-width: 480px) {
- .tab-text { display: none; }
- .tab { padding: 6px 8px; }
- .card-meta { flex-direction: column; gap: 4px; }
+.mode-tab {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 5px 8px;
+ border-radius: var(--radius-xs);
+ font-size: 0.73rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ transition: all var(--transition);
}
-
+.mode-tab:hover {
+ color: var(--text);
+}
+.mode-tab--active {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+ box-shadow: var(--shadow-xs);
+}
+
+.agent-form-row {
+ display: flex;
+ gap: 8px;
+}
+.agent-form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ margin-bottom: 10px;
+ flex: 1;
+}
+.agent-form-label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+.agent-form-hint {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+.agent-form-url-error {
+ font-size: 0.65rem;
+ color: #dc2626;
+ line-height: 1.4;
+ display: block;
+ margin-top: 3px;
+}
+.agent-form-resolved {
+ font-size: 0.65rem;
+ color: var(--success-600);
+ font-weight: 500;
+}
+.agent-input {
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ transition: border-color var(--transition);
+ width: 100%;
+}
+.agent-input:focus {
+ outline: none;
+ border-color: var(--primary-400);
+ box-shadow: 0 0 0 3px var(--primary-100);
+}
+.agent-input--sm {
+ width: 72px;
+ text-align: center;
+}
+.agent-select {
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ transition: border-color var(--transition);
+ width: 100%;
+}
+.agent-select:focus {
+ outline: none;
+ border-color: var(--primary-400);
+ box-shadow: 0 0 0 3px var(--primary-100);
+}
+.agent-input-with-suffix {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.agent-input-suffix {
+ font-size: 0.73rem;
+ color: var(--text-muted);
+}
+.agent-form-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 4px;
+}
+.agent-form-detected {
+ display: flex;
+ align-items: flex-start;
+ padding: 5px 8px;
+ border-radius: var(--radius-sm);
+ background: var(--gray-50);
+ border: 1px solid var(--border);
+ font-size: 0.72rem;
+ min-height: 28px;
+ margin-bottom: 4px;
+}
+.agent-form-detected-ok {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--success-600, #16a34a);
+ flex-wrap: wrap;
+}
+.agent-form-detected-fallback {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--text-secondary);
+ flex-wrap: wrap;
+}
+.agent-form-type-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 7px;
+ border-radius: 99px;
+ font-size: 0.68rem;
+ font-weight: 600;
+}
+.agent-form-provider-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 7px;
+ border-radius: 99px;
+ font-size: 0.68rem;
+ font-weight: 600;
+}
+.agent-select-inline {
+ padding: 2px 6px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.72rem;
+}
+
+/* City Autocomplete */
+.city-input-wrap {
+ position: relative;
+ width: 100%;
+}
+.city-suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ z-index: 50;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-lg);
+ max-height: 200px;
+ overflow-y: auto;
+ margin-top: 2px;
+ list-style: none;
+}
+.city-suggestion {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 7px 10px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: background var(--transition);
+}
+.city-suggestion:hover,
+.city-suggestion--active {
+ background: var(--primary-50);
+}
+.city-suggestion-name {
+ font-weight: 500;
+}
+.city-suggestion-slug {
+ font-size: 0.68rem;
+ color: var(--text-muted);
+}
+
+/* Agent Add Button */
+.agent-add-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px;
+ border-radius: var(--radius-md);
+ border: 1.5px dashed var(--gray-300);
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ font-weight: 500;
+ transition: all var(--transition);
+ margin-top: auto;
+}
+.agent-add-btn:hover {
+ border-color: var(--primary-400);
+ color: var(--primary-600);
+ background: var(--primary-50);
+ border-style: solid;
+}
+
+/* ── Filter Bar ────────────────────────────────────────────── */
+.filter-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xs);
+ margin-bottom: 20px;
+}
+.tabs {
+ display: flex;
+ gap: 2px;
+ background: var(--gray-100);
+ border-radius: var(--radius-sm);
+ padding: 3px;
+}
+.tab {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 14px;
+ border-radius: var(--radius-xs);
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+.tab:hover {
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.5);
+}
+.tab--active {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+ box-shadow: var(--shadow-xs);
+}
+.tab-icon {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+.tab-badge {
+ font-size: 0.65rem;
+ padding: 0 6px;
+ border-radius: var(--radius-full);
+ background: var(--gray-200);
+ font-weight: 700;
+ color: var(--gray-600);
+ min-width: 18px;
+ text-align: center;
+ line-height: 1.6;
+}
+.tab--active .tab-badge {
+ background: var(--primary-100);
+ color: var(--primary-700);
+}
+.filter-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+.filter-select {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ transition: border-color var(--transition);
+}
+.filter-select:focus {
+ outline: none;
+ border-color: var(--primary-400);
+}
+.search-wrap {
+ position: relative;
+ flex: 1;
+ min-width: 160px;
+}
+.search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 15px;
+ height: 15px;
+ color: var(--gray-400);
+ pointer-events: none;
+}
+.input-search {
+ width: 100%;
+ padding: 6px 10px 6px 30px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ transition: all var(--transition);
+}
+.input-search:focus {
+ outline: none;
+ border-color: var(--primary-400);
+ box-shadow: 0 0 0 3px var(--primary-100);
+}
+.filter-group {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+.filter-label {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+.filter-label--range {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.filter-prefix {
+ font-weight: 600;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+.filter-suffix,
+.filter-suffix--sep {
+ font-size: 0.73rem;
+ color: var(--text-muted);
+}
+.input-small {
+ width: 64px;
+ padding: 5px 6px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ text-align: center;
+ transition: border-color var(--transition);
+}
+.input-small:focus {
+ outline: none;
+ border-color: var(--primary-400);
+}
+.filter-label--date {
+ gap: 6px;
+}
+.filter-date-icon {
+ width: 14px;
+ height: 14px;
+ color: var(--gray-400);
+ flex-shrink: 0;
+}
+.filter-date-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+.input-date {
+ padding: 5px 6px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-size: 0.8rem;
+ transition: border-color var(--transition);
+ color: var(--text-primary);
+}
+.input-date:focus {
+ outline: none;
+ border-color: var(--primary-400);
+}
+.input-date::-webkit-calendar-picker-indicator {
+ opacity: 0.5;
+ cursor: pointer;
+}
+.filter-reset {
+ margin-left: auto;
+}
+
+/* ── Grid ──────────────────────────────────────────────────── */
+.grid-info-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+.grid-info {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+.grid-info strong {
+ color: var(--text);
+ font-weight: 700;
+}
+.grid-info-sep {
+ margin: 0 5px;
+ color: var(--gray-300);
+}
+.listings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+/* Compact grid/card sizing (desktop) */
+@media (min-width: 1100px) {
+ .listings-grid--compact {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ }
+}
+
+/* Card compact tweaks for denser layout (~20% smaller visual footprint) */
+.listings-grid--compact .card {
+ border-radius: var(--radius-md);
+}
+.listings-grid--compact .card-body {
+ padding: 12px 14px 8px;
+}
+.listings-grid--compact .card-footer {
+ padding: 8px 14px;
+}
+
+.listings-grid--compact .card-price {
+ font-size: 1rem;
+}
+.listings-grid--compact .card-title {
+ font-size: 0.82rem;
+}
+.listings-grid--compact .card-address,
+.listings-grid--compact .card-desc {
+ font-size: 0.72rem;
+}
+
+.listings-grid--compact .pill {
+ font-size: 0.6rem;
+ padding: 1px 6px;
+}
+
+.listings-grid--compact .card-fav-btn,
+.listings-grid--compact .card-blacklist-btn {
+ width: 26px;
+ height: 26px;
+}
+.listings-grid--compact .heart-svg {
+ width: 12px;
+ height: 12px;
+}
+
+.listings-grid--compact .carousel-btn {
+ width: 24px;
+ height: 24px;
+}
+.listings-grid--compact .chev-icon {
+ width: 11px;
+ height: 11px;
+}
+
+.listings-grid--compact .card-badge {
+ font-size: 0.58rem;
+ padding: 1px 8px;
+}
+.listings-grid--compact .card-badge--new {
+ top: 7px;
+ left: 7px;
+}
+.listings-grid--compact .card-badge--type {
+ bottom: 7px;
+ left: 7px;
+}
+
+.listings-grid--compact .date-icon {
+ width: 10px;
+ height: 10px;
+}
+.listings-grid--compact .addr-icon {
+ width: 12px;
+ height: 12px;
+}
+.listings-grid--compact .card-open-btn {
+ font-size: 0.75rem;
+ padding: 4px 10px;
+}
+.grid-pagination-bottom {
+ margin-top: 28px;
+ display: flex;
+ justify-content: center;
+}
+.grid-state {
+ text-align: center;
+ padding: 80px 24px;
+}
+.spinner {
+ width: 32px;
+ height: 32px;
+ margin: 0 auto 16px;
+ border: 2.5px solid var(--gray-200);
+ border-top-color: var(--primary-500);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.empty-illustration {
+ margin-bottom: 20px;
+}
+.empty-svg {
+ width: 100px;
+ height: 100px;
+ margin: 0 auto;
+}
+.empty-title {
+ font-size: 1.15rem;
+ font-weight: 700;
+ margin-bottom: 6px;
+ color: var(--gray-800);
+}
+.empty-sub {
+ color: var(--text-muted);
+ margin-bottom: 20px;
+ font-size: 0.9rem;
+}
+
+/* ── Card ──────────────────────────────────────────────────── */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-card);
+ transition:
+ box-shadow var(--transition),
+ transform var(--transition);
+}
+.card:hover {
+ box-shadow: var(--shadow-card-hover);
+ transform: translateY(-3px);
+}
+.card--new {
+ border-left: 3px solid var(--primary-500);
+}
+.card--seen {
+ opacity: 1;
+}
+.card--blacklisted {
+ border-left: 3px solid var(--danger-400);
+ opacity: 0.65;
+}
+.card-img {
+ position: relative;
+ aspect-ratio: 4/3;
+ overflow: hidden;
+ background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-50) 100%);
+}
+.card-img img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 500ms ease;
+}
+.card:hover .card-img img {
+ transform: scale(1.03);
+}
+.card-img-placeholder {
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ font-size: 2.2rem;
+ background: linear-gradient(135deg, var(--gray-100) 0%, var(--primary-50) 100%);
+}
+.carousel-btn {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.9);
+ color: var(--gray-700);
+ display: grid;
+ place-items: center;
+ box-shadow: var(--shadow-sm);
+ opacity: 0;
+ transition: opacity var(--transition);
+ backdrop-filter: blur(4px);
+}
+.card:hover .carousel-btn {
+ opacity: 1;
+}
+.carousel-btn:hover {
+ background: #fff;
+}
+.carousel-btn--prev {
+ left: 8px;
+}
+.carousel-btn--next {
+ right: 8px;
+}
+.chev-icon {
+ width: 13px;
+ height: 13px;
+}
+.carousel-counter {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ font-size: 0.65rem;
+ font-weight: 600;
+ backdrop-filter: blur(4px);
+}
+.card-actions-overlay {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.card-seen-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.9);
+ display: grid;
+ place-items: center;
+ box-shadow: var(--shadow-sm);
+ transition: all var(--transition);
+ backdrop-filter: blur(4px);
+ color: var(--gray-400);
+}
+.card-seen-btn:hover {
+ transform: scale(1.1);
+ color: var(--primary-600);
+ background: #fff;
+}
+.card-seen-btn--active {
+ color: var(--primary-600);
+ background: #fff;
+}
+.card-fav-btn,
+.card-blacklist-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.9);
+ display: grid;
+ place-items: center;
+ box-shadow: var(--shadow-sm);
+ transition: all var(--transition);
+ backdrop-filter: blur(4px);
+}
+.card-fav-btn:hover,
+.card-blacklist-btn:hover {
+ transform: scale(1.1);
+}
+.heart-svg {
+ width: 14px;
+ height: 14px;
+}
+.card-fav-btn {
+ color: var(--gray-400);
+}
+.card-fav-btn:hover {
+ color: var(--danger-500);
+ background: #fff;
+}
+.card-fav-btn--active {
+ color: var(--danger-500);
+ background: #fff;
+}
+.card-blacklist-btn {
+ color: var(--gray-400);
+}
+.card-blacklist-btn:hover {
+ color: var(--gray-800);
+ background: #fff;
+}
+.card-blacklist-btn--active {
+ color: var(--danger-500);
+}
+.card-badge {
+ position: absolute;
+ font-size: 0.625rem;
+ font-weight: 700;
+ padding: 2px 9px;
+ border-radius: var(--radius-full);
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+.card-badge--new {
+ top: 8px;
+ left: 8px;
+ background: var(--primary-600);
+ color: #fff;
+ box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
+}
+.card-badge-stack {
+ position: absolute;
+ left: 8px;
+ bottom: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ max-width: calc(100% - 16px);
+}
+.card-badge-stack .card-badge {
+ position: static;
+}
+.card-badge--provider {
+ border: 1px solid rgba(255, 255, 255, 0.72);
+ backdrop-filter: blur(6px);
+}
+.card-body {
+ padding: 14px 16px 10px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+.card-meta {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+.card-price {
+ font-size: 1.1rem;
+ font-weight: 800;
+ color: var(--gray-900);
+ letter-spacing: -0.02em;
+}
+.card-pills {
+ display: flex;
+ gap: 4px;
+}
+.pill {
+ font-size: 0.65rem;
+ padding: 2px 7px;
+ border-radius: var(--radius-full);
+ background: var(--gray-100);
+ color: var(--gray-600);
+ font-weight: 600;
+}
+.card-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.4;
+ display: -webkit-box;
+ line-clamp: 2;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ margin-bottom: 4px;
+ color: var(--gray-800);
+}
+.card-address {
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+.card-expose-id {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ opacity: 0.65;
+ margin: -2px 0 6px;
+ font-family: monospace;
+}
+.addr-icon {
+ width: 13px;
+ height: 13px;
+ flex-shrink: 0;
+ margin-top: 1px;
+ color: var(--gray-400);
+}
+.card-desc {
+ font-size: 0.78rem;
+ color: var(--gray-400);
+ line-height: 1.45;
+ display: -webkit-box;
+ line-clamp: 2;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ flex: 1;
+}
+.card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 16px;
+ border-top: 1px solid var(--gray-100);
+}
+.card-dates {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+.card-date {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+}
+.card-date-label {
+ font-weight: 700;
+ color: var(--gray-600);
+ white-space: nowrap;
+}
+.card-date--publisher {
+ max-width: 280px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.date-icon {
+ width: 11px;
+ height: 11px;
+}
+.card-open-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 12px;
+ border-radius: var(--radius-sm);
+ background: var(--primary-50);
+ color: var(--primary-600);
+ font-size: 0.78rem;
+ font-weight: 600;
+ transition: all var(--transition);
+}
+.card-open-btn:hover {
+ background: var(--primary-100);
+}
+.card-footer-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.card-detail-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--gray-100);
+ color: var(--gray-600);
+ font-size: 0.76rem;
+ font-weight: 700;
+ transition: all var(--transition);
+}
+.card-detail-btn:hover {
+ background: var(--gray-200);
+ color: var(--gray-800);
+}
+.open-icon {
+ width: 12px;
+ height: 12px;
+}
+
+/* ── Listing Detail Drawer ─────────────────────────────────── */
+.detail-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 250;
+ background: rgba(15, 23, 42, 0.24);
+}
+.detail-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 260;
+ width: min(620px, 96vw);
+ height: 100vh;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ box-shadow: var(--shadow-xl);
+ display: flex;
+ flex-direction: column;
+}
+.detail-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px 22px;
+ border-bottom: 1px solid var(--border);
+}
+.detail-header-main {
+ min-width: 0;
+}
+.detail-provider {
+ display: inline-flex;
+ margin-bottom: 7px;
+ font-size: 0.68rem;
+ font-weight: 800;
+ color: var(--primary-700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.detail-title {
+ font-size: 1rem;
+ line-height: 1.35;
+ font-weight: 800;
+ color: var(--gray-900);
+}
+.detail-close {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--radius-sm);
+ color: var(--gray-500);
+ font-size: 1.35rem;
+ transition: all var(--transition);
+}
+.detail-close:hover {
+ background: var(--gray-100);
+ color: var(--gray-900);
+}
+.detail-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 18px 22px 24px;
+}
+.detail-state {
+ flex: 1;
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: 10px;
+ color: var(--text-muted);
+}
+.detail-error {
+ margin: 20px;
+ padding: 14px;
+ border-radius: var(--radius-md);
+ background: var(--danger-50);
+ color: var(--danger-500);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.detail-error--inline {
+ margin: 0 0 14px;
+}
+.detail-summary {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 18px;
+}
+.detail-section {
+ padding: 16px 0;
+ border-bottom: 1px solid var(--gray-100);
+}
+.detail-section h3 {
+ font-size: 0.78rem;
+ font-weight: 800;
+ color: var(--gray-700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 10px;
+}
+.detail-fields-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px 12px;
+}
+.detail-field {
+ min-width: 0;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--gray-50);
+ border: 1px solid var(--gray-100);
+}
+.detail-field-label {
+ display: block;
+ font-size: 0.64rem;
+ font-weight: 800;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 2px;
+}
+.detail-field-value {
+ display: block;
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--gray-800);
+ overflow-wrap: anywhere;
+}
+.detail-features {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+.detail-feature {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 9px;
+ border-radius: var(--radius-full);
+ background: var(--gray-100);
+ color: var(--gray-400);
+ border: 1px solid var(--gray-200);
+ font-size: 0.72rem;
+ font-weight: 800;
+}
+.detail-feature--yes {
+ background: var(--success-50);
+ color: var(--success-600);
+ border-color: rgba(16, 185, 129, 0.22);
+}
+.detail-text {
+ font-size: 0.84rem;
+ line-height: 1.65;
+ color: var(--gray-700);
+ white-space: pre-wrap;
+ margin-bottom: 10px;
+}
+.detail-attribute-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.detail-attribute-group h4 {
+ margin-bottom: 8px;
+ font-size: 0.72rem;
+ font-weight: 800;
+ color: var(--gray-500);
+}
+.detail-phone-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 10px;
+}
+.detail-phone {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--primary-50);
+ color: var(--primary-700);
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+.detail-map {
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+}
+.detail-map-status {
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--gray-50);
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+.detail-map-status--error {
+ background: var(--danger-50);
+ color: var(--danger-500);
+}
+.detail-map-leaflet {
+ width: 100%;
+ height: 240px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--gray-100);
+ overflow: hidden;
+}
+.detail-map-pin {
+ width: 18px;
+ height: 18px;
+ border: 3px solid #fff;
+ border-radius: 50%;
+ background: var(--primary-600);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.45), 0 6px 14px rgba(15, 23, 42, 0.22);
+}
+.detail-map-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.detail-map-label {
+ min-width: 0;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+.detail-map-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.detail-image-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+.detail-image-grid img {
+ width: 100%;
+ aspect-ratio: 4/3;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+ background: var(--gray-100);
+}
+.detail-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-top: 16px;
+}
+
+/* ── Pagination ────────────────────────────────────────────── */
+.pagination {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+.page-btn {
+ min-width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--radius-sm);
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ transition: all var(--transition);
+}
+.page-btn:hover:not(:disabled) {
+ background: var(--gray-100);
+ color: var(--text);
+}
+.page-btn--active {
+ background: var(--primary-600);
+ color: #fff;
+ box-shadow: 0 1px 4px rgba(79, 70, 229, 0.25);
+}
+.page-btn--active:hover {
+ background: var(--primary-700);
+ color: #fff;
+}
+.page-btn:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+.page-ellipsis {
+ padding: 0 3px;
+ color: var(--gray-400);
+ font-size: 0.8rem;
+}
+
+/* ── Settings Sidebar (Right) ──────────────────────────────── */
+.sidebar {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 200;
+ width: var(--sidebar-w);
+ max-width: 92vw;
+ height: 100vh;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ box-shadow: var(--shadow-xl);
+ transform: translateX(100%);
+ transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ display: flex;
+ flex-direction: column;
+}
+.sidebar--open {
+ transform: translateX(0);
+}
+.sidebar-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 199;
+ background: rgba(15, 23, 42, 0.18);
+}
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+}
+.sidebar-title {
+ font-size: 1rem;
+ font-weight: 700;
+}
+.sidebar-close {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--radius-sm);
+ font-size: 1.15rem;
+ color: var(--text-muted);
+ transition: all var(--transition);
+}
+.sidebar-close:hover {
+ background: var(--gray-100);
+ color: var(--text);
+}
+.sidebar-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+}
+.sidebar-footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.s-section {
+ margin-bottom: 24px;
+}
+.s-heading {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.s-hint {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 10px;
+ line-height: 1.5;
+}
+.tag-input-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ min-height: 40px;
+ cursor: text;
+ transition: border-color var(--transition);
+}
+.tag-input-wrap:focus-within {
+ border-color: var(--primary-400);
+ box-shadow: 0 0 0 3px var(--primary-100);
+}
+.tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 10px;
+ border-radius: var(--radius-full);
+ background: var(--gray-100);
+ font-size: 0.78rem;
+ font-weight: 500;
+}
+.tag-remove {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 2px;
+ color: var(--gray-400);
+ font-size: 0.73rem;
+}
+.tag-remove:hover {
+ color: var(--danger-500);
+}
+.tag-input {
+ flex: 1;
+ min-width: 100px;
+ border: none;
+ outline: none;
+ font-size: 0.78rem;
+ background: transparent;
+}
+.reset-confirm {
+ text-align: center;
+}
+.reset-confirm p {
+ font-size: 0.8125rem;
+ color: var(--danger-500);
+ font-weight: 500;
+ margin-bottom: 8px;
+}
+.reset-confirm-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+}
+
+/* ── ScrapeLog ─────────────────────────────────────────────── */
+.log-section {
+ margin-top: 36px;
+ padding: 20px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xs);
+}
+.log-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+.log-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9375rem;
+ font-weight: 700;
+}
+.log-title-icon {
+ width: 16px;
+ height: 16px;
+ color: var(--primary-500);
+}
+.log-count {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+.log-table-wrap {
+ overflow-x: auto;
+}
+.log-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.78rem;
+}
+.log-table th {
+ text-align: left;
+ padding: 8px 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--border);
+}
+.log-table td {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--gray-100);
+}
+.source-label {
+ background: var(--gray-100);
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+.duration-label {
+ color: var(--text-muted);
+}
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 10px;
+ border-radius: var(--radius-full);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+.status-icon {
+ font-size: 0.65rem;
+}
+.badge--success {
+ background: var(--success-50);
+ color: var(--success-600);
+}
+.badge--error {
+ background: var(--danger-50);
+ color: var(--danger-500);
+}
+.badge--running {
+ background: var(--primary-50);
+ color: var(--primary-700);
+}
+.log-error {
+ display: block;
+ font-size: 0.7rem;
+ color: var(--danger-500);
+ margin-top: 3px;
+}
+.log-toggle {
+ margin-top: 10px;
+}
+
+/* ── Toast ─────────────────────────────────────────────────── */
+.toast-container {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 8px;
+ pointer-events: none;
+}
+.toast {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 18px;
+ border-radius: var(--radius-md);
+ background: var(--gray-900);
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ box-shadow: var(--shadow-xl);
+ opacity: 0;
+ transform: translateY(8px) scale(0.96);
+ transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ pointer-events: auto;
+ backdrop-filter: blur(12px);
+}
+.toast--visible {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+.toast--success {
+ background: #065f46;
+}
+.toast--error {
+ background: #991b1b;
+}
+.toast--info {
+ background: var(--gray-800);
+}
+.toast-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+.toast-msg {
+ flex: 1;
+}
+.toast-action {
+ flex-shrink: 0;
+ padding: 3px 10px;
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 0.78rem;
+ font-weight: 700;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ transition: background var(--transition);
+ white-space: nowrap;
+}
+.toast-action:hover {
+ background: rgba(255, 255, 255, 0.25);
+}
+
+/* ── Error Boundary ────────────────────────────────────────── */
+.error-boundary {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 40px;
+}
+.error-boundary-content {
+ text-align: center;
+ max-width: 480px;
+}
+.error-icon {
+ font-size: 3rem;
+ margin-bottom: 16px;
+}
+.error-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+.error-message {
+ color: var(--text-muted);
+ margin-bottom: 20px;
+}
+.error-details {
+ text-align: left;
+ margin: 16px 0;
+}
+.error-details summary {
+ cursor: pointer;
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+}
+.error-stack {
+ margin-top: 8px;
+ padding: 12px;
+ background: var(--gray-100);
+ border-radius: var(--radius-sm);
+ font-family: monospace;
+ font-size: 0.75rem;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+/* ── Responsive ────────────────────────────────────────────── */
+@media (max-width: 1024px) {
+ .agent-sidebar {
+ width: var(--agent-sidebar-collapsed-w);
+ }
+ .agent-sidebar .agent-sidebar-body {
+ display: none;
+ }
+ .agent-sidebar .agent-sidebar-collapsed-icons {
+ display: flex;
+ }
+ .agent-sidebar .agent-sidebar-title {
+ display: none;
+ }
+}
+@media (max-width: 768px) {
+ .main {
+ padding: 14px 14px 48px;
+ }
+ .listings-grid {
+ grid-template-columns: 1fr;
+ gap: 14px;
+ }
+ .detail-drawer {
+ width: 100vw;
+ }
+ .detail-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .detail-fields-grid {
+ grid-template-columns: 1fr;
+ }
+ .detail-map-leaflet {
+ height: 220px;
+ }
+ .detail-map-footer {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .detail-map-actions {
+ justify-content: flex-start;
+ }
+ .detail-image-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .filter-controls {
+ flex-direction: column;
+ }
+ .filter-group {
+ width: 100%;
+ }
+ .search-wrap {
+ min-width: 100%;
+ }
+ .tabs {
+ gap: 1px;
+ }
+ .tab {
+ padding: 5px 10px;
+ font-size: 0.78rem;
+ }
+ .btn-label-desktop {
+ display: none;
+ }
+ .header-subtitle {
+ display: none;
+ }
+ .header-inner {
+ padding: 0 14px;
+ }
+ .agent-sidebar {
+ position: fixed;
+ left: 0;
+ top: var(--header-h);
+ z-index: 150;
+ width: var(--agent-sidebar-collapsed-w);
+ box-shadow: var(--shadow-xl);
+ }
+ .agent-sidebar:not(.agent-sidebar--collapsed) {
+ width: min(300px, 82vw);
+ }
+ .active-agent-indicator {
+ font-size: 0.75rem;
+ padding: 6px 10px;
+ }
+}
+
+@media (max-width: 480px) {
+ .tab-text {
+ display: none;
+ }
+ .tab {
+ padding: 6px 8px;
+ }
+ .card-meta {
+ flex-direction: column;
+ gap: 4px;
+ }
+}
+
/* ── Confirm Dialog ────────────────────────────────────────── */
.cdialog-backdrop {
- position: fixed; inset: 0; z-index: 500;
- background: rgba(15,23,42,.35);
+ position: fixed;
+ inset: 0;
+ z-index: 500;
+ background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(4px);
- display: grid; place-items: center;
+ display: grid;
+ place-items: center;
padding: 16px;
animation: cdialog-fade-in 150ms ease;
}
-@keyframes cdialog-fade-in { from { opacity: 0; } to { opacity: 1; } }
+@keyframes cdialog-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
.cdialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
- box-shadow: 0 20px 60px rgba(15,23,42,.18), 0 4px 16px rgba(15,23,42,.1);
+ box-shadow:
+ 0 20px 60px rgba(15, 23, 42, 0.18),
+ 0 4px 16px rgba(15, 23, 42, 0.1);
padding: 24px;
- width: 100%; max-width: 380px;
- animation: cdialog-slide-in 160ms cubic-bezier(.34,1.4,.64,1);
+ width: 100%;
+ max-width: 380px;
+ animation: cdialog-slide-in 160ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
@keyframes cdialog-slide-in {
- from { opacity: 0; transform: scale(.94) translateY(-6px); }
- to { opacity: 1; transform: scale(1) translateY(0); }
+ from {
+ opacity: 0;
+ transform: scale(0.94) translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
}
-.cdialog-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
+.cdialog-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
.cdialog-icon-wrap {
flex-shrink: 0;
- width: 38px; height: 38px;
+ width: 38px;
+ height: 38px;
border-radius: var(--radius-sm);
- display: grid; place-items: center;
+ display: grid;
+ place-items: center;
+}
+.cdialog-icon-wrap--danger {
+ background: var(--danger-50);
+ color: var(--danger-500);
+}
+.cdialog-icon-wrap--warning {
+ background: var(--warning-50);
+ color: var(--warning-500);
}
-.cdialog-icon-wrap--danger { background: var(--danger-50); color: var(--danger-500); }
-.cdialog-icon-wrap--warning { background: var(--warning-50); color: var(--warning-500); }
-.cdialog-title { font-size: .9375rem; font-weight: 700; color: var(--text); }
+.cdialog-title {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: var(--text);
+}
.cdialog-message {
- font-size: .8125rem; color: var(--text-secondary);
- line-height: 1.6; margin-bottom: 20px;
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+ line-height: 1.6;
+ margin-bottom: 20px;
padding-left: 50px; /* align with title */
}
.cdialog-actions {
- display: flex; gap: 8px; justify-content: flex-end;
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
}
diff --git a/package.json b/package.json
index 985d7da..0749fa2 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"dev:client": "cd client && npm run dev",
"build:client": "cd client && npm run build",
"db": "node scripts/query-db.js",
+ "inspect:is24-detail": "node scripts/inspect-is24-detail-api.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/ tests/",
diff --git a/src/db/database.js b/src/db/database.js
index 1932d25..9fd7b1c 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -30,6 +30,8 @@ db.exec(`
size TEXT,
rooms TEXT,
address TEXT,
+ lat REAL,
+ lon REAL,
description TEXT,
publisher TEXT,
link TEXT NOT NULL,
@@ -76,6 +78,65 @@ db.exec(`
FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
FOREIGN KEY (search_config_id) REFERENCES search_configs(id) ON DELETE CASCADE
);
+
+ CREATE TABLE IF NOT EXISTS listing_details (
+ listing_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ expose_id TEXT,
+ fetched_at TEXT NOT NULL,
+ source_version TEXT,
+ status TEXT NOT NULL DEFAULT 'ok',
+ error TEXT,
+ available_from TEXT,
+ available_from_source TEXT,
+ cold_rent TEXT,
+ warm_rent TEXT,
+ service_charge TEXT,
+ deposit TEXT,
+ price_per_sqm TEXT,
+ floor TEXT,
+ bedrooms TEXT,
+ bathrooms TEXT,
+ pets TEXT,
+ has_kitchen INTEGER,
+ has_cellar INTEGER,
+ has_balcony INTEGER,
+ has_garden INTEGER,
+ has_lift INTEGER,
+ barrier_free INTEGER,
+ construction_year TEXT,
+ condition TEXT,
+ heating_type TEXT,
+ energy_carrier TEXT,
+ energy_class TEXT,
+ energy_value TEXT,
+ description TEXT,
+ location_description TEXT,
+ address_line1 TEXT,
+ address_line2 TEXT,
+ lat REAL,
+ lon REAL,
+ agent_name TEXT,
+ contact_phone_numbers TEXT,
+ contact_available INTEGER,
+ images TEXT,
+ attribute_groups TEXT,
+ raw_detail_json TEXT,
+ FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS map_location_cache (
+ query TEXT PRIMARY KEY,
+ fetched_at TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'ok',
+ source TEXT,
+ label TEXT,
+ precision TEXT,
+ lat REAL,
+ lon REAL,
+ bbox_json TEXT,
+ geometry_geojson TEXT
+ );
`);
// ── Migrations for existing DBs ───────────────────────────────────────────
@@ -91,6 +152,8 @@ safeAlter('ALTER TABLE listings ADD COLUMN rooms TEXT');
safeAlter('ALTER TABLE listings ADD COLUMN publisher TEXT');
safeAlter('ALTER TABLE listings ADD COLUMN listed_at TEXT');
safeAlter('ALTER TABLE listings ADD COLUMN images TEXT');
+safeAlter('ALTER TABLE listings ADD COLUMN lat REAL');
+safeAlter('ALTER TABLE listings ADD COLUMN lon REAL');
safeAlter("ALTER TABLE listings ADD COLUMN provider TEXT DEFAULT 'kleinanzeigen'");
safeAlter("ALTER TABLE listings ADD COLUMN listing_type TEXT DEFAULT 'miete'");
safeAlter('ALTER TABLE listings ADD COLUMN search_config_id INTEGER');
@@ -124,6 +187,12 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_listings_link ON listings(link)');
// Index for fast agent lookups on the junction table
db.exec('CREATE INDEX IF NOT EXISTS idx_listing_agents_config ON listing_agents(search_config_id)');
+// Index for ordering or inspecting detail fetch timestamps
+db.exec('CREATE INDEX IF NOT EXISTS idx_listing_details_fetched ON listing_details(fetched_at)');
+
+// Cache lookup for address/district geocoding
+db.exec('CREATE INDEX IF NOT EXISTS idx_map_location_cache_status ON map_location_cache(status)');
+
// Migration: populate listing_agents from existing search_config_id values
{
const result = db
@@ -349,13 +418,15 @@ export function upsertListing(l) {
db.prepare(
`
- INSERT INTO listings (id, source, provider, listing_type, title, price, size, rooms, address, description, publisher, link, image, is_blacklisted, listed_at, available_from, first_seen, last_seen, scrape_rank)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO listings (id, source, provider, listing_type, title, price, size, rooms, address, lat, lon, description, publisher, link, image, is_blacklisted, listed_at, available_from, first_seen, last_seen, scrape_rank)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
price = excluded.price,
size = excluded.size,
rooms = COALESCE(excluded.rooms, rooms),
address = excluded.address,
+ lat = COALESCE(excluded.lat, lat),
+ lon = COALESCE(excluded.lon, lon),
description = excluded.description,
publisher = COALESCE(excluded.publisher, publisher),
image = excluded.image,
@@ -374,6 +445,8 @@ export function upsertListing(l) {
l.size ?? null,
l.rooms ?? null,
l.address ?? null,
+ l.lat ?? null,
+ l.lon ?? null,
l.description ?? null,
l.publisher ?? null,
l.link,
@@ -510,6 +583,230 @@ export function getListingById(id) {
return { ...row, agent_ids: row.agent_ids ? row.agent_ids.split(',').map(Number) : [] };
}
+function jsonString(value) {
+ return value == null ? null : JSON.stringify(value);
+}
+
+function jsonValue(value, fallback) {
+ if (!value) return fallback;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+function normalizeDetailRow(row) {
+ if (!row) return null;
+ return {
+ ...row,
+ contact_phone_numbers: jsonValue(row.contact_phone_numbers, []),
+ images: jsonValue(row.images, []),
+ attribute_groups: jsonValue(row.attribute_groups, []),
+ raw_detail_json: jsonValue(row.raw_detail_json, null),
+ };
+}
+
+export function upsertListingDetail(detail) {
+ const fetchedAt = detail.fetched_at ?? new Date().toISOString();
+
+ db.prepare(
+ `
+ INSERT INTO listing_details (
+ listing_id, provider, expose_id, fetched_at, source_version, status, error,
+ available_from, available_from_source,
+ cold_rent, warm_rent, service_charge, deposit, price_per_sqm,
+ floor, bedrooms, bathrooms, pets,
+ has_kitchen, has_cellar, has_balcony, has_garden, has_lift, barrier_free,
+ construction_year, condition, heating_type, energy_carrier, energy_class, energy_value,
+ description, location_description, address_line1, address_line2, lat, lon,
+ agent_name, contact_phone_numbers, contact_available, images, attribute_groups, raw_detail_json
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(listing_id) DO UPDATE SET
+ provider = excluded.provider,
+ expose_id = excluded.expose_id,
+ fetched_at = excluded.fetched_at,
+ source_version = excluded.source_version,
+ status = excluded.status,
+ error = excluded.error,
+ available_from = excluded.available_from,
+ available_from_source = excluded.available_from_source,
+ cold_rent = excluded.cold_rent,
+ warm_rent = excluded.warm_rent,
+ service_charge = excluded.service_charge,
+ deposit = excluded.deposit,
+ price_per_sqm = excluded.price_per_sqm,
+ floor = excluded.floor,
+ bedrooms = excluded.bedrooms,
+ bathrooms = excluded.bathrooms,
+ pets = excluded.pets,
+ has_kitchen = excluded.has_kitchen,
+ has_cellar = excluded.has_cellar,
+ has_balcony = excluded.has_balcony,
+ has_garden = excluded.has_garden,
+ has_lift = excluded.has_lift,
+ barrier_free = excluded.barrier_free,
+ construction_year = excluded.construction_year,
+ condition = excluded.condition,
+ heating_type = excluded.heating_type,
+ energy_carrier = excluded.energy_carrier,
+ energy_class = excluded.energy_class,
+ energy_value = excluded.energy_value,
+ description = excluded.description,
+ location_description = excluded.location_description,
+ address_line1 = excluded.address_line1,
+ address_line2 = excluded.address_line2,
+ lat = excluded.lat,
+ lon = excluded.lon,
+ agent_name = excluded.agent_name,
+ contact_phone_numbers = excluded.contact_phone_numbers,
+ contact_available = excluded.contact_available,
+ images = excluded.images,
+ attribute_groups = excluded.attribute_groups,
+ raw_detail_json = excluded.raw_detail_json
+ `,
+ ).run(
+ detail.listing_id,
+ detail.provider ?? 'immoscout24',
+ detail.expose_id ?? null,
+ fetchedAt,
+ detail.source_version ?? null,
+ detail.status ?? 'ok',
+ detail.error ?? null,
+ detail.available_from ?? null,
+ detail.available_from_source ?? null,
+ detail.cold_rent ?? null,
+ detail.warm_rent ?? null,
+ detail.service_charge ?? null,
+ detail.deposit ?? null,
+ detail.price_per_sqm ?? null,
+ detail.floor ?? null,
+ detail.bedrooms ?? null,
+ detail.bathrooms ?? null,
+ detail.pets ?? null,
+ detail.has_kitchen ?? null,
+ detail.has_cellar ?? null,
+ detail.has_balcony ?? null,
+ detail.has_garden ?? null,
+ detail.has_lift ?? null,
+ detail.barrier_free ?? null,
+ detail.construction_year ?? null,
+ detail.condition ?? null,
+ detail.heating_type ?? null,
+ detail.energy_carrier ?? null,
+ detail.energy_class ?? null,
+ detail.energy_value ?? null,
+ detail.description ?? null,
+ detail.location_description ?? null,
+ detail.address_line1 ?? null,
+ detail.address_line2 ?? null,
+ detail.lat ?? null,
+ detail.lon ?? null,
+ detail.agent_name ?? null,
+ jsonString(detail.contact_phone_numbers ?? []),
+ detail.contact_available ?? null,
+ jsonString(detail.images ?? []),
+ jsonString(detail.attribute_groups ?? []),
+ jsonString(detail.raw_detail_json ?? null),
+ );
+
+ if (
+ /^\d{4}-\d{2}-\d{2}$/.test(detail.available_from ?? '') ||
+ detail.available_from === 'sofort'
+ ) {
+ db.prepare('UPDATE listings SET available_from = COALESCE(available_from, ?) WHERE id = ?').run(
+ detail.available_from,
+ detail.listing_id,
+ );
+ }
+
+ if (Array.isArray(detail.images) && detail.images.length > 0) {
+ db.prepare('UPDATE listings SET images = COALESCE(images, ?) WHERE id = ?').run(
+ jsonString(detail.images),
+ detail.listing_id,
+ );
+ }
+}
+
+export function markListingDetailError({
+ listingId,
+ provider = 'immoscout24',
+ exposeId = null,
+ error,
+}) {
+ db.prepare(
+ `
+ INSERT INTO listing_details (listing_id, provider, expose_id, fetched_at, status, error)
+ VALUES (?, ?, ?, ?, 'error', ?)
+ ON CONFLICT(listing_id) DO UPDATE SET
+ provider = excluded.provider,
+ expose_id = excluded.expose_id,
+ fetched_at = excluded.fetched_at,
+ status = 'error',
+ error = excluded.error
+ `,
+ ).run(listingId, provider, exposeId, new Date().toISOString(), error ?? null);
+}
+
+export function getListingDetailById(listingId) {
+ const row = db.prepare('SELECT * FROM listing_details WHERE listing_id = ?').get(listingId);
+ return normalizeDetailRow(row);
+}
+
+function normalizeMapLocationRow(row) {
+ if (!row) return null;
+ return {
+ status: row.status,
+ source: row.source,
+ query: row.query,
+ label: row.label,
+ precision: row.precision,
+ lat: row.lat,
+ lon: row.lon,
+ bbox: jsonValue(row.bbox_json, null),
+ geometry_geojson: jsonValue(row.geometry_geojson, null),
+ fetched_at: row.fetched_at,
+ };
+}
+
+export function getCachedMapLocation(query) {
+ const row = db.prepare('SELECT * FROM map_location_cache WHERE query = ?').get(query);
+ return normalizeMapLocationRow(row);
+}
+
+export function upsertMapLocationCache(location) {
+ db.prepare(
+ `
+ INSERT INTO map_location_cache (
+ query, fetched_at, status, source, label, precision, lat, lon, bbox_json, geometry_geojson
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(query) DO UPDATE SET
+ fetched_at = excluded.fetched_at,
+ status = excluded.status,
+ source = excluded.source,
+ label = excluded.label,
+ precision = excluded.precision,
+ lat = excluded.lat,
+ lon = excluded.lon,
+ bbox_json = excluded.bbox_json,
+ geometry_geojson = excluded.geometry_geojson
+ `,
+ ).run(
+ location.query,
+ location.fetched_at ?? new Date().toISOString(),
+ location.status ?? 'ok',
+ location.source ?? null,
+ location.label ?? null,
+ location.precision ?? null,
+ location.lat ?? null,
+ location.lon ?? null,
+ jsonString(location.bbox ?? null),
+ jsonString(location.geometry_geojson ?? null),
+ );
+}
+
export function resetAll() {
db.exec('DELETE FROM listing_agents');
db.exec('DELETE FROM listings');
diff --git a/src/providers/immoscout24/detail.js b/src/providers/immoscout24/detail.js
new file mode 100644
index 0000000..7b5dc83
--- /dev/null
+++ b/src/providers/immoscout24/detail.js
@@ -0,0 +1,244 @@
+import { normalizeAvailableFrom } from '../../utils.js';
+
+const REQUEST_HEADERS = {
+ 'User-Agent': 'ImmoScout_28.3_34.0_._',
+ Accept: 'application/json',
+};
+
+const AVAILABLE_ATTR_LABEL = /(bezugsfrei|bezug|einzug|frei\s*ab|verfügbar\s*ab|verfuegbar\s*ab)/i;
+
+export function getExposeIdFromUrl(url) {
+ return String(url ?? '').match(/\/expose\/(\d+)/)?.[1] ?? null;
+}
+
+function cleanText(value) {
+ if (value == null) return null;
+ const text = String(value)
+ .replace(/\u00a0/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return text || null;
+}
+
+function cleanBlockText(value) {
+ if (value == null) return null;
+ const text = String(value)
+ .replace(/\u00a0/g, ' ')
+ .replace(/\r\n?/g, '\n')
+ .replace(/[ \t\f\v]+/g, ' ')
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+ return text || null;
+}
+
+function cleanLabel(value) {
+ return cleanText(value)?.replace(/:$/, '') ?? null;
+}
+
+export function normalizeDetailAvailableFrom(value, baseDate = new Date()) {
+ const normalized = normalizeAvailableFrom(value);
+ if (normalized !== value) return normalized;
+
+ const raw = cleanText(value);
+ const match = raw?.match(/^(\d{1,2})\.(\d{1,2})\.$/);
+ if (!match) return normalized;
+
+ let year = baseDate.getFullYear();
+ const candidate = new Date(year, Number(match[2]) - 1, Number(match[1]));
+ const today = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate());
+ if (candidate < today) year++;
+ return `${year}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`;
+}
+
+export function extractAvailableFromText(text) {
+ const value = cleanText(text);
+ if (!value) return null;
+
+ if (
+ /(?:bezugsfrei|verfügbar|verfuegbar|frei|einzug|bezug|ab)\s*(?:ab\s*)?sofort/i.test(value) ||
+ /sofort\s*(?:verfügbar|verfuegbar|frei|bezugsfrei)/i.test(value)
+ ) {
+ return 'sofort';
+ }
+
+ if (
+ /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei)[^.]{0,60}nach vereinbarung/i.test(value)
+ ) {
+ return 'nach Vereinbarung';
+ }
+
+ const patterns = [
+ /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei\s*ab|ab dem|ab|zum)\D{0,30}(\d{1,2}\.\d{1,2}\.(?:\d{2,4})?)/i,
+ /(\d{1,2}\.\d{1,2}\.(?:\d{2,4})?)\s*(?:verfügbar|verfuegbar|frei|bezugsfrei)/i,
+ /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei\s*ab|ab dem|ab|zum)\D{0,30}(\d{4}-\d{2}-\d{2})/i,
+ ];
+
+ for (const pattern of patterns) {
+ const match = value.match(pattern);
+ if (match) return match[1];
+ }
+
+ return null;
+}
+
+export function extractAvailableFromExposeDetail(detail) {
+ for (const section of detail?.sections ?? []) {
+ for (const attr of section.attributes ?? []) {
+ const label = cleanLabel(attr.label);
+ const text = cleanText(attr.text);
+ if (text && AVAILABLE_ATTR_LABEL.test(label)) return { value: text, source: label };
+ }
+ }
+
+ for (const [sectionIndex, section] of (detail?.sections ?? []).entries()) {
+ for (const key of ['title', 'text', 'subText']) {
+ const found = extractAvailableFromText(section[key]);
+ if (found) return { value: found, source: `sections[${sectionIndex}].${key}` };
+ }
+ }
+
+ return { value: null, source: null };
+}
+
+function attributeGroups(detail) {
+ return (detail?.sections ?? [])
+ .filter((section) => Array.isArray(section.attributes) && section.attributes.length > 0)
+ .map((section) => ({
+ type: section.type ?? null,
+ title: cleanText(section.title) || section.type || null,
+ attributes: section.attributes.map((attr) => ({
+ label: cleanLabel(attr.label),
+ value:
+ cleanText(attr.text) ?? cleanText(attr.value) ?? (attr.type === 'CHECK' ? true : null),
+ type: attr.type ?? null,
+ })),
+ }));
+}
+
+function flattenAttributes(groups) {
+ return groups.flatMap((group) => group.attributes);
+}
+
+function attrValue(attrs, pattern) {
+ const found = attrs.find((attr) => attr.label && pattern.test(attr.label));
+ return found?.value ?? null;
+}
+
+function attrBool(attrs, pattern) {
+ const found = attrs.find((attr) => attr.label && pattern.test(attr.label));
+ if (!found) return null;
+ if (found.value === true) return 1;
+ if (/^(ja|yes|true|vorhanden)$/i.test(String(found.value ?? ''))) return 1;
+ if (/^(nein|no|false)$/i.test(String(found.value ?? ''))) return 0;
+ return null;
+}
+
+function yesNo(value) {
+ if (value == null || value === '') return null;
+ if (/^(y|yes|true|1|ja)$/i.test(String(value))) return 1;
+ if (/^(n|no|false|0|nein)$/i.test(String(value))) return 0;
+ return null;
+}
+
+function textSection(detail, titlePattern) {
+ const section = (detail?.sections ?? []).find(
+ (s) => s.type === 'TEXT_AREA' && titlePattern.test(cleanText(s.title) ?? ''),
+ );
+ return cleanBlockText(section?.text);
+}
+
+function collectImages(detail) {
+ return (detail?.sections ?? [])
+ .flatMap((section) => section.media ?? [])
+ .filter((item) => item.type === 'PICTURE')
+ .map((item) => cleanText(item.fullImageUrl) ?? cleanText(item.previewImageUrl))
+ .filter(Boolean);
+}
+
+function collectPhoneNumbers(detail) {
+ return (detail?.contact?.phoneNumbers ?? [])
+ .map((phone) => ({
+ type: cleanText(phone.label ?? phone.type),
+ text: cleanText(phone.text),
+ }))
+ .filter((phone) => phone.text);
+}
+
+export function parseExposeDetail(detail, { listingId = null, exposeId = null } = {}) {
+ const groups = attributeGroups(detail);
+ const attrs = flattenAttributes(groups);
+ const targeting = detail?.adTargetingParameters ?? {};
+ const tracking = detail?.tracking?.parameters ?? {};
+ const mergedParams = { ...tracking, ...targeting };
+ const availability = extractAvailableFromExposeDetail(detail);
+ const mapSection = (detail?.sections ?? []).find((section) => section.type === 'MAP');
+
+ return {
+ listing_id: listingId,
+ provider: 'immoscout24',
+ expose_id: exposeId ?? cleanText(detail?.header?.id),
+ source_version: 'is24-mobile-expose-v1',
+ status: 'ok',
+ error: null,
+ available_from: normalizeDetailAvailableFrom(availability.value),
+ available_from_source: availability.source,
+ cold_rent: attrValue(attrs, /kaltmiete/i) ?? mergedParams.obj_baseRent ?? null,
+ warm_rent: attrValue(attrs, /warmmiete|gesamtmiete/i) ?? mergedParams.obj_totalRent ?? null,
+ service_charge: attrValue(attrs, /nebenkosten/i) ?? mergedParams.obj_serviceCharge ?? null,
+ deposit: attrValue(attrs, /kaution|genossenschaft/i),
+ price_per_sqm: attrValue(attrs, /preis\/m²|preis\/m2/i),
+ floor: attrValue(attrs, /etage/i),
+ bedrooms: attrValue(attrs, /schlafzimmer/i),
+ bathrooms: attrValue(attrs, /badezimmer/i),
+ pets: attrValue(attrs, /haustiere/i) ?? mergedParams.obj_petsAllowed ?? null,
+ has_kitchen: attrBool(attrs, /einbauküche|küche/i) ?? yesNo(mergedParams.obj_hasKitchen),
+ has_cellar: attrBool(attrs, /keller/i) ?? yesNo(mergedParams.obj_cellar),
+ has_balcony: attrBool(attrs, /balkon/i) ?? yesNo(mergedParams.obj_balcony),
+ has_garden: attrBool(attrs, /garten/i) ?? yesNo(mergedParams.obj_garden),
+ has_lift: attrBool(attrs, /aufzug|lift/i) ?? yesNo(mergedParams.obj_lift),
+ barrier_free: attrBool(attrs, /stufenlos|barriere/i),
+ construction_year: attrValue(attrs, /baujahr/i),
+ condition: attrValue(attrs, /zustand/i) ?? mergedParams.obj_condition ?? null,
+ heating_type: attrValue(attrs, /heizungsart/i),
+ energy_carrier: attrValue(attrs, /energieträger|energietraeger/i),
+ energy_class: attrValue(attrs, /energieeffizienzklasse/i),
+ energy_value: attrValue(attrs, /endenergie/i),
+ description: textSection(detail, /objektbeschreibung|beschreibung/i),
+ location_description: textSection(detail, /lage/i),
+ address_line1: cleanText(mapSection?.addressLine1),
+ address_line2: cleanText(mapSection?.addressLine2),
+ lat: mapSection?.location?.lat ?? null,
+ lon: mapSection?.location?.lng ?? null,
+ agent_name: cleanText(detail?.contact?.contactData?.agent?.name),
+ contact_phone_numbers: collectPhoneNumbers(detail),
+ contact_available:
+ detail?.contact?.mailButtonState === 'active' || detail?.contact?.callButtonState === 'active'
+ ? 1
+ : 0,
+ images: collectImages(detail),
+ attribute_groups: groups,
+ raw_detail_json: detail,
+ };
+}
+
+export async function fetchExposeDetail(exposeId, { signal } = {}) {
+ const response = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
+ headers: REQUEST_HEADERS,
+ signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`IS24 detail API failed for expose ${exposeId}: HTTP ${response.status}`);
+ }
+
+ return response.json();
+}
+
+export async function fetchAndParseExposeDetail(listing, opts = {}) {
+ const exposeId = getExposeIdFromUrl(listing?.link) ?? listing?.expose_id;
+ if (!exposeId) throw new Error('No IS24 expose id found');
+
+ const raw = await fetchExposeDetail(exposeId, opts);
+ return parseExposeDetail(raw, { listingId: listing.id, exposeId });
+}
diff --git a/src/providers/immoscout24/index.js b/src/providers/immoscout24/index.js
index 2a4bb46..a33d007 100644
--- a/src/providers/immoscout24/index.js
+++ b/src/providers/immoscout24/index.js
@@ -327,7 +327,7 @@ export async function scrape(inputUrl, maxPages = 10, opts = {}) {
*
* @param {string} inputUrl
* @param {number} maxPages
- * @param {{ signal?: AbortSignal, onProgress?: Function, log?: Function }} opts
+ * @param {{ signal?: AbortSignal, onProgress?: Function, knownIds?: Set, log?: Function }} opts
* @returns {Promise<{ mobileUrl: string, hitCount: number|string, pageCount: number, targetPages: number, pages: Array<{ pageNum: number, listings: object[] }> }>}
*/
export async function scrapePages(inputUrl, maxPages = 10, opts = {}) {
diff --git a/src/providers/kleinanzeigen/detail.js b/src/providers/kleinanzeigen/detail.js
new file mode 100644
index 0000000..5ede56c
--- /dev/null
+++ b/src/providers/kleinanzeigen/detail.js
@@ -0,0 +1,358 @@
+import { normalizeAvailableFrom } from '../../utils.js';
+
+const REQUEST_HEADERS = {
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+ 'Accept-Language': 'de-DE,de;q=0.9',
+ Accept: 'text/html',
+};
+
+const MONTHS = {
+ januar: '01',
+ februar: '02',
+ maerz: '03',
+ märz: '03',
+ april: '04',
+ mai: '05',
+ juni: '06',
+ juli: '07',
+ august: '08',
+ september: '09',
+ oktober: '10',
+ november: '11',
+ dezember: '12',
+};
+
+export function getAdIdFromUrl(url) {
+ return String(url ?? '').match(/\/(\d+)-\d+-\d+(?:[/?#]|$)/)?.[1] ?? null;
+}
+
+function cleanText(value) {
+ if (value == null) return null;
+ const text = decodeHtml(String(value))
+ .replace(/\u00a0/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return text || null;
+}
+
+function cleanBlockText(value) {
+ if (value == null) return null;
+ const text = decodeHtml(String(value))
+ .replace(/\u00a0/g, ' ')
+ .replace(/\r\n?/g, '\n')
+ .replace(/[ \t\f\v]+/g, ' ')
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+ return text || null;
+}
+
+function stripTags(value) {
+ return cleanText(
+ String(value ?? '')
+ .replace(/
/gi, '\n')
+ .replace(/<\/p\s*>/gi, '\n')
+ .replace(/<[^>]+>/g, ' '),
+ );
+}
+
+function stripBlockTags(value) {
+ return cleanBlockText(
+ String(value ?? '')
+ .replace(/
/gi, '\n')
+ .replace(/<\/p\s*>/gi, '\n\n')
+ .replace(/<[^>]+>/g, ' '),
+ );
+}
+
+function decodeHtml(value) {
+ return String(value ?? '')
+ .replace(/([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
+ .replace(/(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+}
+
+function extractFirst(html, pattern) {
+ const match = html.match(pattern);
+ return match ? stripTags(match[1]) : null;
+}
+
+function extractFirstBlock(html, pattern) {
+ const match = html.match(pattern);
+ return match ? stripBlockTags(match[1]) : null;
+}
+
+function metaPropertyContent(html, property) {
+ for (const match of html.matchAll(/
]*>/gi)) {
+ const tag = match[0];
+ const tagProperty = tag.match(/\bproperty=["']([^"']+)["']/i)?.[1];
+ if (tagProperty !== property) continue;
+ const content = tag.match(/\bcontent=["']([^"']*)["']/i)?.[1];
+ return cleanText(content);
+ }
+ return null;
+}
+
+function numberValue(value) {
+ const text = cleanText(value);
+ if (!text) return null;
+ const number = Number(text.replace(',', '.'));
+ return Number.isFinite(number) ? number : null;
+}
+
+function sectionHtml(html, id) {
+ const start = html.indexOf(`id="${id}"`);
+ if (start < 0) return '';
+ const next = html.indexOf('id="viewad-', start + id.length);
+ return html.slice(start, next > start ? next : start + 12000);
+}
+
+function extractAttributes(html) {
+ const details = sectionHtml(html, 'viewad-details');
+ const attributes = [];
+ const pattern =
+ /
]*class="[^"]*addetailslist--detail[^"]*"[^>]*>([\s\S]*?)]*class="[^"]*addetailslist--detail--value[^"]*"[^>]*>([\s\S]*?)<\/span>[\s\S]*?<\/li>/gi;
+
+ for (const match of details.matchAll(pattern)) {
+ const label = stripTags(match[1])?.replace(/:$/, '');
+ const value = stripTags(match[2]);
+ if (label && value) attributes.push({ label, value, type: 'TEXT' });
+ }
+
+ return attributes;
+}
+
+function extractFeatures(html) {
+ const config = sectionHtml(html, 'viewad-configuration');
+ return [...config.matchAll(/]*class="[^"]*checktag[^"]*"[^>]*>([\s\S]*?)<\/li>/gi)]
+ .map((match) => stripTags(match[1]))
+ .filter(Boolean);
+}
+
+function extractImages(html) {
+ const urls = new Set();
+
+ for (const match of html.matchAll(
+ /
+
+
+ `;
+
+ it('extracts the ad id from a Kleinanzeigen URL', () => {
+ expect(
+ getAdIdFromUrl('https://www.kleinanzeigen.de/s-anzeige/schoene-wohnung/3364804426-199-4308'),
+ ).toBe('3364804426');
+ });
+
+ it('parses detail attributes, features and normalized availability', () => {
+ const detail = parseKleinanzeigenDetailHtml(HTML, {
+ listingId: 'listing-1',
+ url: 'https://www.kleinanzeigen.de/s-anzeige/schoene-wohnung/3364804426-199-4308',
+ });
+
+ expect(detail.provider).toBe('kleinanzeigen');
+ expect(detail.expose_id).toBe('3364804426');
+ expect(detail.available_from).toBe('2026-04-01');
+ expect(detail.available_from_source).toBe('attribute:Verfügbar ab');
+ expect(detail.warm_rent).toBe('700 €');
+ expect(detail.deposit).toBe('500€');
+ expect(detail.description).toBe('Ab dem 01.04. frei.\n\n500€ Kaution.\nWeitere Zeile.');
+ expect(detail.address_line1).toBe('60320 Frankfurt am Main - Westend');
+ expect(detail.lat).toBe(50.1354734);
+ expect(detail.lon).toBe(8.6718962);
+ expect(detail.agent_name).toBe('Semih K');
+ expect(detail.has_cellar).toBe(1);
+ expect(detail.has_kitchen).toBe(1);
+ expect(detail.barrier_free).toBe(1);
+ expect(detail.attribute_groups[0].attributes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: 'Wohnfläche', value: '18 m²' }),
+ expect.objectContaining({ label: 'Zimmer', value: '3' }),
+ ]),
+ );
+ expect(detail.images).toHaveLength(1);
+ });
+
+ it('keeps coordinates null when the page does not expose map metadata', () => {
+ const html = HTML.replace('', '').replace(
+ '',
+ '',
+ );
+ const detail = parseKleinanzeigenDetailHtml(html);
+ expect(detail.lat).toBeNull();
+ expect(detail.lon).toBeNull();
+ });
+
+ it('does not expose a hidden/login-required phone number', () => {
+ const detail = parseKleinanzeigenDetailHtml(HTML);
+ expect(detail.contact_phone_numbers).toEqual([]);
+ expect(detail.raw_detail_json.login_required_for_phone).toBe(true);
+ });
+
+ it('extracts a visible phone number when the page exposes one', () => {
+ const html = HTML.replace(
+ 'hasVisiblePhoneNumber: false',
+ 'hasVisiblePhoneNumber: true',
+ ).replace("adPhoneNumber: ''", "adPhoneNumber: '+49 151 12345678'");
+ const detail = parseKleinanzeigenDetailHtml(html);
+ expect(detail.contact_phone_numbers).toEqual([{ type: 'Telefon', text: '+49 151 12345678' }]);
+ });
+});
diff --git a/tests/mapLocation.test.js b/tests/mapLocation.test.js
new file mode 100644
index 0000000..e99374f
--- /dev/null
+++ b/tests/mapLocation.test.js
@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest';
+import {
+ addressLooksRegional,
+ cleanMapAddress,
+ mapAddressCandidates,
+ mapLocationFromCoordinates,
+ selectBestNominatimLocation,
+} from '../src/utils/mapLocation.js';
+
+describe('map location helpers', () => {
+ it('removes provider placeholder text from incomplete addresses', () => {
+ expect(
+ cleanMapAddress(
+ 'Die vollständige Adresse der Immobilie erhältst du vom Anbieter., 68165 Schwetzingerstadt/Oststadt, Mannheim',
+ ),
+ ).toBe('68165 Schwetzingerstadt/Oststadt, Mannheim');
+ });
+
+ it('generates useful candidates for postcode and slash districts', () => {
+ expect(mapAddressCandidates('68165 Schwetzingerstadt/Oststadt, Mannheim')).toEqual([
+ '68165 Schwetzingerstadt/Oststadt, Mannheim',
+ 'Schwetzingerstadt/Oststadt, Mannheim',
+ 'Schwetzingerstadt, Mannheim',
+ 'Oststadt, Mannheim',
+ '68165 Mannheim',
+ 'Mannheim',
+ ]);
+ });
+
+ it('recognizes postcode/district strings as regional addresses', () => {
+ expect(addressLooksRegional('68165 Schwetzingerstadt/Oststadt, Mannheim')).toBe(true);
+ expect(addressLooksRegional('Käfertaler Straße 12, Mannheim')).toBe(false);
+ expect(addressLooksRegional('Mönchwörthstraße 19, 68199 Neckarau, Mannheim')).toBe(false);
+ expect(addressLooksRegional('Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)')).toBe(
+ false,
+ );
+ });
+
+ it('does not fall back from exact-looking addresses to city polygons', () => {
+ expect(mapAddressCandidates('Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)')).toEqual(
+ [
+ 'Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)',
+ 'Am Hungerberg 10, Hirschhorn (Neckar)',
+ ],
+ );
+ });
+
+ it('selects a regional polygon instead of a random building for regional queries', () => {
+ const location = selectBestNominatimLocation(
+ [
+ {
+ display_name: 'Nationaltheater Mannheim, 9, Mozartstraße, Oststadt, Mannheim',
+ type: 'construction',
+ addresstype: 'building',
+ lat: '49.4883241',
+ lon: '8.4777193',
+ boundingbox: ['49.4881370', '49.4886271', '8.4767593', '8.4786758'],
+ address: { house_number: '9', road: 'Mozartstraße' },
+ geojson: { type: 'Polygon', coordinates: [] },
+ },
+ {
+ display_name:
+ 'Schwetzingerstadt/Oststadt, Mannheim, Baden-Württemberg, 68165, Deutschland',
+ type: 'administrative',
+ addresstype: 'city_district',
+ lat: '49.4815281',
+ lon: '8.4894105',
+ boundingbox: ['49.4690924', '49.4940040', '8.4678293', '8.5084720'],
+ geojson: { type: 'Polygon', coordinates: [] },
+ },
+ ],
+ '68165 Schwetzingerstadt/Oststadt, Mannheim',
+ );
+
+ expect(location).toEqual(
+ expect.objectContaining({
+ precision: 'district',
+ label: 'Schwetzingerstadt/Oststadt, Mannheim, Baden-Württemberg, 68165, Deutschland',
+ }),
+ );
+ });
+
+ it('does not expose building polygons for exact address queries', () => {
+ const location = selectBestNominatimLocation(
+ [
+ {
+ display_name:
+ 'Mönchwörthstraße 19, Neckarau, Mannheim, Baden-Württemberg, 68199, Deutschland',
+ type: 'apartments',
+ addresstype: 'building',
+ lat: '49.4494600',
+ lon: '8.4865000',
+ boundingbox: ['49.4493000', '49.4496200', '8.4863000', '8.4867000'],
+ address: { house_number: '19', road: 'Mönchwörthstraße' },
+ geojson: { type: 'Polygon', coordinates: [] },
+ },
+ ],
+ 'Mönchwörthstraße 19, 68199 Neckarau, Mannheim',
+ );
+
+ expect(location).toEqual(
+ expect.objectContaining({
+ precision: 'exact',
+ bbox: null,
+ geometry_geojson: null,
+ }),
+ );
+ });
+
+ it('maps exact addresses without a street suffix to exact points', () => {
+ const location = selectBestNominatimLocation(
+ [
+ {
+ display_name: '10, Am Hungerberg, Ersheim, Hirschhorn, Hessen, 69434, Deutschland',
+ type: 'yes',
+ addresstype: 'building',
+ lat: '49.4505322',
+ lon: '8.9070176',
+ boundingbox: ['49.4503939', '49.4505792', '8.9068359', '8.9071497'],
+ address: { house_number: '10', road: 'Am Hungerberg' },
+ geojson: { type: 'Polygon', coordinates: [] },
+ },
+ ],
+ 'Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)',
+ );
+
+ expect(location).toEqual(
+ expect.objectContaining({
+ precision: 'exact',
+ bbox: null,
+ geometry_geojson: null,
+ }),
+ );
+ });
+
+ it('uses Nominatim road fields for street-level results', () => {
+ const location = selectBestNominatimLocation(
+ [
+ {
+ display_name: 'Am Hungerberg, Hirschhorn, Hessen, 69434, Deutschland',
+ type: 'residential',
+ addresstype: 'road',
+ lat: '49.4502000',
+ lon: '8.9069000',
+ boundingbox: ['49.4499000', '49.4506000', '8.9065000', '8.9073000'],
+ address: { road: 'Am Hungerberg' },
+ },
+ ],
+ 'Am Hungerberg, Hirschhorn',
+ );
+
+ expect(location).toEqual(
+ expect.objectContaining({
+ precision: 'street',
+ bbox: null,
+ geometry_geojson: null,
+ }),
+ );
+ });
+
+ it('does not convert missing coordinates into a 0/0 map point', () => {
+ expect(mapLocationFromCoordinates({ lat: null, lon: null })).toBeNull();
+ });
+});