Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0b23139
feat(ui): add `CpSessionItem` and `CpBadge` Component
mirumodapon Mar 7, 2026
4246e38
feat(ui): refactor toggle functionality in CpPopup component
mirumodapon May 1, 2026
b996ce8
feat(ui): implement logic of CpSessionTable component for session gri…
mirumodapon Apr 4, 2026
4894702
feat(ui): remove text-justify class from title in CpSessionItem compo…
mirumodapon Apr 5, 2026
e691378
style: fix with eslint
mirumodapon Apr 5, 2026
dd57390
feat(ui): integrate drag scroll functionality in CpSessionTable compo…
mirumodapon Apr 5, 2026
9d6fccc
feat: remove useless statement
mirumodapon Apr 9, 2026
cfe19ce
feat(ui): implement CpSessionDaySelector for day selection in session…
mirumodapon Apr 12, 2026
06a17a2
fix(ui): session item should can be clicked
mirumodapon Apr 12, 2026
8c25dc9
fix: current session item url
mirumodapon Apr 12, 2026
60e5965
fix(ui): correct styling issues in CpSessionItem components
mirumodapon May 1, 2026
7528640
fix(types): update PretalxData interface to allow numeric keys in map…
mirumodapon Apr 12, 2026
e6f13ef
perf(ui): improve drag scroll event handling and cleanup
mirumodapon Apr 26, 2026
01a1ee6
fix: improve parseMinutes function to handle ISO time strings correctly
mirumodapon Apr 26, 2026
acd5d07
fix(ui): update session selection logic and add i18n support for no s…
mirumodapon Apr 26, 2026
c58e4ac
fix(ui): adjust time label generation to exclude end time
mirumodapon Apr 29, 2026
224c10c
feat(ui): enhance button styling for day selection in CpSessionDaySel…
mirumodapon Apr 29, 2026
2403534
feat(ui): correct heading level for session title in CpSessionItem
mirumodapon Apr 29, 2026
70f24da
fix(ui): remove unused selectedDay prop from CpSessionDaySelector
mirumodapon Apr 29, 2026
385b726
perf(ui): simplify time parsing logic in CpSessionTable
mirumodapon Apr 29, 2026
f2bbade
perf(ui): remove unused type import for session data in index.vue
mirumodapon Apr 29, 2026
3a352e3
fix(types): update PretalxData map type to use string keys only
mirumodapon Apr 29, 2026
4962965
fix(ui): adjust grid-row calculation for time labels in CpSessionTable
mirumodapon May 9, 2026
e0141e5
fix(ui): refactor selectedDay handling to use computed property with …
mirumodapon May 9, 2026
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
34 changes: 34 additions & 0 deletions app/components/feature/CpSessionDaySelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
const props = defineProps<{ days: string[] }>()

const selectedDay = defineModel<string>()

function formatDayLabel(day: string) {
return new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
timeZone: 'Asia/Taipei',
})
.format(new Date(`${day}T00:00:00+08:00`))
.replace(' ', '.')
}

const activeDay = computed(() => selectedDay.value ?? props.days[0] ?? '')
</script>

<template>
<div class="px-6 pb-4 pt-3 border-b border-primary-100 flex justify-center">
<div class="flex flex-wrap gap-3 items-center justify-center">
<button
v-for="day in days"
:key="day"
class="text-2xl text-white font-bold px-8 py-2 border-3 border-primary-700 rounded-full min-w-34 cursor-pointer italic shadow-[0_4px_0_0_var(--un-shadow-color)] shadow-primary-700 transition-colors"
:class="day === activeDay ? 'bg-primary-400' : 'bg-gray-200'"
type="button"
@click="selectedDay = day"
>
{{ formatDayLabel(day) }}
</button>
</div>
</div>
</template>
33 changes: 33 additions & 0 deletions app/components/feature/CpSessionItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import CpBadge from '~/components/shared/CpBadge.vue'

defineProps<{
title: string
start: string
end: string
speaker: string
tags: string[]
}>()
</script>

<template>
<div class="text-primary-600 p-2 border border-primary-100 rounded bg-primary-50 relative">
<div class="flex flex-col">
<h3 class="text-base text-inherit font-normal my-1">
{{ title }}
</h3>
<time class="text-base opacity-50">{{ start }} ~ {{ end }}</time>
<p class="text-sm my-1">
{{ speaker }}
</p>
<p class="flex gap-1">
<CpBadge
v-for="tag in tags"
:key="tag"
>
{{ tag }}
</CpBadge>
</p>
</div>
</div>
</template>
172 changes: 172 additions & 0 deletions app/components/feature/CpSessionTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { SessionSummary } from '#shared/types/session'
import { useI18n } from '#imports'
import { useDragScroll } from '~/composables/useDragScroll'
import CpSessionItem from './CpSessionItem.vue'

const { sessions: _sessions, day, timeRange, interval, rowHeight } = defineProps<{
day: string
timeRange: [string, string]
sessions: SessionSummary[]
interval: number
rowHeight: number
}>()

const { locale } = useI18n()

const { containerRef, isDragging } = useDragScroll({ vertical: false })

function parseMinutes(isoStr: string) {
Comment thread
rileychh marked this conversation as resolved.
const match = isoStr.match(/T(\d{2}):(\d{2})/)
if (!match) {
throw new Error(`Invalid ISO time string: ${isoStr}`)
}
const [, hours, minutes] = match
return Number(hours) * 60 + Number(minutes)
}

const timeStart = computed(() => parseMinutes(`T${timeRange[0]}`))
const timeEnd = computed(() => parseMinutes(`T${timeRange[1]}`))
const totalGridRows = computed(() => Math.round((timeEnd.value - timeStart.value) / interval))

function formatTime(minutes: number) {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}

function toRow(minutes: number) {
return Math.round((minutes - timeStart.value) / interval) + 2
}

const rooms = computed(() => {
if (!_sessions) {
return []
}

const rooms = _sessions
.map((session) => session.room?.en)
.filter((value) => value !== undefined)

return [...new Set(rooms)].sort() satisfies string[]
})

const timeLabels = computed(() => {
const labels: { label: string, row: number }[] = []
let m = Math.ceil(timeStart.value / 30) * 30
while (m < timeEnd.value) {
labels.push({ label: formatTime(m), row: toRow(m) })
m += 30
}
return labels
})

const sessions = computed(() => {
if (!_sessions) {
return []
}

return _sessions
.filter((session) => [
session.start?.startsWith(day),
session.end,
session.room,
].every(Boolean))
Comment thread
pan93412 marked this conversation as resolved.
.map((session) => {
const startMins = parseMinutes(session.start!)
const endMins = parseMinutes(session.end!)

const { title } = session[locale.value]
const speaker = session.speakers?.map((s) => s[locale.value].name).join(', ')

return {
id: session.id,
title,
speaker,
start: session.start!.slice(11, 16),
end: session.end!.slice(11, 16),
row: [toRow(startMins), toRow(endMins)],
col: rooms.value.findIndex((r) => session.room!.en === r) + 2,
tags: [],
}
})
})
</script>

<template>
<div
ref="containerRef"
class="border border-gray-200 rounded-xl grid overflow-x-auto"
:class="isDragging ? 'cursor-grabbing select-none' : 'cursor-grab'"
:style="{
gridTemplateColumns: `4.5rem repeat(${rooms.length}, minmax(11rem, 1fr))`,
gridTemplateRows: `3rem repeat(${totalGridRows}, ${rowHeight}px)`,
}"
>
<div
class="border-b border-gray-200 bg-gray-50 left-0 top-0 sticky z-20"
:style="{
'grid-row': 1,
'grid-column': 1,
}"
/>

<div
v-for="(room, i) in rooms"
:key="room"
class="text-sm text-primary-400 font-medium border-b border-gray-200 bg-gray-50 flex items-center justify-center"
:style="{
'grid-row': 1,
'grid-column': i + 2,
}"
>
{{ room }}
</div>

<template
v-for="label in timeLabels"
:key="label.label"
>
<div
class="text-xs text-gray-400 pr-2 pt-0.5 text-right border-t border-gray-100 bg-gray-50 flex items-start justify-center"
:style="{
'grid-row': `${label.row} / ${label.row + 30 / interval}`,
'grid-column': 1,
}"
>
Comment thread
rileychh marked this conversation as resolved.
{{ label.label }}
</div>
<div
v-for="(_, i) in rooms"
:key="i"
class="border-t border-gray-100"
:style="{
'grid-row': label.row,
'grid-column': i + 2,
}"
/>
</template>

<NuxtLink
v-for="session in sessions"
:key="session.id"
class="overflow-hidden"
:draggable="false"
:style="{
'grid-row': `${session.row[0]} / ${session.row[1]}`,
'grid-column': session.col,
}"
:to="`/session/${session.id}`"
Comment thread
rileychh marked this conversation as resolved.
@dragstart.prevent
>
<CpSessionItem
class="h-full"
:end="session.end"
:speaker="session.speaker"
:start="session.start"
:tags="session.tags"
:title="session.title"
/>
</NuxtLink>
</div>
</template>
12 changes: 12 additions & 0 deletions app/components/shared/CpBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ text?: string }>()
</script>

<template>
<span class="text-xs text-primary-600 font-semibold px-2 py-0.5 text-center rounded-full bg-primary-100 inline-block">
<slot v-if="$slots.default" />
<template v-else>
{{ text }}
</template>
</span>
</template>
11 changes: 7 additions & 4 deletions app/components/shared/CpPopup.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, useToggle } from '@vueuse/core'
import { ref } from 'vue'
import useToggle from '~/composables/useToggle'

const { isOpen, close, toggle } = useToggle()
const [isOpen, toggle] = useToggle()
const target = ref(null)
const close = () => toggle(false)
function onTriggerClick() {
toggle()
}

onClickOutside(target, close)
</script>
Expand All @@ -18,7 +21,7 @@ onClickOutside(target, close)
class="cursor-pointer"
:is-open="isOpen"
name="trigger"
@click="toggle"
@click="onTriggerClick"
/>

<Teleport to="body">
Expand Down
112 changes: 112 additions & 0 deletions app/composables/useDragScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
interface UseDragScrollOptions {
horizontal?: boolean
vertical?: boolean
}

export function useDragScroll({ horizontal = true, vertical = true }: UseDragScrollOptions = {}) {
const containerRef = ref<HTMLElement>()
const isDragging = ref(false)
const suppressClick = ref(false)

const dragState = reactive({
pointerId: -1,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
})

function handlePointerDown(event: PointerEvent) {
if (event.button !== 0 || !containerRef.value) {
return
}

dragState.pointerId = event.pointerId
dragState.startX = event.clientX
dragState.startY = event.clientY
dragState.scrollLeft = containerRef.value.scrollLeft
dragState.scrollTop = containerRef.value.scrollTop
Comment thread
mirumodapon marked this conversation as resolved.
isDragging.value = true
suppressClick.value = false

window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
}

function handlePointerMove(event: PointerEvent) {
if (!isDragging.value || !containerRef.value) {
return
}

event.preventDefault()

const deltaX = event.clientX - dragState.startX
const deltaY = event.clientY - dragState.startY

const moved = (horizontal && Math.abs(deltaX) > 4) || (vertical && Math.abs(deltaY) > 4)
if (moved) {
suppressClick.value = true
}

if (horizontal) {
containerRef.value.scrollLeft = dragState.scrollLeft - deltaX
}
if (vertical) {
containerRef.value.scrollTop = dragState.scrollTop - deltaY
}
}

function stopDragging(event?: PointerEvent) {
if (event && event.pointerId !== dragState.pointerId) {
return
}

dragState.pointerId = -1
isDragging.value = false

window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}

function handleClickCapture(event: MouseEvent) {
if (!suppressClick.value) {
return
}

event.preventDefault()
event.stopPropagation()
suppressClick.value = false
}

function handleDragStart(event: DragEvent) {
event.preventDefault()
}

watchEffect((onCleanup) => {
const el = containerRef.value
if (!el) {
return
}

el.addEventListener('pointerdown', handlePointerDown)
el.addEventListener('click', handleClickCapture, { capture: true })
el.addEventListener('dragstart', handleDragStart)

onCleanup(() => {
el.removeEventListener('pointerdown', handlePointerDown)
el.removeEventListener('click', handleClickCapture, { capture: true })
el.removeEventListener('dragstart', handleDragStart)
// Clean up window listeners in case drag was in progress
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
})
})

return {
containerRef,
isDragging,
}
}
Loading