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
|
# UI components must not crash the app — silent fallback to default
|
||||||
pref = "streamlit"
|
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)
|
# 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 pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,20 @@
|
||||||
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
|
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
|
||||||
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
<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 />
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -17,13 +30,17 @@ import { RouterView, useRoute } from 'vue-router'
|
||||||
import { useMotion } from './composables/useMotion'
|
import { useMotion } from './composables/useMotion'
|
||||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||||
import { useTheme } from './composables/useTheme'
|
import { useTheme } from './composables/useTheme'
|
||||||
|
import { useToast } from './composables/useToast'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useAppConfigStore } from './stores/appConfig'
|
||||||
import { useDigestStore } from './stores/digest'
|
import { useDigestStore } from './stores/digest'
|
||||||
|
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { toggle, restore } = useHackerMode()
|
const { toggle, restore } = useHackerMode()
|
||||||
const { initTheme } = useTheme()
|
const { initTheme } = useTheme()
|
||||||
|
const toast = useToast()
|
||||||
|
const config = useAppConfigStore()
|
||||||
const digestStore = useDigestStore()
|
const digestStore = useDigestStore()
|
||||||
|
|
||||||
const isWizard = computed(() => route.path.startsWith('/setup'))
|
const isWizard = computed(() => route.path.startsWith('/setup'))
|
||||||
|
|
@ -110,4 +127,51 @@ body {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-bottom: 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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -283,9 +283,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
import type { Job } from '../stores/review'
|
import type { Job } from '../stores/review'
|
||||||
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
const props = defineProps<{ jobId: number }>()
|
const props = defineProps<{ jobId: number }>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -379,6 +382,7 @@ async function pollTaskStatus() {
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
if (generating.value) return
|
if (generating.value) return
|
||||||
|
if (config.isDemo) { showToast('AI features are disabled in demo mode'); return }
|
||||||
generating.value = true
|
generating.value = true
|
||||||
clState.value = 'queued'
|
clState.value = 'queued'
|
||||||
taskError.value = null
|
taskError.value = null
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
import { showToast } from '../composables/useToast'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
jobId: number
|
jobId: number
|
||||||
|
|
@ -181,6 +185,7 @@ async function load() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
|
if (config.isDemo) { showToast('AI features are disabled in demo mode'); state.value = 'empty'; return }
|
||||||
state.value = 'generating'
|
state.value = 'generating'
|
||||||
stage.value = null
|
stage.value = null
|
||||||
errorMsg.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()
|
const config = useAppConfigStore()
|
||||||
if (!config.loaded) await config.load()
|
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
|
// Wizard gate runs first for every route except /setup itself
|
||||||
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
|
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
|
||||||
return next('/setup')
|
return next('/setup')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
import { useAppConfigStore } from '../appConfig'
|
||||||
|
import { showToast } from '../../composables/useToast'
|
||||||
|
|
||||||
export interface TrainingPair {
|
export interface TrainingPair {
|
||||||
index: number
|
index: number
|
||||||
|
|
@ -41,6 +43,7 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitJob() {
|
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' })
|
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
|
||||||
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue