feat: refine portfolio content, contact, and supporting media
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
@@ -28,8 +28,12 @@ const playing = shallowRef(false);
|
||||
const unavailable = shallowRef(false);
|
||||
const status = shallowRef('Video ready to play.');
|
||||
const savedTime = shallowRef(0);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value);
|
||||
const lazy = useLazyMedia(figure, automatic);
|
||||
const userPaused = ref(false);
|
||||
const pageVisible = ref(true);
|
||||
const enhanced = ref(false);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value && !userPaused.value && pageVisible.value);
|
||||
const lazy = useLazyMedia(figure, automatic, '0px');
|
||||
const fallbackSource = computed(() => props.sources.find(({ type }) => type === 'video/mp4') ?? props.sources[0]);
|
||||
let failureTimer: number | undefined;
|
||||
|
||||
const clearFailureTimer = (): void => {
|
||||
@@ -51,7 +55,10 @@ const detach = async (): Promise<void> => {
|
||||
|
||||
watch(() => lazy.shouldAttach.value, (attached, previous) => {
|
||||
if (!attached && previous) void detach();
|
||||
if (attached) void nextTick(() => video.value?.load());
|
||||
if (attached) void nextTick(() => {
|
||||
// Explicit Play loads its sources before play(); a later load would abort it.
|
||||
if (!lazy.explicitRequested.value) video.value?.load();
|
||||
});
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(() => props.allowAutomatic, (allowed) => {
|
||||
@@ -92,6 +99,7 @@ const play = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const pause = (): void => {
|
||||
userPaused.value = true;
|
||||
video.value?.pause();
|
||||
playing.value = false;
|
||||
status.value = 'Video paused.';
|
||||
@@ -100,9 +108,25 @@ const pause = (): void => {
|
||||
|
||||
const togglePlayback = (): void => {
|
||||
if (playing.value) pause();
|
||||
else void play();
|
||||
else {
|
||||
userPaused.value = false;
|
||||
void play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibility = (): void => {
|
||||
pageVisible.value = !document.hidden;
|
||||
if (!pageVisible.value) {
|
||||
video.value?.pause();
|
||||
lazy.clearExplicit();
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
enhanced.value = true;
|
||||
handleVisibility();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
});
|
||||
|
||||
const restoreTime = (): void => {
|
||||
const element = video.value;
|
||||
if (element !== null && Number.isFinite(savedTime.value) && savedTime.value > 0) {
|
||||
@@ -111,13 +135,14 @@ const restoreTime = (): void => {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
clearFailureTimer();
|
||||
video.value?.pause();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media :data-enhanced="enhanced">
|
||||
<video
|
||||
ref="video"
|
||||
:poster="poster"
|
||||
@@ -125,7 +150,12 @@ onUnmounted(() => {
|
||||
:height="height"
|
||||
:aria-label="accessibleLabel"
|
||||
preload="none"
|
||||
muted
|
||||
loop
|
||||
:autoplay="automatic"
|
||||
playsinline
|
||||
@play="playing = true; status = 'Video playing.'"
|
||||
@pause="playing = false"
|
||||
@loadedmetadata="restoreTime"
|
||||
@error="markUnavailable"
|
||||
@ended="playing = false; status = 'Video ended.'"
|
||||
@@ -145,5 +175,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<p data-testid="video-status">{{ status }}</p>
|
||||
</div>
|
||||
<a v-if="fallbackSource" class="video-fallback" :href="fallbackSource.src" target="_blank" rel="noopener noreferrer">Open gameplay video</a>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +226,7 @@ onUnmounted(() => removePageListeners());
|
||||
<div class="reading-stage">
|
||||
<div class="reading-viewport">
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
|
||||
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
||||
|
||||
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||
<p>{{ content.profile.introduction }}</p>
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue';
|
||||
import BrandIcon from './BrandIcon.vue';
|
||||
import { productionInputsKey } from '@/app';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { contactAddress, readableContactAddress } from '@/content/contact-email';
|
||||
import type { ContactContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
contact: Readonly<ContactContent>;
|
||||
}>();
|
||||
const inputs = inject(productionInputsKey, undefined);
|
||||
const avatar = computed(() => inputs?.manifest.assets.find(({ id }) => id === props.contact.avatarAssetId));
|
||||
const avatarUrl = computed(() => avatar.value && inputs ? assetUrl(inputs.manifest, avatar.value.id) : undefined);
|
||||
const enhanced = ref(false);
|
||||
const details = ref<HTMLDetailsElement | null>(null);
|
||||
const revealed = ref(false);
|
||||
const address = ref('');
|
||||
const copyStatus = ref('');
|
||||
const readableAddress = computed(() => readableContactAddress(props.contact.email));
|
||||
|
||||
onMounted(() => { enhanced.value = true; });
|
||||
const revealAddress = (): void => {
|
||||
address.value = contactAddress(props.contact.email);
|
||||
};
|
||||
const handleToggle = (): void => {
|
||||
revealed.value = details.value?.open ?? false;
|
||||
copyStatus.value = '';
|
||||
if (revealed.value) revealAddress();
|
||||
};
|
||||
const closeDisclosure = (): void => {
|
||||
if (details.value?.open) {
|
||||
details.value.open = false;
|
||||
details.value.querySelector('summary')?.focus();
|
||||
}
|
||||
};
|
||||
const copyAddress = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contactAddress(props.contact.email));
|
||||
copyStatus.value = 'Copied';
|
||||
} catch {
|
||||
copyStatus.value = 'Select the address to copy it.';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="avatar && avatarUrl" class="contact-avatar" :src="avatarUrl" alt="Illustrated avatar" :width="avatar.width!" :height="avatar.height!" loading="lazy" decoding="async" />
|
||||
<div class="contact-destination">
|
||||
<p class="contact-destination__copy">{{ contact.introduction }}</p>
|
||||
<a class="contact-link" :href="`mailto:${contact.email}`">Email Luca</a>
|
||||
<p class="contact-destination__email">{{ contact.email }}</p>
|
||||
<div class="contact-email" :data-enhanced="enhanced" :data-revealed="revealed" @keydown.esc="closeDisclosure">
|
||||
<div class="contact-email__action" @pointerenter="revealAddress" @focusin="revealAddress">
|
||||
<a v-if="enhanced" class="contact-link" :href="address ? `mailto:${address}` : '#contact-email-details'" @pointerdown="revealAddress" @click="revealAddress">Email Me</a>
|
||||
<span v-if="enhanced" class="contact-email__preview" aria-hidden="true">{{ address }}</span>
|
||||
</div>
|
||||
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">
|
||||
<summary>{{ enhanced ? (revealed ? 'Hide email' : 'Show email') : 'Email Me' }}</summary>
|
||||
<div class="contact-email__disclosure">
|
||||
<span class="contact-email__address">{{ enhanced && address ? address : readableAddress }}</span>
|
||||
<button v-if="enhanced" type="button" class="contact-email__copy" @click="copyAddress">Copy email</button>
|
||||
<span v-if="enhanced" class="contact-email__status" role="status">{{ copyStatus }}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
|
||||
</div>
|
||||
<ul class="contact-social">
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { PortfolioContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
identity: Readonly<PortfolioContent['identity']>;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
explore: [event: MouseEvent];
|
||||
contact: [event: MouseEvent];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
||||
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
||||
<p class="hero-journey__availability">{{ identity.availability }}</p>
|
||||
<div class="hero-journey__actions">
|
||||
<a class="hero-journey__action hero-journey__action--primary" :href="`mailto:${email}`">Email Luca</a>
|
||||
<a class="hero-journey__action hero-journey__action--primary" href="#contact" @click="emit('contact', $event)">Contact Me</a>
|
||||
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
|
||||
:allow-automatic-video="allowAutomaticVideo"
|
||||
/>
|
||||
<p>{{ project.contribution }}</p>
|
||||
<p>{{ project.outcome }}</p>
|
||||
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user