Files
Portfolio/src/components/AppShell.vue

309 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue';
import ExperienceTimeline from './ExperienceTimeline.vue';
import HeroJourney from './HeroJourney.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue';
import JourneyHud from './JourneyHud.vue';
import PortfolioSection from './PortfolioSection.vue';
import ProjectCard from './ProjectCard.vue';
import ProjectCaseStudy from './ProjectCaseStudy.vue';
import SceneCanvas from './SceneCanvas.vue';
import SiteNavigation from './SiteNavigation.vue';
import ToolkitGrid from './ToolkitGrid.vue';
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { assetUrl } from '@/content/asset-url';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy';
import type { SoundtrackSources } from '@/audio/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const props = defineProps<{
content: Readonly<PortfolioContent>;
manifest: Readonly<PublicAssetManifest>;
}>();
const canonicalSectionIds = [
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[];
const sectionLayouts = {
start: 'hero',
profile: 'right-rail',
experience: 'left-timeline',
infrastructure: 'full-diagram',
'selected-builds': 'project-stage',
'additional-work': 'compact-rail',
toolkit: 'toolkit-field',
contact: 'destination',
} as const satisfies Readonly<Record<SectionId, SectionLayout>>;
const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item])));
const sectionLabel = (id: SectionId): string => {
const item = navigationById.value.get(id);
if (item === undefined) {
throw new Error(`Missing canonical navigation label: ${id}`);
}
return item.label;
};
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
const motion = useMotionPreferences();
const navigation = useKeyboardNavigation(canonicalSectionIds);
const journeyBinding = useJourneySections(
canonicalSectionIds,
navigation.state,
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
const pendingAudioDestination = shallowRef<SectionId | null>(null);
let audioNavigationEpoch = -1;
watch(
[navigation.state, () => journey.value.activeSectionId],
([navigationState, activeSectionId]) => {
if (navigationState.epoch !== audioNavigationEpoch) {
audioNavigationEpoch = navigationState.epoch;
pendingAudioDestination.value = navigationState.source === 'passive-scroll'
? null
: navigationState.destinationId;
}
if (pendingAudioDestination.value === activeSectionId) {
pendingAudioDestination.value = null;
}
},
{ flush: 'sync', immediate: true },
);
const audioSectionId = computed(() => (
pendingAudioDestination.value ?? journey.value.activeSectionId
));
const soundtrackSources = computed<SoundtrackSources | null>(() => {
const soundtrack = props.content.soundtrack;
if (soundtrack === undefined) return null;
return {
intro: [
{ url: assetUrl(props.manifest, soundtrack.intro.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, soundtrack.intro.mp3AssetId), mediaType: 'audio/mpeg' },
],
main: [
{ url: assetUrl(props.manifest, soundtrack.main.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, soundtrack.main.mp3AssetId), mediaType: 'audio/mpeg' },
],
};
});
const audio = useAudioEngine({
activeSectionId: audioSectionId,
intensity: computed(() => journey.value.acceleration),
soundtrack: soundtrackSources,
volume: motion.volume,
setVolume: motion.setVolume,
});
const sceneState = shallowRef<SceneHostState>('idle');
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
const controlsReady = shallowRef(false);
const adaptive = useAdaptiveQuality({
maximum: computed(() => motion.policy.value.maximumTier),
sceneState,
pageLifecycle,
});
const requestedPerformanceTier = TEST_BUILD
? new URL(window.location.href).searchParams.get('__portfolio_tier')
: null;
const pinnedPerformanceTier = requestedPerformanceTier === 'high' || requestedPerformanceTier === 'low'
? requestedPerformanceTier
: null;
const actualQuality = computed(() => pinnedPerformanceTier ?? adaptive.snapshot.value.actual);
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,
allowAutomaticVideo: false,
maximumTier: 'high',
reason: 'full',
};
const displayedPolicy = computed(() => controlsReady.value ? motion.policy.value : serverDefaultPolicy);
const displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running');
let removePageListeners = (): void => {};
const sampleSceneFrame = (start: number, end: number): void => {
if (pinnedPerformanceTier === null) adaptive.sampleFrame(start, end);
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneFrame(start, end);
};
const updateSceneState = (state: SceneHostState): void => {
sceneState.value = state;
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneState(state);
};
watch(
() => journey.value.activeSectionId,
(id) => navigation.notePassiveSection(id),
);
watch(
() => motion.policy.value.maximumTier,
(tier, previous) => {
if (tier !== previous) {
adaptive.setMaximum(tier, controlsReady.value ? 'resize' : 'manual');
}
},
{ flush: 'sync' },
);
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
motion.setMaximumQuality(tier);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const enableMotion = (): void => {
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
motion.setMaximumQuality(tier);
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const resumeMotion = (): void => {
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('resume');
};
const pauseMotion = (): void => {
motion.setPaused(true);
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('pause');
};
const startDrive = (): void => {
audio.startFromGesture();
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('start');
};
const toggleAudioMuted = (): void => {
const action = audio.muted.value ? 'unmute' : 'mute';
audio.toggleMuted();
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(action);
};
const updateAudioVolume = (value: number): void => {
audio.setVolume(value);
if (TEST_BUILD && value === 0.35) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('volume');
};
onMounted(() => {
controlsReady.value = true;
const onPageHide = (event: PageTransitionEvent): void => {
pageLifecycle.value = event.persisted ? 'frozen' : 'disposed';
};
const onPageShow = (): void => { pageLifecycle.value = 'active'; };
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
removePageListeners = () => {
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
};
});
onUnmounted(() => removePageListeners());
</script>
<template>
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
<SceneCanvas
:journey="journey"
:policy="motion.policy.value"
:quality="actualQuality"
:scene-description="content.sceneDescription"
:sample-frame="sampleSceneFrame"
@state-change="updateSceneState"
/>
<JourneyHud
:journey="journey"
:section-label="activeSectionLabel"
/>
<header class="site-header">
<SiteNavigation
:items="canonicalNavigation"
:journey="journey"
:on-section-link-click="navigation.onSectionLinkClick"
/>
</header>
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" />
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p>
<dl class="profile-facts">
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</template>
</dl>
</PortfolioSection>
<PortfolioSection id="experience" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection>
<PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection>
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
<div class="project-stage__projects">
<ProjectCaseStudy
v-for="(project, index) in content.projects.selected"
:key="project.id"
:project="project"
:priority="index < 2 ? 'primary' : 'supporting'"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection>
<PortfolioSection id="additional-work" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<div class="compact-projects">
<ProjectCard
v-for="project in content.projects.additional"
:key="project.id"
:project="project"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection>
<PortfolioSection id="toolkit" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<ToolkitGrid :groups="content.toolkit" />
</PortfolioSection>
<PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
<footer>
<ContactDestination :contact="content.contact" />
</footer>
</PortfolioSection>
</main>
<DriveControls
:policy="displayedPolicy"
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
:scene-status="displayedSceneState"
:audio-state="audio.state.value"
:audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4"
@pause="pauseMotion"
@resume="resumeMotion"
@enable="enableMotion"
@quality-change="setQuality"
@audio-start="startDrive"
@audio-resume="audio.resumeFromGesture"
@audio-mute-toggle="toggleAudioMuted"
@audio-volume-change="updateAudioVolume"
/>
</template>