332 lines
8.5 KiB
Vue
332 lines
8.5 KiB
Vue
<!-- StoryViewer.vue -->
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||
import { proxyImage } from '@/lib/proxyImage';
|
||
import type { StoryDetail } from '@/types/dredge.types';
|
||
|
||
const props = defineProps<{
|
||
username: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
close: [];
|
||
}>();
|
||
|
||
const detail = ref<StoryDetail | null>(null);
|
||
const loading = ref(true);
|
||
const error = ref<string | null>(null);
|
||
const currentIndex = ref(0);
|
||
const progress = ref(0); // 0-1 for the current item
|
||
|
||
const IMAGE_DURATION_MS = 5000;
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
let elapsed = 0;
|
||
|
||
const currentItem = computed(
|
||
() => detail.value?.items[currentIndex.value] ?? null,
|
||
);
|
||
|
||
async function fetchStory() {
|
||
loading.value = true;
|
||
error.value = null;
|
||
try {
|
||
const response = await fetch(`/api/stories/${props.username}`);
|
||
if (!response.ok) throw new Error('Failed to load story');
|
||
detail.value = await response.json();
|
||
currentIndex.value = detail.value?.start_index ?? 0;
|
||
} catch (e) {
|
||
error.value = 'Could not load this story.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function markViewed(storyId: string) {
|
||
// fire-and-forget, don't block the viewer on this
|
||
fetch(`/api/story-items/${storyId}/view`, { method: 'POST' }).catch(
|
||
() => {},
|
||
);
|
||
}
|
||
function startTimer() {
|
||
clearTimer();
|
||
elapsed = 0;
|
||
progress.value = 0;
|
||
|
||
// Videos advance on their own 'ended' event instead of a fixed timer
|
||
if (currentItem.value?.is_video) return;
|
||
|
||
const tick = 100;
|
||
timer = setInterval(() => {
|
||
elapsed += tick;
|
||
progress.value = Math.min(elapsed / IMAGE_DURATION_MS, 1);
|
||
if (elapsed >= IMAGE_DURATION_MS) {
|
||
next();
|
||
}
|
||
}, tick);
|
||
}
|
||
|
||
function clearTimer() {
|
||
if (timer) clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
|
||
function next() {
|
||
if (!detail.value) return;
|
||
if (currentIndex.value < detail.value.items.length - 1) {
|
||
currentIndex.value++;
|
||
} else {
|
||
emit('close');
|
||
}
|
||
}
|
||
|
||
function prev() {
|
||
if (currentIndex.value > 0) {
|
||
currentIndex.value--;
|
||
}
|
||
}
|
||
|
||
function onVideoEnded() {
|
||
next();
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'ArrowRight') next();
|
||
else if (e.key === 'ArrowLeft') prev();
|
||
else if (e.key === 'Escape') emit('close');
|
||
}
|
||
|
||
watch(currentIndex, () => {
|
||
startTimer();
|
||
if (currentItem.value) markViewed(currentItem.value.id);
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await fetchStory();
|
||
if (detail.value?.items.length) {
|
||
startTimer();
|
||
if (currentItem.value) markViewed(currentItem.value.id);
|
||
}
|
||
window.addEventListener('keydown', onKeydown);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
clearTimer();
|
||
window.removeEventListener('keydown', onKeydown);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="viewer"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-label="`${username}'s story`"
|
||
>
|
||
<div class="viewer__backdrop" @click="emit('close')" />
|
||
|
||
<div class="viewer__content">
|
||
<p v-if="loading" class="viewer__status">Loading story…</p>
|
||
<p v-else-if="error" class="viewer__status" role="alert">
|
||
{{ error }}
|
||
</p>
|
||
|
||
<template v-else-if="currentItem">
|
||
<div class="viewer__progress" role="presentation">
|
||
<div
|
||
v-for="(item, i) in detail!.items"
|
||
:key="item.id"
|
||
class="viewer__segment"
|
||
>
|
||
<div
|
||
class="viewer__segment-fill"
|
||
:style="{
|
||
width:
|
||
i < currentIndex
|
||
? '100%'
|
||
: i === currentIndex
|
||
? `${progress * 100}%`
|
||
: '0%',
|
||
}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<header class="viewer__header">
|
||
<span class="viewer__username">{{ username }}</span>
|
||
<span class="viewer__count"
|
||
>{{ currentIndex + 1 }} /
|
||
{{ detail!.items.length }}</span
|
||
>
|
||
<button
|
||
class="viewer__close"
|
||
@click="emit('close')"
|
||
aria-label="Close story"
|
||
>
|
||
✕
|
||
</button>
|
||
</header>
|
||
|
||
<div class="viewer__media">
|
||
<video
|
||
v-if="currentItem.is_video && currentItem.video_url"
|
||
:key="currentItem.id"
|
||
:src="currentItem.video_url ?? undefined"
|
||
:aria-label="
|
||
currentItem.accessibility_caption ??
|
||
`Story video from ${username}`
|
||
"
|
||
autoplay
|
||
playsinline
|
||
@ended="onVideoEnded"
|
||
class="viewer__video"
|
||
>
|
||
Your browser doesn't support video playback.
|
||
</video>
|
||
<img
|
||
v-else-if="currentItem.image_url"
|
||
:src="proxyImage(currentItem.image_url) ?? undefined"
|
||
:alt="
|
||
currentItem.accessibility_caption ??
|
||
`Story photo from ${username}`
|
||
"
|
||
class="viewer__image"
|
||
/>
|
||
</div>
|
||
|
||
<nav class="viewer__nav" aria-label="Story navigation">
|
||
<button
|
||
class="viewer__nav-btn"
|
||
:disabled="currentIndex === 0"
|
||
@click="prev"
|
||
aria-label="Previous story"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
class="viewer__nav-btn"
|
||
@click="next"
|
||
aria-label="Next story"
|
||
>
|
||
›
|
||
</button>
|
||
</nav>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.viewer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.viewer__backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(4, 12, 16, 0.92);
|
||
}
|
||
.viewer__content {
|
||
position: relative;
|
||
width: min(420px, 92vw);
|
||
height: min(760px, 88vh);
|
||
background: #000;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.viewer__status {
|
||
margin: auto;
|
||
color: var(--haze);
|
||
}
|
||
.viewer__progress {
|
||
display: flex;
|
||
gap: 0.3rem;
|
||
padding: 0.6rem 0.6rem 0;
|
||
z-index: 2;
|
||
}
|
||
.viewer__segment {
|
||
flex: 1;
|
||
height: 3px;
|
||
background: rgba(255, 255, 255, 0.25);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
.viewer__segment-fill {
|
||
height: 100%;
|
||
background: #fff;
|
||
transition: width 0.1s linear;
|
||
}
|
||
.viewer__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.6rem 0.9rem;
|
||
color: #fff;
|
||
z-index: 2;
|
||
}
|
||
.viewer__username {
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
}
|
||
.viewer__count {
|
||
font-size: 0.75rem;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
}
|
||
.viewer__close {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: #fff;
|
||
font-size: 1.1rem;
|
||
cursor: pointer;
|
||
padding: 0.3rem;
|
||
}
|
||
.viewer__media {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 0;
|
||
}
|
||
.viewer__image,
|
||
.viewer__video {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
.viewer__nav {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 0.4rem;
|
||
pointer-events: none;
|
||
}
|
||
.viewer__nav-btn {
|
||
pointer-events: auto;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
border: none;
|
||
color: #fff;
|
||
width: 2.2rem;
|
||
height: 2.2rem;
|
||
border-radius: 999px;
|
||
font-size: 1.3rem;
|
||
cursor: pointer;
|
||
}
|
||
.viewer__nav-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: default;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.viewer__segment-fill {
|
||
transition: none;
|
||
}
|
||
}
|
||
</style>
|