Initial Commit
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
<!-- 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>
|
||||
Reference in New Issue
Block a user