fix: credit tracking, voice clone TTL, avatar upload ui, asset serving fallback, OAuth encryption, free plan video renders, backlink outreach sprint
This commit is contained in:
183
frontend/src/hooks/usePhaseNavigationCore.ts
Normal file
183
frontend/src/hooks/usePhaseNavigationCore.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { readLSString, readLSBool } from '../utils/persistence';
|
||||
|
||||
export interface PhaseBase {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface PhaseNavigationConfig {
|
||||
/** localStorage key for the current phase */
|
||||
phaseKey: string;
|
||||
/** localStorage key for the user-selected flag */
|
||||
userSelectedKey: string;
|
||||
/**
|
||||
* Default phase shown when no progress exists.
|
||||
* BlogWriter uses `''` (landing page), StoryWriter uses `'setup'`.
|
||||
*/
|
||||
emptyPhaseId?: string;
|
||||
/**
|
||||
* Override the initial phase instead of reading from localStorage.
|
||||
* Used when the stored phase is stale (e.g., 'research' stored but no
|
||||
* research data exists yet on a different origin).
|
||||
*/
|
||||
initialPhase?: string;
|
||||
}
|
||||
|
||||
interface OscillationState {
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
lastTime: number;
|
||||
}
|
||||
|
||||
export interface UsePhaseNavigationCoreReturn {
|
||||
currentPhase: string;
|
||||
setCurrentPhase: (phase: string) => void;
|
||||
userSelectedPhase: boolean;
|
||||
navigateToPhase: (phaseId: string, phases: PhaseBase[]) => void;
|
||||
resetUserSelection: () => void;
|
||||
oscillationGuardRef: React.MutableRefObject<OscillationState>;
|
||||
lastClickAtRef: React.MutableRefObject<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core phase navigation state management shared across BlogWriter,
|
||||
* StoryWriter, etc.
|
||||
*
|
||||
* Handles:
|
||||
* - Initializing phase + user-selected state from localStorage
|
||||
* - Persisting state back to localStorage on changes
|
||||
* - User-tracking flag (auto-progression vs. manual selection)
|
||||
* - Click debouncing (200ms)
|
||||
*
|
||||
* Does NOT handle:
|
||||
* - Phase definitions (phases array) — product-specific
|
||||
* - Phase validation effect — use usePhaseValidation() separately
|
||||
* - Auto-update / auto-progression effect — product-specific
|
||||
*/
|
||||
export const usePhaseNavigationCore = (
|
||||
config: PhaseNavigationConfig,
|
||||
): UsePhaseNavigationCoreReturn => {
|
||||
const { phaseKey, userSelectedKey, emptyPhaseId = '' } = config;
|
||||
|
||||
const [currentPhase, setCurrentPhase] = useState<string>(() => {
|
||||
if (config.initialPhase !== undefined) return config.initialPhase;
|
||||
try {
|
||||
if (typeof window === 'undefined') return emptyPhaseId;
|
||||
return readLSString(phaseKey, emptyPhaseId);
|
||||
} catch {
|
||||
return emptyPhaseId;
|
||||
}
|
||||
});
|
||||
|
||||
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
return readLSBool(userSelectedKey, false);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
return false;
|
||||
});
|
||||
|
||||
const lastClickAtRef = useRef<number>(0);
|
||||
const oscillationGuardRef = useRef<OscillationState>({
|
||||
from: '', to: '', count: 0, lastTime: 0,
|
||||
});
|
||||
|
||||
// Persist to localStorage on change
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(phaseKey, currentPhase); } catch { /* noop */ }
|
||||
}, [currentPhase, phaseKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(userSelectedKey, String(userSelectedPhase)); } catch { /* noop */ }
|
||||
}, [userSelectedPhase, userSelectedKey]);
|
||||
|
||||
const navigateToPhase = useCallback((phaseId: string, phases: PhaseBase[]) => {
|
||||
const now = Date.now();
|
||||
if (now - lastClickAtRef.current < 200) return;
|
||||
lastClickAtRef.current = now;
|
||||
|
||||
const phase = phases.find(p => p.id === phaseId);
|
||||
if (phase && !phase.disabled) {
|
||||
setCurrentPhase(phaseId);
|
||||
setUserSelectedPhase(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetUserSelection = useCallback(() => {
|
||||
setUserSelectedPhase(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentPhase,
|
||||
setCurrentPhase,
|
||||
userSelectedPhase,
|
||||
navigateToPhase,
|
||||
resetUserSelection,
|
||||
oscillationGuardRef,
|
||||
lastClickAtRef,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared phase validation effect.
|
||||
*
|
||||
* Checks that the current phase is still valid (not disabled) given the
|
||||
* latest data. If the phase is disabled, redirects to the first
|
||||
* non-disabled phase with oscillation detection to prevent bouncing.
|
||||
*/
|
||||
export function usePhaseValidation(
|
||||
phases: PhaseBase[],
|
||||
currentPhase: string,
|
||||
userSelectedPhase: boolean,
|
||||
setCurrentPhase: (phase: string) => void,
|
||||
oscillationGuardRef: React.MutableRefObject<OscillationState>,
|
||||
emptyPhaseId: string,
|
||||
research?: any,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (currentPhase === emptyPhaseId) return;
|
||||
if (userSelectedPhase) return;
|
||||
|
||||
const current = phases.find(p => p.id === currentPhase);
|
||||
if (!current) {
|
||||
setCurrentPhase(research ? 'research' : emptyPhaseId);
|
||||
return;
|
||||
}
|
||||
if (current.disabled) {
|
||||
const guard = oscillationGuardRef.current;
|
||||
const now = Date.now();
|
||||
|
||||
// Oscillation guard: detect rapid bouncing between two phases
|
||||
if (guard.from === currentPhase && guard.count >= 3 && (now - guard.lastTime) < 1000) {
|
||||
return;
|
||||
}
|
||||
if (guard.to !== currentPhase) {
|
||||
oscillationGuardRef.current = { from: currentPhase, to: '', count: 1, lastTime: now };
|
||||
}
|
||||
|
||||
const fallback = phases.find(p => !p.disabled);
|
||||
if (fallback && fallback.id !== currentPhase) {
|
||||
oscillationGuardRef.current = {
|
||||
...oscillationGuardRef.current,
|
||||
to: fallback.id,
|
||||
count: guard.from === currentPhase ? guard.count + 1 : 1,
|
||||
lastTime: now,
|
||||
};
|
||||
setCurrentPhase(fallback.id);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
phases,
|
||||
currentPhase,
|
||||
userSelectedPhase,
|
||||
setCurrentPhase,
|
||||
oscillationGuardRef,
|
||||
emptyPhaseId,
|
||||
research,
|
||||
]);
|
||||
}
|
||||
|
||||
export default usePhaseNavigationCore;
|
||||
Reference in New Issue
Block a user