feat: Vue SPA demo mode support
- useToast.ts: global reactive toast singleton for cross-component toasts - App.vue: sticky demo mode banner + global toast slot - router: bypass wizard gate entirely in demo mode (pre-seeded data) - ApplyWorkspace, CompanyResearchModal: guard generate() in demo mode - fineTune store: guard submitJob() in demo mode - ui_switcher.py: remove Vue→Streamlit fallback in demo mode (now handled natively) All LLM-triggering actions show a toast and no-op in demo mode. Backend already blocks inference via DEMO_MODE env; Vue layer adds UX signal. Closes #46
This commit is contained in:
parent
9f9453a3b0
commit
6115a68550
7 changed files with 99 additions and 6 deletions
|
|
@ -124,12 +124,6 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
|
|||
# UI components must not crash the app — silent fallback to default
|
||||
pref = "streamlit"
|
||||
|
||||
# Demo mode: Vue SPA has no demo data wiring — always serve Streamlit.
|
||||
# (The tier downgrade check below is skipped in demo mode, but we must
|
||||
# also block the Vue navigation itself so Caddy doesn't route to a blank SPA.)
|
||||
if pref == "vue" and _DEMO_MODE:
|
||||
pref = "streamlit"
|
||||
|
||||
# Tier downgrade protection (skip in demo — demo bypasses tier gate)
|
||||
if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
|
||||
if profile is not None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,20 @@
|
|||
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" 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>
|
||||
|
||||
<!-- Demo mode banner — sticky top bar, visible on all pages -->
|
||||
<div v-if="config.isDemo" class="demo-banner" role="status" aria-live="polite">
|
||||
👁 Demo mode — changes are not saved and AI features are disabled.
|
||||
</div>
|
||||
|
||||
<RouterView />
|
||||
|
||||
<!-- Global toast — rendered at App level so any component can trigger it -->
|
||||
<Transition name="global-toast">
|
||||
<div v-if="toast.message.value" class="global-toast" role="status" aria-live="polite">
|
||||
{{ toast.message.value }}
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -17,13 +30,17 @@ import { RouterView, useRoute } from 'vue-router'
|
|||
import { useMotion } from './composables/useMotion'
|
||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
import { useToast } from './composables/useToast'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import { useAppConfigStore } from './stores/appConfig'
|
||||
import { useDigestStore } from './stores/digest'
|
||||
|
||||
const motion = useMotion()
|
||||
const route = useRoute()
|
||||
const { toggle, restore } = useHackerMode()
|
||||
const { initTheme } = useTheme()
|
||||
const toast = useToast()
|
||||
const config = useAppConfigStore()
|
||||
const digestStore = useDigestStore()
|
||||
|
||||
const isWizard = computed(() => route.path.startsWith('/setup'))
|
||||
|
|
@ -110,4 +127,51 @@ body {
|
|||
margin-left: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Demo mode banner — sticky top bar */
|
||||
.demo-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background: var(--color-warning);
|
||||
color: #1a1a1a; /* forced dark — warning bg is always light enough */
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 6px var(--space-4, 16px);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Global toast — bottom-center, above tab bar */
|
||||
.global-toast {
|
||||
position: fixed;
|
||||
bottom: calc(72px + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-surface-raised, #2a3650);
|
||||
color: var(--color-text, #eaeff8);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
white-space: nowrap;
|
||||
z-index: 9000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-toast-enter-active, .global-toast-leave-active {
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
}
|
||||
.global-toast-enter-from, .global-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.global-toast {
|
||||
bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
left: calc(50% + var(--sidebar-width, 220px) / 2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -283,9 +283,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import { useAppConfigStore } from '../stores/appConfig'
|
||||
import type { Job } from '../stores/review'
|
||||
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
||||
|
||||
const config = useAppConfigStore()
|
||||
|
||||
const props = defineProps<{ jobId: number }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -379,6 +382,7 @@ async function pollTaskStatus() {
|
|||
|
||||
async function generate() {
|
||||
if (generating.value) return
|
||||
if (config.isDemo) { showToast('AI features are disabled in demo mode'); return }
|
||||
generating.value = true
|
||||
clState.value = 'queued'
|
||||
taskError.value = null
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import { useAppConfigStore } from '../stores/appConfig'
|
||||
import { showToast } from '../composables/useToast'
|
||||
|
||||
const config = useAppConfigStore()
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: number
|
||||
|
|
@ -181,6 +185,7 @@ async function load() {
|
|||
}
|
||||
|
||||
async function generate() {
|
||||
if (config.isDemo) { showToast('AI features are disabled in demo mode'); state.value = 'empty'; return }
|
||||
state.value = 'generating'
|
||||
stage.value = null
|
||||
errorMsg.value = null
|
||||
|
|
|
|||
20
web/src/composables/useToast.ts
Normal file
20
web/src/composables/useToast.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* useToast — global reactive toast singleton.
|
||||
*
|
||||
* Module-level ref shared across all importers; no Pinia needed for a single
|
||||
* ephemeral string. Call showToast() from anywhere; App.vue renders it.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const _message = ref<string | null>(null)
|
||||
let _timer = 0
|
||||
|
||||
export function showToast(msg: string, duration = 3500): void {
|
||||
clearTimeout(_timer)
|
||||
_message.value = msg
|
||||
_timer = window.setTimeout(() => { _message.value = null }, duration)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return { message: _message }
|
||||
}
|
||||
|
|
@ -56,6 +56,9 @@ router.beforeEach(async (to, _from, next) => {
|
|||
const config = useAppConfigStore()
|
||||
if (!config.loaded) await config.load()
|
||||
|
||||
// Demo mode: pre-seeded data, no wizard needed — route freely
|
||||
if (config.isDemo) return next()
|
||||
|
||||
// Wizard gate runs first for every route except /setup itself
|
||||
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
|
||||
return next('/setup')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
import { useAppConfigStore } from '../appConfig'
|
||||
import { showToast } from '../../composables/useToast'
|
||||
|
||||
export interface TrainingPair {
|
||||
index: number
|
||||
|
|
@ -41,6 +43,7 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
|||
}
|
||||
|
||||
async function submitJob() {
|
||||
if (useAppConfigStore().isDemo) { showToast('AI features are disabled in demo mode'); return }
|
||||
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
|
||||
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue