Files
dredgegram/resources/js/components/dredge/Stories/StoryViewer.vue
T
dredgy d8d4ecbe8a
linter / quality (push) Canceled after 0s
tests / ci (8.3) (push) Canceled after 0s
tests / ci (8.4) (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 0s
Initial Commit
2026-08-28 21:50:26 +10:00

332 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>