-
Notifications
You must be signed in to change notification settings - Fork 8
feat: 議程軌詳細頁面 #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: 議程軌詳細頁面 #215
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a87b97e
feat: add track detail page
pan93412 be36d0f
feat: expand track sessions inline via ?session= panel
pan93412 0ec1549
fix: use responsive width for track session drawer
pan93412 bfef72e
fix: correct typing
pan93412 d235171
Merge branch 'main' into implement-topic-page
a5c97e3
feat: render track description as markdown
pan93412 4179588
fix: update room localization from 'zh-hans' to 'zh-hant' across comp…
mirumodapon bb5285f
Merge pull request #249 from COSCUP/feat/locale-key
mirumodapon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| <script setup lang="ts"> | ||
| import { useI18n } from '#imports' | ||
|
|
||
| const props = defineProps<{ | ||
| title: string | ||
| subtitle?: string | ||
| count: number | ||
| days: string[] | ||
| rooms: string[] | ||
| links: { label: string, url: string }[] | ||
| }>() | ||
|
|
||
| const { t } = useI18n() | ||
|
|
||
| const selectedDay = defineModel<string>({ required: true }) | ||
|
|
||
| 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> | ||
| <header class="flex flex-col gap-5"> | ||
| <div class="flex gap-4 items-start justify-between"> | ||
| <div class="flex flex-col gap-2"> | ||
| <h1 class="text-3xl text-primary-700 font-bold sm:text-4xl"> | ||
| {{ title }} | ||
| </h1> | ||
| <p | ||
| v-if="subtitle" | ||
| class="text-gray-500" | ||
| > | ||
| {{ subtitle }} | ||
| </p> | ||
| </div> | ||
|
|
||
| <span class="text-sm text-gray-600 font-medium px-3 py-1.5 rounded-full bg-gray-100 flex-shrink-0 whitespace-nowrap"> | ||
| {{ t('count', { count }) }} | ||
| </span> | ||
| </div> | ||
|
|
||
| <div class="text-sm flex flex-wrap gap-x-6 gap-y-3 items-center"> | ||
| <!-- 議程日數 --> | ||
| <div | ||
| v-if="days.length" | ||
| class="flex gap-2 items-center" | ||
| > | ||
| <span class="text-gray-500">{{ t('days') }}</span> | ||
| <button | ||
| v-for="day in days" | ||
| :key="day" | ||
| :aria-pressed="day === activeDay" | ||
| class="text-sm font-medium px-3 py-1.5 border rounded-lg cursor-pointer transition-colors" | ||
| :class="day === activeDay | ||
| ? 'bg-primary-700 text-white border-primary-700' | ||
| : 'bg-white text-gray-700 border-gray-300 hover:border-primary-400'" | ||
| type="button" | ||
| @click="selectedDay = day" | ||
| > | ||
| {{ formatDayLabel(day) }} | ||
| </button> | ||
| </div> | ||
|
|
||
| <!-- 教室 --> | ||
| <div | ||
| v-if="rooms.length" | ||
| class="text-gray-600 flex gap-1.5 items-center" | ||
| > | ||
| <Icon | ||
| class="h-4 w-4" | ||
| name="tabler:map-pin" | ||
| /> | ||
| <span>{{ rooms.join(t('separator')) }}</span> | ||
| </div> | ||
|
|
||
| <!-- 社群連結 --> | ||
| <div | ||
| v-if="links.length" | ||
| class="flex flex-wrap gap-2 items-center" | ||
| > | ||
| <span class="text-gray-500">{{ t('links') }}</span> | ||
| <a | ||
| v-for="link in links" | ||
| :key="link.url" | ||
| class="text-sm text-white font-medium px-3 py-1.5 rounded-lg bg-primary-400 inline-flex gap-1.5 transition-colors items-center hover:bg-primary-500" | ||
| :href="link.url" | ||
| rel="noopener noreferrer" | ||
| target="_blank" | ||
| > | ||
| <Icon | ||
| class="h-4 w-4" | ||
| name="tabler:external-link" | ||
| /> | ||
| {{ link.label }} | ||
| </a> | ||
| </div> | ||
| </div> | ||
| </header> | ||
| </template> | ||
|
|
||
| <i18n lang="yaml"> | ||
| en: | ||
| count: '{count} sessions' | ||
| days: 'Days' | ||
| links: 'Community' | ||
| separator: ', ' | ||
| zh: | ||
| count: '{count} 場議程' | ||
| days: '議程日數' | ||
| links: '社群連結' | ||
| separator: '、' | ||
| </i18n> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| <script setup lang="ts"> | ||
| import type { SessionDifficulty, SessionSummary } from '#shared/types/session' | ||
| import { useI18n } from '#imports' | ||
| import CpTrackSessionBlock from './CpTrackSessionBlock.vue' | ||
|
|
||
| const { sessions } = defineProps<{ | ||
| sessions: SessionSummary[] | ||
| }>() | ||
|
|
||
| const { t, locale } = useI18n() | ||
| const route = useRoute() | ||
|
|
||
| // 時間軸尺寸。以「分鐘 → grid 列」為共同基準,刻度線與卡片同列才會對齊。 | ||
| const SLOT_MINUTES = 5 // 一列代表的分鐘數,對齊 Pretalx 排程粒度 | ||
| const ROW_HEIGHT = 16 // 一列高度 px;30 分鐘 = 6 列 = 96px,容得下兩行標題+講者+時間 | ||
| const TIME_GUTTER = '2.75rem' // 左側時間欄寬度 | ||
| const CARD_GAP = 2 // 相鄰卡片間的細縫 px | ||
|
|
||
| const DIFFICULTIES: SessionDifficulty[] = ['Elementary', 'Intermediate', 'Advanced', 'Professional'] | ||
|
|
||
| const localeKey = computed(() => (locale.value === 'zh' ? 'zh' : 'en')) | ||
| const roomKey = computed(() => (localeKey.value === 'zh' ? 'zh-hant' : 'en') as 'zh-hant' | 'en') | ||
|
|
||
| function parseMinutes(iso: string) { | ||
| const match = iso.match(/T(\d{2}):(\d{2})/) | ||
| if (!match) { | ||
| return null | ||
| } | ||
| return Number(match[1]) * 60 + Number(match[2]) | ||
| } | ||
|
pan93412 marked this conversation as resolved.
|
||
|
|
||
| function formatHour(minutes: number) { | ||
| return `${Math.floor(minutes / 60)}:00` | ||
| } | ||
|
|
||
| function roomName(session: SessionSummary) { | ||
| return session.room?.[roomKey.value] || session.room?.en | ||
| } | ||
|
|
||
| const rooms = computed(() => { | ||
| const names = sessions | ||
| .map((session) => roomName(session)) | ||
| .filter((name): name is string => Boolean(name)) | ||
| return [...new Set(names)].sort() | ||
| }) | ||
|
|
||
| // 涵蓋全部場次、往外取整到整點,最少 9:00–18:00。 | ||
| const range = computed(() => { | ||
| let min = 9 * 60 | ||
| let max = 18 * 60 | ||
| for (const session of sessions) { | ||
| if (session.start) { | ||
| const start = parseMinutes(session.start) | ||
| if (start !== null) { | ||
| min = Math.min(min, start) | ||
| } | ||
| } | ||
| if (session.end) { | ||
| const end = parseMinutes(session.end) | ||
| if (end !== null) { | ||
| max = Math.max(max, end) | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| start: Math.floor(min / 60) * 60, | ||
| end: Math.ceil(max / 60) * 60, | ||
| } | ||
| }) | ||
|
|
||
| const totalRows = computed(() => (range.value.end - range.value.start) / SLOT_MINUTES) | ||
|
|
||
| // 分鐘 → grid 列(1 起算)。 | ||
| function toRow(minutes: number) { | ||
| return Math.round((minutes - range.value.start) / SLOT_MINUTES) + 1 | ||
| } | ||
|
|
||
| const hourMarks = computed(() => { | ||
| const marks: { minutes: number, row: number }[] = [] | ||
| for (let m = range.value.start; m <= range.value.end; m += 60) { | ||
| marks.push({ minutes: m, row: toRow(m) }) | ||
| } | ||
| return marks | ||
| }) | ||
|
|
||
| // 半點(如 11:30),畫虛線、不放標籤。 | ||
| const halfHourMarks = computed(() => { | ||
| const marks: { minutes: number, row: number }[] = [] | ||
| for (let m = range.value.start + 30; m < range.value.end; m += 60) { | ||
| marks.push({ minutes: m, row: toRow(m) }) | ||
| } | ||
| return marks | ||
| }) | ||
|
|
||
| function roomSessions(room: string) { | ||
| return sessions | ||
| .filter((session) => roomName(session) === room && session.start && session.end) | ||
| .map((session) => { | ||
| const startMins = parseMinutes(session.start!) | ||
| const endMins = parseMinutes(session.end!) | ||
| if (startMins === null || endMins === null) { | ||
| return null | ||
| } | ||
|
|
||
| const difficulty = session.tags.find((tag): tag is SessionDifficulty => | ||
| (DIFFICULTIES as string[]).includes(tag)) | ||
|
|
||
| return { | ||
| id: session.id, | ||
| startMins, | ||
| title: session[localeKey.value].title, | ||
| speaker: session.speakers.map((s) => s[localeKey.value].name).join(t('separator')), | ||
| start: session.start!.slice(11, 16), | ||
| end: session.end!.slice(11, 16), | ||
| difficulty: difficulty ? t(`difficulty.${difficulty}`) : undefined, | ||
| rowStart: toRow(startMins), | ||
| rowEnd: toRow(endMins), | ||
| } | ||
| }) | ||
| .filter((session): session is NonNullable<typeof session> => session !== null) | ||
| .sort((a, b) => a.startMins - b.startMins) | ||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
| <div class="flex flex-col gap-6"> | ||
| <div | ||
| v-for="room in rooms" | ||
| :key="room" | ||
| class="border border-gray-200 rounded-2xl overflow-hidden" | ||
| > | ||
| <div class="text-primary-500 font-medium px-5 py-3 bg-primary-50 flex gap-2 items-center"> | ||
| <Icon | ||
| class="h-4 w-4" | ||
| name="tabler:map-pin" | ||
| /> | ||
| <span>{{ room }}</span> | ||
| </div> | ||
|
|
||
| <!-- 刻度線與卡片共用同一組 grid 列,故彼此對齊 --> | ||
| <div | ||
| class="px-5 py-4 grid" | ||
| :style="{ | ||
| gridTemplateColumns: `${TIME_GUTTER} minmax(0, 1fr)`, | ||
| gridTemplateRows: `repeat(${totalRows}, ${ROW_HEIGHT}px)`, | ||
| }" | ||
| > | ||
| <!-- 整點:標籤+實線 --> | ||
| <template | ||
| v-for="mark in hourMarks" | ||
| :key="`h-${mark.minutes}`" | ||
| > | ||
| <div | ||
| class="text-xs text-gray-400 pr-2 text-right self-start -translate-y-1/2" | ||
| :style="{ gridColumn: 1, gridRow: mark.row }" | ||
| > | ||
| {{ formatHour(mark.minutes) }} | ||
| </div> | ||
| <div | ||
| class="border-t border-gray-100 w-full self-start" | ||
| :style="{ gridColumn: 2, gridRow: mark.row }" | ||
| /> | ||
| </template> | ||
|
|
||
| <!-- 半點:虛線 --> | ||
| <div | ||
| v-for="mark in halfHourMarks" | ||
| :key="`hh-${mark.minutes}`" | ||
| class="border-t border-gray-100 border-dashed w-full self-start" | ||
| :style="{ gridColumn: 2, gridRow: mark.row }" | ||
| /> | ||
|
|
||
| <NuxtLink | ||
| v-for="session in roomSessions(room)" | ||
| :key="session.id" | ||
| class="min-w-0 block" | ||
| :style="{ | ||
| gridColumn: 2, | ||
| gridRow: `${session.rowStart} / ${session.rowEnd}`, | ||
| paddingBottom: `${CARD_GAP}px`, | ||
| }" | ||
| :to="{ query: { ...route.query, session: session.id } }" | ||
| > | ||
| <CpTrackSessionBlock | ||
| :difficulty="session.difficulty" | ||
| :end="session.end" | ||
| :speaker="session.speaker" | ||
| :start="session.start" | ||
| :title="session.title" | ||
| /> | ||
| </NuxtLink> | ||
| </div> | ||
| </div> | ||
|
|
||
| <p | ||
| v-if="rooms.length === 0" | ||
| class="text-gray-400 py-8 text-center" | ||
| > | ||
| {{ t('noSession') }} | ||
| </p> | ||
| </div> | ||
| </template> | ||
|
|
||
| <i18n lang="yaml"> | ||
| en: | ||
| noSession: 'No sessions on this day.' | ||
| separator: ', ' | ||
| difficulty: | ||
| Elementary: 'Elementary' | ||
| Intermediate: 'Intermediate' | ||
| Advanced: 'Advanced' | ||
| Professional: 'Professional' | ||
| zh: | ||
| noSession: '這天沒有議程。' | ||
| separator: '、' | ||
| difficulty: | ||
| Elementary: '入門' | ||
| Intermediate: '中階' | ||
| Advanced: '進階' | ||
| Professional: '專業' | ||
| </i18n> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| <script setup lang="ts"> | ||
| defineProps<{ | ||
| title: string | ||
| speaker: string | ||
| start: string | ||
| end: string | ||
| difficulty?: string | ||
| }>() | ||
| </script> | ||
|
|
||
| <template> | ||
| <div class="text-white px-3 py-1.5 rounded-lg bg-primary-400 flex flex-col gap-1 h-full transition-shadow overflow-hidden hover:shadow-lg"> | ||
| <!-- 時段過短時整體裁切;外層 flex-1 讓時間列固定在底部 --> | ||
| <div class="flex flex-1 flex-col gap-0.5 min-h-0 overflow-hidden"> | ||
| <h4 class="text-sm leading-tight font-bold line-clamp-2"> | ||
| {{ title }} | ||
| </h4> | ||
| <p | ||
| v-if="speaker" | ||
| class="text-xs opacity-90 line-clamp-1" | ||
| > | ||
| {{ speaker }} | ||
| </p> | ||
| </div> | ||
| <div class="flex flex-shrink-0 gap-2 items-center"> | ||
| <time class="text-xs opacity-80">{{ start }}–{{ end }}</time> | ||
| <span | ||
| v-if="difficulty" | ||
| class="text-[10px] leading-none px-1.5 py-0.5 rounded bg-white/20" | ||
| > | ||
| {{ difficulty }} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </template> |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.