- api/cloud_session.py: new module — JWT validation (Directus HS256), Heimdall provision+tier-resolve, CloudUser+SessionFeatures dataclasses, compute_features() tier→feature-flag mapping, require_tier() dependency factory, get_session() FastAPI dependency (local-mode transparent passthrough) - api/main.py: remove _DB_PATH singleton; all endpoints receive session via Depends(get_session); shared_store (sellers/comps) and user_store (listings/ saved_searches) created per-request from session.shared_db / session.user_db; pages capped to features.max_pages; saved_searches limit enforced for free tier; /api/session endpoint exposes tier+features to frontend; _trigger_scraper_enrichment receives shared_db Path (background thread creates its own Store) - app/platforms/ebay/adapter.py, scraper.py: rename store→shared_store parameter (adapters only touch sellers+comps, never listings — naming reflects this) - app/trust/__init__.py: rename store→shared_store (TrustScorer reads sellers+comps from shared DB; listing staging fields come from caller) - app/db/store.py: refresh_seller_categories gains listing_store param for split-DB mode (reads listings from user_store, writes categories to self) - web/src/stores/session.ts: new Pinia store — bootstrap() fetches /api/session, exposes tier+features reactively; falls back to full-access local defaults - web/src/App.vue: call session.bootstrap() on mount - web/src/views/SearchView.vue: import session store; pages buttons disabled+greyed above features.max_pages with upgrade tooltip - compose.cloud.yml: add CLOUD_MODE=true + CLOUD_DATA_ROOT env; fix volume mount - docker/web/nginx.cloud.conf: forward X-CF-Session header from Caddy to API - .env.example: document cloud env vars (CLOUD_MODE, DIRECTUS_JWT_SECRET, etc.)
97 lines
2.5 KiB
Vue
97 lines
2.5 KiB
Vue
<template>
|
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
|
<AppNav />
|
|
<main class="app-main" id="main-content" tabindex="-1">
|
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
<RouterView />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted } from 'vue'
|
|
import { RouterView } from 'vue-router'
|
|
import { useMotion } from './composables/useMotion'
|
|
import { useSnipeMode } from './composables/useSnipeMode'
|
|
import { useKonamiCode } from './composables/useKonamiCode'
|
|
import { useSessionStore } from './stores/session'
|
|
import AppNav from './components/AppNav.vue'
|
|
|
|
const motion = useMotion()
|
|
const { activate, restore } = useSnipeMode()
|
|
const session = useSessionStore()
|
|
|
|
useKonamiCode(activate)
|
|
|
|
onMounted(() => {
|
|
restore() // re-apply snipe mode from localStorage on hard reload
|
|
session.bootstrap() // fetch tier + feature flags from API
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* Global resets — unscoped, applied once to document */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html {
|
|
font-family: var(--font-body, sans-serif);
|
|
color: var(--color-text, #e6edf3);
|
|
background: var(--color-surface, #0d1117);
|
|
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
|
|
}
|
|
|
|
body {
|
|
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
#app { min-height: 100dvh; }
|
|
|
|
/* Layout root — sidebar pushes content right on desktop */
|
|
.app-root {
|
|
display: flex;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
/* Main content area */
|
|
.app-main {
|
|
flex: 1;
|
|
min-width: 0; /* prevents flex blowout */
|
|
/* Desktop: offset by sidebar width */
|
|
margin-left: var(--sidebar-width, 220px);
|
|
}
|
|
|
|
/* Skip-to-content link — visible only on keyboard focus */
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -999px;
|
|
left: var(--space-4);
|
|
background: var(--app-primary);
|
|
color: var(--color-text-inverse);
|
|
padding: var(--space-2) var(--space-4);
|
|
border-radius: var(--radius-md);
|
|
font-weight: 600;
|
|
z-index: 9999;
|
|
text-decoration: none;
|
|
transition: top 0ms;
|
|
}
|
|
|
|
.skip-link:focus {
|
|
top: var(--space-4);
|
|
}
|
|
|
|
/* Mobile: no sidebar margin, add bottom tab bar clearance */
|
|
@media (max-width: 1023px) {
|
|
.app-main {
|
|
margin-left: 0;
|
|
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
|
}
|
|
}
|
|
</style>
|