feat: scammer blocklist, search/listing UI overhaul, tier refactor

**Scammer blocklist**
- migration 006: scammer_blocklist table (platform + seller_id unique key,
  source: manual|csv_import|community)
- ScammerEntry dataclass + Store.add/remove/list_blocklist methods
- blocklist.ts Pinia store — CRUD, export CSV, import CSV with validation
- BlocklistView.vue — list with search, export/import, bulk-remove; sellers
  show on ListingCard with force-score-0 badge
- API: GET/POST/DELETE /api/blocklist + CSV export/import endpoints
- Router: /blocklist route added; AppNav link

**Migration renumber**
- 002_background_tasks.sql → 007_background_tasks.sql (correct sequence
  after blocklist; idempotent CREATE IF NOT EXISTS safe for existing DBs)

**Search + listing UI overhaul**
- SearchView.vue: keyword expansion preview, filter chips for condition/
  format/price, saved-search quick-run button, paginated results
- ListingCard.vue: trust tier badge, scammer flag overlay, photo count
  chip, quick-block button, save-to-search action
- savedSearches store: optimistic update on run, last-run timestamp

**Tier refactor**
- tiers.py: full rewrite with docstring ladder, BYOK LOCAL_VISION_UNLOCKABLE
  flag, intentionally-free list with rationale (scammer_db, saved_searches,
  market_comps free to maximise adoption)

**Trust aggregator + scraper**
- aggregator.py: blocklist check short-circuits scoring to 0/BAD_ACTOR
- scraper.py: listing format detection, photo count, improved title parsing

**Theme**
- theme.css: trust tier color tokens, badge variants, blocklist badge
This commit is contained in:
pyr0ball 2026-04-03 19:08:54 -07:00
parent d9660093b1
commit e93e3de207
22 changed files with 1176 additions and 113 deletions

View file

@ -159,6 +159,7 @@ docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python cha
Online auctions are frustrating because:
- Winning requires being present at the exact closing moment — sometimes 2 AM
- Platforms vary wildly: some allow proxy bids, some don't; closing times extend on activity
- Scammers exploit auction urgency — new accounts, stolen photos, pressure to pay outside platform
- Price history is hidden — you don't know if an item is underpriced or a trap
- Sellers hide damage in descriptions rather than titles to avoid automated filters
- Shipping logistics for large / fragile antiques require coordination with the auction house

View file

@ -26,6 +26,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import jwt as pyjwt
import requests
from fastapi import Depends, HTTPException, Request
@ -109,7 +110,6 @@ def validate_session_jwt(token: str) -> str:
Directus 11+ uses 'id' (not 'sub') for the user UUID in its JWT payload.
"""
try:
import jwt as pyjwt
payload = pyjwt.decode(
token,
DIRECTUS_JWT_SECRET,

View file

@ -1,18 +0,0 @@
-- 002_background_tasks.sql
-- Shared background task queue used by the LLM/vision task scheduler.
-- Schema mirrors the circuitforge-core standard.
CREATE TABLE IF NOT EXISTS background_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
job_id INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued',
params TEXT,
error TEXT,
stage TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bg_tasks_status_type
ON background_tasks (status, task_type);

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS scammer_blocklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
platform_seller_id TEXT NOT NULL,
username TEXT NOT NULL,
reason TEXT,
source TEXT NOT NULL DEFAULT 'manual', -- manual | csv_import | community
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(platform, platform_seller_id)
);
CREATE INDEX IF NOT EXISTS idx_scammer_blocklist_lookup
ON scammer_blocklist(platform, platform_seller_id);

View file

@ -82,6 +82,18 @@ class SavedSearch:
last_run_at: Optional[str] = None
@dataclass
class ScammerEntry:
"""A seller manually or community-flagged as a known scammer."""
platform: str
platform_seller_id: str
username: str
reason: Optional[str] = None
source: str = "manual" # "manual" | "csv_import" | "community"
id: Optional[int] = None
created_at: Optional[str] = None
@dataclass
class PhotoHash:
"""Perceptual hash store for cross-search dedup (v0.2+). Schema scaffolded in v0.1."""

View file

@ -32,6 +32,9 @@ EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
EBAY_ITEM_URL = "https://www.ebay.com/itm/"
_HTML_CACHE_TTL = 300 # seconds — 5 minutes
_JOINED_RE = re.compile(r"Joined\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+(\d{4})", re.I)
# Matches "username (1,234) 99.1% positive feedback" on /itm/ listing pages.
# Capture groups: 1=raw_count ("1,234"), 2=ratio_pct ("99.1").
_ITEM_FEEDBACK_RE = re.compile(r'\((\d[\d,]*)\)\s*([\d.]+)%\s*positive', re.I)
_MONTH_MAP = {m: i+1 for i, m in enumerate(
["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
)}
@ -371,6 +374,23 @@ class ScrapedEbayAdapter(PlatformAdapter):
except ValueError:
return None
@staticmethod
def _parse_feedback_from_item(html: str) -> tuple[Optional[int], Optional[float]]:
"""Parse feedback count and ratio from a listing page seller card.
Matches 'username (1,234) 99.1% positive feedback'.
Returns (count, ratio) or (None, None) if not found.
"""
m = _ITEM_FEEDBACK_RE.search(html)
if not m:
return None, None
try:
count = int(m.group(1).replace(",", ""))
ratio = float(m.group(2)) / 100.0
return count, ratio
except ValueError:
return None, None
def enrich_sellers_btf(
self,
seller_to_listing: dict[str, str],
@ -387,19 +407,38 @@ class ScrapedEbayAdapter(PlatformAdapter):
Does not raise failures per-seller are silently skipped so the main
search response is never blocked.
"""
db_path = self._store._db_path # capture for thread-local Store creation
def _enrich_one(item: tuple[str, str]) -> None:
seller_id, listing_id = item
try:
html = self._fetch_item_html(listing_id)
age_days = self._parse_joined_date(html)
if age_days is not None:
seller = self._store.get_seller("ebay", seller_id)
if seller:
fb_count, fb_ratio = self._parse_feedback_from_item(html)
log.debug(
"BTF enrich: seller=%s age_days=%s feedback=%s ratio=%s",
seller_id, age_days, fb_count, fb_ratio,
)
if age_days is None and fb_count is None:
return # nothing new to write
thread_store = Store(db_path)
seller = thread_store.get_seller("ebay", seller_id)
if not seller:
log.warning("BTF enrich: seller %s not found in DB", seller_id)
return
from dataclasses import replace
updated = replace(seller, account_age_days=age_days)
self._store.save_seller(updated)
except Exception:
pass # non-fatal: partial score is better than a crashed enrichment
updates: dict = {}
if age_days is not None:
updates["account_age_days"] = age_days
# Only overwrite feedback if the listing page found a real value —
# prefer a fresh count over a 0 that came from a failed search parse.
if fb_count is not None:
updates["feedback_count"] = fb_count
if fb_ratio is not None:
updates["feedback_ratio"] = fb_ratio
thread_store.save_seller(replace(seller, **updates))
except Exception as exc:
log.warning("BTF enrich failed for %s/%s: %s", seller_id, listing_id, exc)
with ThreadPoolExecutor(max_workers=max_workers) as ex:
list(ex.map(_enrich_one, seller_to_listing.items()))

View file

@ -1,22 +1,44 @@
"""Snipe feature gates. Delegates to circuitforge_core.tiers."""
"""Snipe feature gates. Delegates to circuitforge_core.tiers.
Tier ladder: free < paid < premium
Ultra is not used in Snipe auto-bidding is the highest-impact feature and is Premium.
BYOK unlock analog: LOCAL_VISION_UNLOCKABLE photo_analysis and serial_number_check
unlock when the user has a local vision model (moondream2 (MD2) or equivalent).
Intentionally ungated (free for all):
- metadata_trust_scoring core value prop; wide adoption preferred
- hash_dedup infrastructure, not a differentiator
- market_comps useful enough to drive signups; not scarce
- scammer_db community data is more valuable with wider reach
- saved_searches retention feature; friction cost outweighs gate value
"""
from __future__ import annotations
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
# Feature key → minimum tier required.
FEATURES: dict[str, str] = {
# Free tier
"metadata_trust_scoring": "free",
"hash_dedup": "free",
# Paid tier
"photo_analysis": "paid",
"serial_number_check": "paid",
"ai_image_detection": "paid",
"reverse_image_search": "paid",
"saved_searches": "paid",
"background_monitoring": "paid",
"ebay_oauth": "paid", # full trust scores via eBay Trading API
"background_monitoring": "paid", # limited at Paid; see LIMITS below
# Premium tier
"auto_bidding": "premium",
}
# Photo analysis features unlock if user has local vision model (moondream2 (MD2) or similar).
# Per-feature usage limits by tier. None = unlimited.
# Call get_limit(feature, tier) at enforcement points (e.g. before creating a new monitor).
LIMITS: dict[tuple[str, str], int | None] = {
("background_monitoring", "paid"): 5,
("background_monitoring", "premium"): 25,
}
# Unlock photo_analysis and serial_number_check when user has a local vision model.
# Same policy as Peregrine's BYOK_UNLOCKABLE: user is providing the compute.
LOCAL_VISION_UNLOCKABLE: frozenset[str] = frozenset({
"photo_analysis",
"serial_number_check",
@ -32,3 +54,19 @@ def can_use(
if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE:
return True
return _core_can_use(feature, tier, has_byok=has_byok, _features=FEATURES)
def get_limit(feature: str, tier: str) -> int | None:
"""Return the usage limit for a feature at the given tier.
Returns None if the feature is unlimited at this tier.
Returns None if the feature has no entry in LIMITS (treat as unlimited).
Call can_use() first get_limit() does not check tier eligibility.
Example:
if can_use("background_monitoring", tier):
limit = get_limit("background_monitoring", tier)
if limit is not None and current_count >= limit:
raise LimitExceeded(f"Paid tier allows {limit} active monitors. Upgrade to Premium for unlimited.")
"""
return LIMITS.get((feature, tier))

View file

@ -41,6 +41,7 @@ class TrustScorer:
scores = []
for listing, is_dup in zip(listings, duplicates):
seller = self._store.get_seller("ebay", listing.seller_platform_id)
blocklisted = self._store.is_blocklisted("ebay", listing.seller_platform_id)
if seller:
signal_scores = self._meta.score(seller, market_median, listing.price, price_cv)
else:
@ -55,6 +56,7 @@ class TrustScorer:
first_seen_at=listing.first_seen_at,
price=listing.price,
price_at_first_seen=listing.price_at_first_seen,
is_blocklisted=blocklisted,
)
scores.append(trust)
return scores

View file

@ -76,6 +76,7 @@ class Aggregator:
first_seen_at: Optional[str] = None,
price: float = 0.0,
price_at_first_seen: Optional[float] = None,
is_blocklisted: bool = False,
) -> TrustScore:
is_partial = any(v is None for v in signal_scores.values())
clean = {k: (v if v is not None else 0) for k, v in signal_scores.items()}
@ -92,6 +93,23 @@ class Aggregator:
red_flags: list[str] = []
# Blocklist: force established_bad_actor and zero the score regardless of other signals.
if is_blocklisted:
red_flags.append("established_bad_actor")
composite = 0
return TrustScore(
listing_id=listing_id,
composite_score=composite,
account_age_score=clean["account_age"],
feedback_count_score=clean["feedback_count"],
feedback_ratio_score=clean["feedback_ratio"],
price_vs_market_score=clean["price_vs_market"],
category_history_score=clean["category_history"],
photo_hash_duplicate=photo_hash_duplicate,
red_flags_json=json.dumps(red_flags),
score_is_partial=is_partial,
)
# Hard filters
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
red_flags.append("new_account")
@ -100,6 +118,11 @@ class Aggregator:
and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT
):
red_flags.append("established_bad_actor")
if seller and seller.feedback_count == 0:
red_flags.append("zero_feedback")
# Zero feedback is a deliberate signal, not missing data — cap composite score
# so a 0-feedback seller can never appear trustworthy on other signals alone.
composite = min(composite, 35)
# Soft flags
if seller and seller.account_age_days is not None and seller.account_age_days < 30:

View file

@ -17,10 +17,12 @@ dependencies = [
"beautifulsoup4>=4.12",
"lxml>=5.0",
"fastapi>=0.111",
"python-multipart>=0.0.9",
"uvicorn[standard]>=0.29",
"playwright>=1.44",
"playwright-stealth>=1.0",
"cryptography>=42.0",
"PyJWT>=2.8",
]
[tool.setuptools.packages.find]

View file

@ -18,17 +18,20 @@ import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
import AppNav from './components/AppNav.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
const session = useSessionStore()
const blocklistStore = useBlocklistStore()
useKonamiCode(activate)
onMounted(() => {
restore() // re-apply snipe mode from localStorage on hard reload
session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
})
</script>

View file

@ -4,7 +4,10 @@
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
*/
/* ── Snipe — dark tactical (default — always dark) ─ */
/* Snipe dark tactical (default)
Light variant is defined below via prefers-color-scheme.
Snipe Mode easter egg always overrides both.
*/
:root {
/* Brand — amber target reticle */
--app-primary: #f59e0b;
@ -71,6 +74,49 @@
--sidebar-width: 220px;
}
/* Light mode field notebook / tactical map
Warm cream surfaces with the same amber accent.
Snipe Mode data attribute overrides this via higher specificity.
*/
@media (prefers-color-scheme: light) {
:root:not([data-snipe-mode="active"]) {
/* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee;
--color-surface-2: #f0ece3;
--color-surface-raised: #e8e3d8;
/* Borders — warm khaki */
--color-border: #c8bfae;
--color-border-light: #dbd3c4;
/* Text — warm near-black ink */
--color-text: #1c1a16;
--color-text-muted: #6b6357;
--color-text-inverse: #f8f5ee;
/* Brand — amber stays identical (works great on light too) */
--app-primary: #d97706; /* slightly deeper for contrast on light */
--app-primary-hover: #b45309;
--app-primary-light: rgba(217, 119, 6, 0.12);
/* Trust signals — same hues, adjusted for legibility on cream */
--trust-high: #16a34a;
--trust-mid: #b45309;
--trust-low: #dc2626;
/* Semantic */
--color-success: #16a34a;
--color-error: #dc2626;
--color-warning: #b45309;
--color-info: #2563eb;
/* Shadows — lighter, warm tint */
--shadow-sm: 0 1px 3px rgba(60, 45, 20, 0.12), 0 1px 2px rgba(60, 45, 20, 0.08);
--shadow-md: 0 4px 12px rgba(60, 45, 20, 0.15), 0 2px 4px rgba(60, 45, 20, 0.1);
--shadow-lg: 0 10px 30px rgba(60, 45, 20, 0.2), 0 4px 8px rgba(60, 45, 20, 0.1);
}
}
/* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */

View file

@ -66,6 +66,7 @@ import {
MagnifyingGlassIcon,
BookmarkIcon,
Cog6ToothIcon,
ShieldExclamationIcon,
} from '@heroicons/vue/24/outline'
import { useSnipeMode } from '../composables/useSnipeMode'
@ -74,11 +75,13 @@ const { active: isSnipeMode, deactivate } = useSnipeMode()
const navLinks = computed(() => [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
])
const mobileLinks = [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
]
</script>

View file

@ -4,6 +4,7 @@
:class="{
'steal-card': isSteal,
'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1,
'listing-card--triple-red': tripleRed,
}"
>
<!-- Thumbnail -->
@ -69,6 +70,25 @@
</p>
</div>
<!-- Block seller inline form -->
<div v-if="blockingOpen" class="card__block-popover" @click.stop>
<p class="card__block-title">Block <strong>{{ seller?.username }}</strong>?</p>
<input
v-model="blockReason"
class="card__block-reason"
placeholder="Reason (optional)"
maxlength="200"
@keydown.enter="onBlock"
@keydown.esc="blockingOpen = false"
autofocus
/>
<div class="card__block-actions">
<button class="card__block-confirm" @click="onBlock">Block</button>
<button class="card__block-cancel" @click="blockingOpen = false; blockReason = ''; blockError = ''">Cancel</button>
</div>
<p v-if="blockError" class="card__block-error">{{ blockError }}</p>
</div>
<!-- Score + price column -->
<div class="card__score-col">
<!-- Trust score badge -->
@ -98,6 +118,14 @@
:disabled="enriching"
@click.stop="onEnrich"
>{{ enrichError ? '✗' : '↻' }}</button>
<!-- Block seller -->
<button
v-if="seller"
class="card__block-btn"
:class="{ 'card__block-btn--active': isBlocked }"
:title="isBlocked ? 'Seller is blocked' : 'Block this seller'"
@click.stop="isBlocked ? null : (blockingOpen = !blockingOpen)"
></button>
</div>
<!-- Price -->
@ -123,6 +151,7 @@
import { computed, ref } from 'vue'
import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search'
import { useBlocklistStore } from '../stores/blocklist'
const props = defineProps<{
listing: Listing
@ -132,8 +161,32 @@ const props = defineProps<{
}>()
const store = useSearchStore()
const blocklist = useBlocklistStore()
const enriching = ref(false)
const enrichError = ref(false)
const blockingOpen = ref(false)
const blockReason = ref('')
const blockError = ref('')
const isBlocked = computed(() =>
blocklist.isBlocklisted(props.listing.seller_platform_id),
)
async function onBlock() {
if (!props.seller) return
blockError.value = ''
try {
await blocklist.addSeller(
props.listing.seller_platform_id,
props.seller.username,
blockReason.value.trim(),
)
blockingOpen.value = false
blockReason.value = ''
} catch {
blockError.value = 'Failed to block — try again'
}
}
async function onEnrich() {
if (enriching.value) return
@ -211,7 +264,11 @@ function flagLabel(flag: string): string {
suspicious_price: '✗ Suspicious price',
duplicate_photo: '✗ Duplicate photo',
established_bad_actor: '✗ Bad actor',
zero_feedback: '✗ No feedback',
marketing_photo: '✗ Marketing photo',
scratch_dent_mentioned:'⚠ Damage mentioned',
long_on_market: '⚠ Long on market',
significant_price_drop:'⚠ Price dropped',
}
return labels[flag] ?? `${flag}`
}
@ -250,6 +307,20 @@ const trustBadgeTitle = computed(() => {
return `${base} · pending: ${pendingSignalNames.value.join(', ')} (search again to update)`
})
// Triple Red easter egg: account flag + suspicious price + at least one more hard flag
const tripleRed = computed(() => {
const flags = new Set(redFlags.value)
const hasAccountFlag = flags.has('new_account') || flags.has('account_under_30_days')
const hasPriceFlag = flags.has('suspicious_price')
const hasThirdFlag = (
flags.has('duplicate_photo') ||
flags.has('established_bad_actor') ||
flags.has('zero_feedback') ||
flags.has('scratch_dent_mentioned')
)
return hasAccountFlag && hasPriceFlag && hasThirdFlag
})
const isSteal = computed(() => {
const s = props.trust?.composite_score
if (!s || s < 80) return false
@ -470,6 +541,91 @@ const formattedMarket = computed(() => {
to { transform: rotate(360deg); }
}
.card__block-btn {
margin-top: 2px;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.7rem;
line-height: 1;
opacity: 0;
padding: 1px 4px;
transition: opacity 150ms ease, color 150ms ease, border-color 150ms ease;
}
.listing-card:hover .card__block-btn { opacity: 0.5; }
.listing-card:hover .card__block-btn:hover { opacity: 1; color: var(--color-error); border-color: var(--color-error); }
.card__block-btn--active { opacity: 1 !important; color: var(--color-error); border-color: var(--color-error); cursor: default; }
/* Block popover */
.card__block-popover {
position: absolute;
right: var(--space-4);
top: var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
z-index: 10;
min-width: 220px;
box-shadow: var(--shadow-lg, 0 4px 16px rgba(0,0,0,0.35));
}
.card__block-title {
font-size: 0.8125rem;
margin: 0 0 var(--space-2);
color: var(--color-text);
}
.card__block-reason {
width: 100%;
padding: var(--space-1) var(--space-2);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.8125rem;
box-sizing: border-box;
margin-bottom: var(--space-2);
}
.card__block-actions {
display: flex;
gap: var(--space-2);
}
.card__block-confirm {
flex: 1;
padding: var(--space-1) var(--space-2);
background: var(--color-error);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: opacity 120ms ease;
}
.card__block-confirm:hover { opacity: 0.85; }
.card__block-cancel {
padding: var(--space-1) var(--space-2);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.8125rem;
cursor: pointer;
}
.card__block-cancel:hover { border-color: var(--color-text-muted); }
.card__block-error {
font-size: 0.75rem;
color: var(--color-error);
margin: var(--space-1) 0 0;
}
.card__price-wrap {
display: flex;
flex-direction: column;
@ -498,11 +654,79 @@ const formattedMarket = computed(() => {
font-family: var(--font-mono);
}
/* ── Triple Red easter egg ──────────────────────────────────────────────── */
/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
.listing-card--triple-red {
animation: triple-red-glow 2s ease-in-out infinite;
}
.listing-card--triple-red::after {
content: '✗';
position: absolute;
right: var(--space-4);
bottom: var(--space-2);
font-size: 4rem;
font-weight: 900;
line-height: 1;
color: var(--color-error);
opacity: 0.15;
pointer-events: none;
z-index: 0;
animation: triple-red-glitch 2.4s steps(1) infinite;
transition: opacity 350ms ease;
user-select: none;
}
/* On hover: glow settles, ✗ fades away */
.listing-card--triple-red:hover {
animation: none;
border-color: var(--color-error);
box-shadow: 0 0 10px 2px rgba(248, 81, 73, 0.35);
}
.listing-card--triple-red:hover::after {
animation: none;
opacity: 0;
}
@keyframes triple-red-glow {
0%, 100% {
border-color: rgba(248, 81, 73, 0.5);
box-shadow: 0 0 5px 1px rgba(248, 81, 73, 0.2);
}
50% {
border-color: var(--color-error);
box-shadow: 0 0 14px 3px rgba(248, 81, 73, 0.45);
}
}
/* Glitch: mostly still, rapid jitter bursts every ~2.4s */
@keyframes triple-red-glitch {
0%, 80%, 100% { transform: translate(0, 0); opacity: 0.15; }
82% { transform: translate(-4px, 2px); opacity: 0.35; }
84% { transform: translate(3px, -2px); opacity: 0.1; }
86% { transform: translate(-3px, -3px); opacity: 0.4; }
88% { transform: translate(5px, 1px); opacity: 0.08; }
90% { transform: translate(-2px, 3px); opacity: 0.3; }
92% { transform: translate(0, 0); opacity: 0.15; }
}
/* Mobile: stack vertically */
@media (max-width: 600px) {
.listing-card {
grid-template-columns: 60px 1fr;
grid-template-columns: 68px 1fr;
grid-template-rows: auto auto;
padding: var(--space-3);
gap: var(--space-2);
}
.card__thumb {
width: 68px;
height: 68px;
}
.card__title {
font-size: 0.875rem;
}
.card__score-col {
@ -514,5 +738,26 @@ const formattedMarket = computed(() => {
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
/* Trust badge + dots: side by side instead of stacked */
.card__trust {
flex-direction: row;
gap: var(--space-2);
min-width: unset;
padding: var(--space-1) var(--space-3);
}
/* Price + market price: row layout */
.card__price-block {
flex-direction: row;
align-items: center;
gap: var(--space-3);
}
/* Enrich + block buttons: always visible on mobile (no hover) */
.card__enrich-btn,
.card__block-btn {
opacity: 0.6;
}
}
</style>

View file

@ -7,6 +7,7 @@ export const router = createRouter({
{ path: '/', component: SearchView },
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
{ path: '/blocklist', component: () => import('../views/BlocklistView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],

109
web/src/stores/blocklist.ts Normal file
View file

@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiFetch } from '../utils/api'
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
export interface BlocklistEntry {
id: number | null
platform: string
platform_seller_id: string
username: string
reason: string | null
source: string
created_at: string | null
}
export const useBlocklistStore = defineStore('blocklist', () => {
const entries = ref<BlocklistEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchBlocklist() {
loading.value = true
error.value = null
try {
const res = await apiFetch(`${apiBase}/api/blocklist`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
entries.value = data.entries
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load blocklist'
} finally {
loading.value = false
}
}
async function addSeller(
platformSellerId: string,
username: string,
reason: string,
): Promise<void> {
const res = await apiFetch(`${apiBase}/api/blocklist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: 'ebay',
platform_seller_id: platformSellerId,
username,
reason,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const entry: BlocklistEntry = await res.json()
// Prepend so the new entry appears at the top
entries.value = [entry, ...entries.value.filter(
e => e.platform_seller_id !== platformSellerId,
)]
}
async function removeSeller(platformSellerId: string): Promise<void> {
const res = await apiFetch(`${apiBase}/api/blocklist/${encodeURIComponent(platformSellerId)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
entries.value = entries.value.filter(e => e.platform_seller_id !== platformSellerId)
}
function isBlocklisted(platformSellerId: string): boolean {
return entries.value.some(e => e.platform_seller_id === platformSellerId)
}
async function exportCsv(): Promise<void> {
const res = await apiFetch(`${apiBase}/api/blocklist/export`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'snipe-blocklist.csv'
a.click()
URL.revokeObjectURL(url)
}
async function importCsv(file: File): Promise<{ imported: number; errors: string[] }> {
const formData = new FormData()
formData.append('file', file)
const res = await apiFetch(`${apiBase}/api/blocklist/import`, {
method: 'POST',
body: formData,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const result = await res.json()
// Refresh to pick up all imported entries
await fetchBlocklist()
return result
}
return {
entries,
loading,
error,
fetchBlocklist,
addSeller,
removeSeller,
isBlocklisted,
exportCsv,
importCsv,
}
})

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SavedSearch, SearchFilters } from './search'
import { apiFetch } from '../utils/api'
export type { SavedSearch }
@ -15,7 +16,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
loading.value = true
error.value = null
try {
const res = await fetch(`${apiBase}/api/saved-searches`)
const res = await apiFetch(`${apiBase}/api/saved-searches`)
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json() as { saved_searches: SavedSearch[] }
items.value = data.saved_searches
@ -29,7 +30,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
async function create(name: string, query: string, filters: SearchFilters): Promise<SavedSearch> {
// Strip per-run fields before persisting
const { pages: _pages, ...persistable } = filters
const res = await fetch(`${apiBase}/api/saved-searches`, {
const res = await apiFetch(`${apiBase}/api/saved-searches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, query, filters_json: JSON.stringify(persistable) }),

View file

@ -83,15 +83,43 @@ export interface SearchFilters {
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
}
// ── Session cache ─────────────────────────────────────────────────────────────
const CACHE_KEY = 'snipe:search-cache'
function saveCache(data: {
query: string
results: Listing[]
trustScores: Record<string, TrustScore>
sellers: Record<string, Seller>
marketPrice: number | null
adapterUsed: 'api' | 'scraper' | null
}) {
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)) } catch { /* quota */ }
}
function loadCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
} catch { return null }
}
// ── Store ────────────────────────────────────────────────────────────────────
export const useSearchStore = defineStore('search', () => {
const query = ref('')
const results = ref<Listing[]>([])
const trustScores = ref<Map<string, TrustScore>>(new Map()) // key: platform_listing_id
const sellers = ref<Map<string, Seller>>(new Map()) // key: platform_seller_id
const marketPrice = ref<number | null>(null)
const adapterUsed = ref<'api' | 'scraper' | null>(null)
const cached = loadCache()
const query = ref<string>(cached?.query ?? '')
const results = ref<Listing[]>(cached?.results ?? [])
const trustScores = ref<Map<string, TrustScore>>(
cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map()
)
const sellers = ref<Map<string, Seller>>(
cached ? new Map(Object.entries(cached.sellers ?? {})) : new Map()
)
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
const loading = ref(false)
const error = ref<string | null>(null)
@ -143,6 +171,14 @@ export const useSearchStore = defineStore('search', () => {
sellers.value = new Map(Object.entries(data.sellers ?? {}))
marketPrice.value = data.market_price ?? null
adapterUsed.value = data.adapter_used ?? null
saveCache({
query: q,
results: results.value,
trustScores: data.trust_scores ?? {},
sellers: data.sellers ?? {},
marketPrice: marketPrice.value,
adapterUsed: adapterUsed.value,
})
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
// User cancelled — clear loading but don't surface as an error

17
web/src/utils/api.ts Normal file
View file

@ -0,0 +1,17 @@
/**
* Thin fetch wrapper that redirects to login on 401.
* All stores should use this instead of raw fetch() for authenticated endpoints.
*/
const LOGIN_URL = 'https://circuitforge.tech/login'
export async function apiFetch(url: string, init?: RequestInit): Promise<Response> {
const res = await fetch(url, init)
if (res.status === 401) {
const next = encodeURIComponent(window.location.href)
window.location.href = `${LOGIN_URL}?next=${next}`
// Return a never-resolving promise — navigation is in progress
return new Promise(() => {})
}
return res
}

View file

@ -0,0 +1,318 @@
<template>
<div class="blocklist-view">
<header class="blocklist-header">
<div class="blocklist-header__title-row">
<h1 class="blocklist-title">Scammer Blocklist</h1>
<span class="blocklist-count" v-if="!store.loading">
{{ store.entries.length }} {{ store.entries.length === 1 ? 'entry' : 'entries' }}
</span>
</div>
<p class="blocklist-desc">
Sellers on this list are force-scored to 0 and flagged as bad actors on every search.
Use the block button on any listing card to add sellers.
</p>
<div class="blocklist-actions">
<button class="bl-btn bl-btn--secondary" @click="onExport" :disabled="store.entries.length === 0">
Export CSV
</button>
<label class="bl-btn bl-btn--secondary bl-btn--upload">
Import CSV
<input type="file" accept=".csv,text/csv" class="sr-only" @change="onImport" />
</label>
</div>
<p v-if="importResult" class="import-result" :class="{ 'import-result--error': importResult.errors.length }">
Imported {{ importResult.imported }} sellers.
<span v-if="importResult.errors.length">
{{ importResult.errors.length }} row(s) skipped.
</span>
</p>
</header>
<div v-if="store.loading" class="blocklist-empty">Loading</div>
<div v-else-if="store.error" class="blocklist-empty blocklist-empty--error">
{{ store.error }}
</div>
<div v-else-if="store.entries.length === 0" class="blocklist-empty">
No blocked sellers yet. Use the button on any listing card to add one.
</div>
<table v-else class="bl-table">
<thead>
<tr>
<th>Seller</th>
<th>Reason</th>
<th>Source</th>
<th>Added</th>
<th aria-label="Remove"></th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in store.entries"
:key="entry.platform_seller_id"
class="bl-table__row"
>
<td class="bl-table__seller">
<span class="bl-table__username">{{ entry.username }}</span>
<span class="bl-table__id">{{ entry.platform_seller_id }}</span>
</td>
<td class="bl-table__reason">{{ entry.reason || '—' }}</td>
<td class="bl-table__source">
<span class="bl-source-badge" :class="`bl-source-badge--${entry.source}`">
{{ sourceLabel(entry.source) }}
</span>
</td>
<td class="bl-table__date">{{ formatDate(entry.created_at) }}</td>
<td class="bl-table__remove">
<button
class="bl-remove-btn"
title="Remove from blocklist"
@click="onRemove(entry.platform_seller_id)"
></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useBlocklistStore } from '../stores/blocklist'
const store = useBlocklistStore()
const importResult = ref<{ imported: number; errors: string[] } | null>(null)
onMounted(() => store.fetchBlocklist())
async function onRemove(sellerId: string) {
await store.removeSeller(sellerId)
}
async function onExport() {
await store.exportCsv()
}
async function onImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
importResult.value = null
try {
importResult.value = await store.importCsv(file)
} catch {
importResult.value = { imported: 0, errors: ['Upload failed — check file format'] }
} finally {
input.value = ''
}
}
function sourceLabel(source: string): string {
const map: Record<string, string> = {
manual: 'Manual',
csv_import: 'CSV',
community: 'Community',
}
return map[source] ?? source
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
</script>
<style scoped>
.blocklist-view {
max-width: 860px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
.blocklist-header {
margin-bottom: var(--space-6);
}
.blocklist-header__title-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.blocklist-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.blocklist-count {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.blocklist-desc {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0 0 var(--space-4);
line-height: 1.5;
}
.blocklist-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.bl-btn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 150ms ease, opacity 150ms ease;
}
.bl-btn--secondary {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.bl-btn--secondary:hover:not(:disabled) {
background: var(--color-surface-2);
border-color: var(--app-primary);
color: var(--app-primary);
}
.bl-btn--secondary:disabled {
opacity: 0.45;
cursor: default;
}
.bl-btn--upload {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.import-result {
margin-top: var(--space-3);
font-size: 0.8125rem;
color: var(--color-success, var(--trust-high));
}
.import-result--error {
color: var(--color-warning);
}
.blocklist-empty {
text-align: center;
padding: var(--space-10) var(--space-4);
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.blocklist-empty--error {
color: var(--color-error);
}
/* Table */
.bl-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.bl-table thead th {
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
padding: var(--space-2) var(--space-3);
}
.bl-table__row {
border-bottom: 1px solid var(--color-border);
transition: background 120ms ease;
}
.bl-table__row:hover {
background: var(--color-surface-raised);
}
.bl-table__row td {
padding: var(--space-3);
vertical-align: middle;
}
.bl-table__seller {
display: flex;
flex-direction: column;
gap: 2px;
}
.bl-table__username {
font-weight: 600;
color: var(--color-text);
}
.bl-table__id {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.bl-table__reason {
color: var(--color-text-muted);
max-width: 280px;
}
.bl-table__date {
white-space: nowrap;
color: var(--color-text-muted);
}
.bl-source-badge {
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.bl-source-badge--manual { background: rgba(88, 166, 255, 0.15); color: var(--app-primary); }
.bl-source-badge--csv_import { background: rgba(164, 120, 255, 0.15); color: #a478ff; }
.bl-source-badge--community { background: rgba(63, 185, 80, 0.15); color: var(--trust-high); }
.bl-remove-btn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 2px var(--space-2);
transition: color 120ms ease, border-color 120ms ease;
}
.bl-remove-btn:hover {
color: var(--color-error);
border-color: var(--color-error);
}
/* Mobile */
@media (max-width: 600px) {
.bl-table thead th:nth-child(3),
.bl-table tbody td:nth-child(3) {
display: none;
}
}
</style>

View file

@ -70,7 +70,7 @@ function formatDate(iso: string | null): string {
async function onRun(item: SavedSearch) {
store.markRun(item.id)
const query: Record<string, string> = { q: item.query }
const query: Record<string, string> = { q: item.query, autorun: '1' }
if (item.filters_json && item.filters_json !== '{}') query.filters = item.filters_json
router.push({ path: '/', query })
}

View file

@ -3,6 +3,7 @@
<!-- Search bar -->
<header class="search-header">
<form class="search-form" @submit.prevent="onSearch" role="search">
<div class="search-form-row1">
<label for="cat-select" class="sr-only">Category</label>
<select
id="cat-select"
@ -29,6 +30,8 @@
autocomplete="off"
:disabled="store.loading"
/>
</div>
<div class="search-form-row2">
<button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()">
<MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" />
<span>{{ store.loading ? 'Searching…' : 'Search' }}</span>
@ -50,6 +53,7 @@
>
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
</button>
</div>
</form>
<form v-if="showSaveForm" class="save-inline-form" @submit.prevent="onSave">
<input
@ -67,8 +71,25 @@
</header>
<div class="search-body">
<!-- Filter sidebar -->
<aside class="filter-sidebar" aria-label="Search filters">
<!-- Mobile filter toggle -->
<button
type="button"
class="filter-drawer-toggle"
:class="{ 'filter-drawer-toggle--active': showFilters }"
aria-controls="filter-sidebar"
:aria-expanded="showFilters"
@click="showFilters = !showFilters"
>
Filters<span v-if="activeFilterCount > 0" class="filter-badge">{{ activeFilterCount }}</span>
</button>
<!-- Filter sidebar / drawer -->
<aside
id="filter-sidebar"
class="filter-sidebar"
:class="{ 'filter-sidebar--open': showFilters }"
aria-label="Search filters"
>
<!-- eBay Search Parameters -->
<!-- These are sent to eBay. Changes require a new search to take effect. -->
@ -321,10 +342,28 @@ const queryInput = ref('')
// Save search UI state
const showSaveForm = ref(false)
const showFilters = ref(false)
const saveName = ref('')
const saveError = ref<string | null>(null)
const saveSuccess = ref(false)
// Count active non-default filters for the mobile badge
const activeFilterCount = computed(() => {
let n = 0
if (filters.categoryId) n++
if (filters.minPrice !== null && filters.minPrice > 0) n++
if (filters.maxPrice !== null && filters.maxPrice > 0) n++
if (filters.minTrust > 0) n++
if (filters.hideRedFlags) n++
if (filters.hidePartial) n++
if (filters.hideLongOnMarket) n++
if (filters.hidePriceDrop) n++
if (filters.mustInclude) n++
if (filters.mustExclude) n++
if (filters.pages > 1) n++
return n
})
async function onSave() {
if (!saveName.value.trim()) return
saveError.value = null
@ -344,7 +383,6 @@ onMounted(() => {
const q = route.query.q
if (typeof q === 'string' && q.trim()) {
queryInput.value = q.trim()
// Restore saved filters (e.g. category, price range, trust threshold)
const f = route.query.filters
if (typeof f === 'string') {
try {
@ -352,8 +390,14 @@ onMounted(() => {
Object.assign(filters, restored)
} catch { /* malformed — ignore */ }
}
if (route.query.autorun === '1') {
// Strip the autorun flag from the URL before searching
router.replace({ query: { ...route.query, autorun: undefined } })
onSearch()
}
// Otherwise: URL params just restore the form (e.g. on page refresh).
// Results are restored from sessionStorage by the search store.
}
})
// Filters
@ -563,6 +607,7 @@ const hiddenCount = computed(() => store.results.length - visibleListings.value.
async function onSearch() {
if (!queryInput.value.trim()) return
showFilters.value = false // close drawer on mobile when search runs
await store.search(queryInput.value.trim(), filters)
}
</script>
@ -584,12 +629,6 @@ async function onSearch() {
z-index: 10;
}
.search-form {
display: flex;
gap: var(--space-3);
max-width: 760px;
}
.search-category-select {
padding: var(--space-3) var(--space-3);
background: var(--color-surface-raised);
@ -1078,13 +1117,146 @@ async function onSearch() {
gap: var(--space-3);
}
/* Mobile: collapse filter sidebar */
@media (max-width: 767px) {
.filter-sidebar {
/* ── Search form rows (desktop: single flex row, mobile: two rows) ───── */
.search-form {
display: flex;
gap: var(--space-3);
max-width: 760px;
flex-wrap: wrap; /* rows fall through naturally on mobile */
}
.search-form-row1 {
display: flex;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.search-form-row2 {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
/* ── Mobile filter drawer toggle (hidden on desktop) ─────────────────── */
.filter-drawer-toggle {
display: none;
}
.search-header { padding: var(--space-4); }
.results-area { padding: var(--space-4); }
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
margin-left: var(--space-1);
background: var(--app-primary);
color: var(--color-text-inverse);
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
/* ── Responsive breakpoints ──────────────────────────────────────────── */
@media (max-width: 767px) {
/* Search header: tighter padding on mobile */
.search-header {
padding: var(--space-3) var(--space-3) var(--space-3);
}
/* Form rows: row1 takes full width, row2 stretches buttons */
.search-form {
gap: var(--space-2);
}
.search-form-row1 {
width: 100%;
flex: unset;
}
.search-form-row2 {
width: 100%;
flex-shrink: unset;
}
.search-btn {
flex: 1; /* stretch search button to fill row */
}
/* Category select: don't let it crowd the input */
.search-category-select {
max-width: 110px;
font-size: 0.8125rem;
}
/* Filter drawer toggle: show on mobile */
.filter-drawer-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: var(--space-2) var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
width: calc(100% - var(--space-6));
transition: border-color 150ms ease, color 150ms ease;
align-self: flex-start;
}
.filter-drawer-toggle--active {
border-color: var(--app-primary);
color: var(--app-primary);
}
/* Filter sidebar: hidden by default, slides down when open */
.filter-sidebar {
display: none;
width: 100%;
max-height: 65dvh;
overflow-y: auto;
border-right: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4) var(--space-4) var(--space-6);
background: var(--color-surface-2);
animation: drawer-slide-down 180ms ease;
}
.filter-sidebar--open {
display: flex;
}
/* Search body: stack vertically (toggle → sidebar → results) */
.search-body {
flex-direction: column;
}
/* Results: full-width, slightly tighter padding */
.results-area {
padding: var(--space-4) var(--space-3);
overflow-y: unset; /* let the page scroll on mobile, not a sub-scroll container */
}
/* Toolbar: wrap if needed */
.results-toolbar {
flex-wrap: wrap;
gap: var(--space-2);
}
.toolbar-actions {
flex-wrap: wrap;
}
/* Save inline form: full width */
.save-inline-form {
flex-wrap: wrap;
}
.save-name-input {
width: 100%;
}
}
@keyframes drawer-slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>