Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions app/components/feature/CpSessionDetailModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { Ad } from '#shared/types/ad'
import { useMediaQuery } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import CpSessionDetailShareButton from '~/components/feature/CpSessionDetailShareButton.vue'
import CpSessionInfoCard from '~/components/feature/CpSessionInfoCard.vue'
import { useFavorites } from '~/composables/useFavorites'

// Session-detail dialog shared by the session grid and the track page. The parent
// supplies the resolved content and handles `close`.
const props = defineProps<{
sessionId: string
title: string
time: string
speakers: {
id?: string
name: string
bio: string
avatar?: string
}[]
room: string
coWrite?: string
tags: string[]
track?: {
id: number
name: string
color: string
}
description: string
trackColor: string
// One is picked (weighted) on mount.
ads?: Ad[]
}>()

const emit = defineEmits<{
close: []
}>()

const { t } = useI18n()
const isDesktop = useMediaQuery('(min-width: 640px)')
const { isFavorite, toggleFavorite } = useFavorites()

const scroller = ref<HTMLElement | null>(null)
const dragOffsetY = ref(0)
const isDragging = ref(false)
const dragCloseThreshold = 140
const dragState = { startY: 0 }

const randomAd = ref<Ad | null>(null)

const sheetStyle = computed(() => ({
transform: dragOffsetY.value > 0 ? `translateY(${dragOffsetY.value}px)` : undefined,
transition: isDragging.value ? 'none' : undefined,
}))

function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && !!target.closest('a, button, input, textarea, select, [role="button"]')
}

function resetDrag() {
isDragging.value = false
dragOffsetY.value = 0
dragState.startY = 0
}

function onTouchStart(event: TouchEvent) {
if (isDesktop.value || event.touches.length !== 1 || isInteractiveTarget(event.target)) {
return
}

if ((scroller.value?.scrollTop ?? 0) > 0) {
return
}

isDragging.value = true
dragState.startY = event.touches[0]?.clientY ?? 0
}

function onTouchMove(event: TouchEvent) {
if (!isDragging.value || event.touches.length !== 1) {
return
}

const distance = (event.touches[0]?.clientY ?? 0) - dragState.startY
dragOffsetY.value = Math.max(0, distance)

if (dragOffsetY.value > 0 && event.cancelable) {
event.preventDefault()
}
}

function onTouchEnd() {
if (!isDragging.value) {
return
}

if (dragOffsetY.value >= dragCloseThreshold) {
emit('close')
return
}

resetDrag()
}

function pickWeightedAd(ads: Ad[]) {
if (!ads.length) {
return null
}

const weightedAds = ads.map((ad) => ({ ad, weight: ad.weight }))

const totalWeight = weightedAds.reduce((total, { weight }) => (
Number.isFinite(weight) && weight > 0 ? total + weight : total
), 0)

if (totalWeight <= 0) {
return ads[Math.floor(Math.random() * ads.length)] ?? null
}

let random = Math.random() * totalWeight

for (const { ad, weight } of weightedAds) {
if (!Number.isFinite(weight) || weight <= 0) {
continue
}

random -= weight

if (random < 0) {
return ad
}
}

return ads.at(-1) ?? null
}

function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
}
}

onMounted(() => {
randomAd.value = pickWeightedAd(props.ads ?? [])
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKeydown)
})

onUnmounted(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', onKeydown)
})
</script>

<template>
<div
:aria-label="title"
aria-modal="true"
class="bg-black/50 flex items-end inset-0 justify-center fixed z-modal sm:items-center"
role="dialog"
@click.self="emit('close')"
>
<div
class="rounded-lg bg-white flex flex-col h-80vh max-w-5xl w-full transition-transform duration-200 ease-out overflow-hidden sm:h-70vh sm:w-80vw"
:style="sheetStyle"
@touchcancel="resetDrag"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
@touchstart="onTouchStart"
>
<div
class="h-2"
:style="{ backgroundColor: trackColor }"
/>

<div class="flex flex-1 min-h-0">
<NuxtLink
v-if="randomAd && isDesktop"
class="shrink-0 h-full aspect-[1/4]"
target="_blank"
:to="randomAd.link"
>
<!-- AD -->
<NuxtImg
:alt="randomAd.id"
class="h-full w-full object-contain"
:src="randomAd.imageVertical"
/>
</NuxtLink>

<div
ref="scroller"
class="h-full w-full overflow-y-auto"
>
<div class="py-2 flex justify-center sm:hidden">
<div class="rounded-full bg-gray-300 h-1 w-10" />
</div>

<div class="mr-4 flex gap-2 h-0 top-5 justify-end relative z-content overflow-visible sm:mr-6">
<CpSessionDetailShareButton :title="title" />
Comment thread
pan93412 marked this conversation as resolved.
<button
:aria-label="isFavorite(sessionId) ? t('removeFavorite') : t('addFavorite')"
class="text-sm font-500 px-3 border-2 rounded flex gap-1 h-8 cursor-pointer transition-colors items-center"
:class="isFavorite(sessionId) ? 'bg-favorite border-favorite text-white' : 'bg-gray-100 border-gray-300 hover:bg-gray-200'"
@click="toggleFavorite(sessionId)"
>
<Icon
class="h-4 w-4"
:class="isFavorite(sessionId) ? 'text-white' : 'text-gray-500'"
:name="isFavorite(sessionId) ? 'tabler:star-filled' : 'tabler:star'"
/>
<span class="hidden sm:inline">{{ isFavorite(sessionId) ? t('saved') : t('save') }}</span>
</button>
<button
aria-label="close"
class="text-gray-500 rounded flex h-8 w-8 cursor-pointer transition-colors items-center justify-center hover:bg-gray-100"
type="button"
@click="emit('close')"
>
<Icon
class="h-5 w-5"
name="tabler:x"
/>
</button>
</div>

<CpSessionInfoCard
:ad="randomAd"
:co-write="coWrite"
:description="description"
has-title-margin-right
:room="room"
:speakers="speakers"
:tags="tags"
:time="time"
:title="title"
:track="track"
/>
</div>
</div>
</div>
</div>
</template>

<i18n lang="yaml">
en:
addFavorite: 'Save session'
removeFavorite: 'Remove from favorites'
save: 'Save'
saved: 'Saved'
zh:
addFavorite: '收藏議程'
removeFavorite: '取消收藏議程'
save: '收藏'
saved: '已收藏'
</i18n>
2 changes: 1 addition & 1 deletion app/components/feature/CpSessionInfoCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ en:
co-write: Co-write
room: Room
speaker: Speaker
time: time
time: Time
zh:
abstract: 議程簡介
room: 位置
Expand Down
Loading
Loading