Skip to content

Commit df8e0b1

Browse files
committed
세션 시간표 페이지 추가
1 parent 56fc3b4 commit df8e0b1

File tree

3 files changed

+356
-5
lines changed

3 files changed

+356
-5
lines changed

src/components/Nav/menus.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,22 @@ const Menus: MenuType = {
7979
},
8080
session: {
8181
name: "세션",
82-
onClick: ({ setOpenMenu, navigate }) => {
83-
navigate?.("/session")
84-
setOpenMenu(false)
85-
},
82+
sub: [
83+
{
84+
name: "세션 목록",
85+
onClick: ({ setOpenMenu, navigate }) => {
86+
navigate?.("/session")
87+
setOpenMenu(false)
88+
}
89+
},
90+
{
91+
name: "세션 시간표",
92+
onClick: ({ setOpenMenu, navigate }) => {
93+
navigate?.("/session/timetable")
94+
setOpenMenu(false)
95+
}
96+
},
97+
],
8698
},
8799
sponsoring: {
88100
name: "후원하기",

src/pages/Session/timetable.tsx

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { wrap } from '@suspensive/react'
2+
import React from "react"
3+
import styled from 'styled-components'
4+
5+
import Page from "components/common/Page"
6+
import { APIPretalxSessions } from 'models/api/session'
7+
import { useNavigate } from 'react-router'
8+
import { useListSessionsQuery } from 'utils/hooks/useAPI'
9+
import useTranslation from "utils/hooks/useTranslation"
10+
11+
type TimeTableData = {
12+
[date: string]: {
13+
[time: string]: {
14+
[room: string]: {
15+
rowSpan: number
16+
session: APIPretalxSessions[0]
17+
} | undefined
18+
}
19+
}
20+
}
21+
22+
const getDateStr = (date: Date) => date.toISOString().split('T')[0]
23+
const getDetailedDateStr = (date: Date) => date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
24+
const getPaddedTime = (time: Date) => `${time.getHours()}:${time.getMinutes().toString().padStart(2, '0')}`
25+
26+
const getRoomName = (room: { [key: string]: string }) => Object.values(room)[0] ?? '알 수 없음'
27+
const getRooms = (data: APIPretalxSessions) => {
28+
const rooms: Set<string> = new Set()
29+
data.forEach((session) => (session.slot?.room) && rooms.add(getRoomName(session.slot.room)))
30+
return Array.from(rooms)
31+
}
32+
33+
const getConfStartEndTimePerDay: (data: APIPretalxSessions) => { [date: string]: { start: Date; end: Date } } = (data) => {
34+
const result: { [date: string]: { start: Date; end: Date } } = {}
35+
36+
data.forEach((session) => {
37+
if (session.slot?.start && session.slot?.end) {
38+
const startTime = new Date(session.slot.start)
39+
const endTime = new Date(session.slot.end)
40+
const date = getDateStr(startTime)
41+
42+
if (!result[date]) {
43+
result[date] = { start: startTime, end: endTime }
44+
} else {
45+
if (startTime < result[date].start) result[date].start = startTime
46+
if (endTime > result[date].end) result[date].end = endTime
47+
}
48+
}
49+
})
50+
51+
return result
52+
}
53+
54+
const getEveryTenMinutesArr = (start: Date, end: Date) => {
55+
let time = new Date(start)
56+
const arr = []
57+
58+
while (time <= end) {
59+
arr.push(time)
60+
time = new Date(new Date(time).setMinutes(time.getMinutes() + 10))
61+
}
62+
return arr
63+
}
64+
65+
const getTimeTableData: (data: APIPretalxSessions) => TimeTableData = (data) => {
66+
// Initialize timeTableData structure
67+
const timeTableData: TimeTableData = Object.entries(getConfStartEndTimePerDay(data)).reduce(
68+
(acc, [date, { start, end }]) => ({
69+
...acc,
70+
[date]: getEveryTenMinutesArr(start, end).reduce((acc, time) => ({ ...acc, [getPaddedTime(time)]: {} }), {}),
71+
}), {}
72+
)
73+
74+
// Fill timeTableData with session data
75+
data.forEach((session) => {
76+
if (session.slot?.start && session.slot?.end) {
77+
const start = new Date(session.slot.start)
78+
const durationMin = (new Date(session.slot.end).getTime() - start.getTime()) / 1000 / 60
79+
timeTableData[getDateStr(start)][getPaddedTime(start)][getRoomName(session.slot.room)] = { rowSpan: durationMin / 10, session }
80+
}
81+
})
82+
83+
return timeTableData
84+
}
85+
86+
const SessionColumn: React.FC<{ rowSpan: number, session: APIPretalxSessions[0] }> = ({ rowSpan, session }) => {
87+
const navigate = useNavigate()
88+
return <td rowSpan={rowSpan}>
89+
<SessionBox onClick={() => navigate(`/session/${session.code}`)}>
90+
<h6>{session.title}</h6>
91+
<SessionSpeakerContainer>
92+
{session.speakers.map((speaker) => <kbd key={speaker.code}>{speaker.name}</kbd>)}
93+
</SessionSpeakerContainer>
94+
</SessionBox>
95+
</td>
96+
}
97+
98+
const BreakColumn: React.FC<{ colSpan: number, hideText?: boolean }> = ({ colSpan, hideText }) => {
99+
const t = useTranslation()
100+
return <td colSpan={colSpan}>
101+
<small style={{ color: 'rgba(255, 255, 255, 0.5)' }}>{!hideText && t('휴식')}</small>
102+
</td>
103+
}
104+
105+
const BlankColumn: React.FC = () => <td></td>
106+
107+
export const SessionTimeTablePage: React.FC = () => {
108+
const t = useTranslation()
109+
110+
const SessionTimeTable = wrap
111+
.ErrorBoundary({ fallback: <h4>{t("세션 목록을 불러오는 중 에러가 발생했습니다.")}</h4> })
112+
.Suspense({ fallback: <h4>{t("세션 목록을 불러오는 중 입니다.")}</h4> })
113+
.on(() => {
114+
// eslint-disable-next-line react-hooks/rules-of-hooks
115+
React.useEffect(() => window.scrollTo(0, 0), [])
116+
// eslint-disable-next-line react-hooks/rules-of-hooks
117+
const navigate = useNavigate()
118+
// eslint-disable-next-line react-hooks/rules-of-hooks
119+
const [confDate, setConfDate] = React.useState('')
120+
// eslint-disable-next-line react-hooks/rules-of-hooks
121+
const { data } = useListSessionsQuery()
122+
123+
const timeTableData = getTimeTableData(data)
124+
const dates = Object.keys(timeTableData).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
125+
const rooms: { [room: string]: number } = getRooms(data).reduce((acc, room) => ({ ...acc, [room]: 0 }), {})
126+
const roomCount = Object.keys(rooms).length
127+
128+
const selectedDate = confDate || dates[0]
129+
const selectedTableData = timeTableData[selectedDate]
130+
return <>
131+
<hr />
132+
<SessionDateTabContainer>
133+
{
134+
dates.map(
135+
(date, i) => <button key={date} onClick={() => setConfDate(date)} className={selectedDate === date ? 'selected' : ''}>
136+
<h3>Day {i + 1}</h3>
137+
<h6><small>{t(getDetailedDateStr(new Date(date)))}</small></h6>
138+
</button>
139+
)
140+
}
141+
</SessionDateTabContainer>
142+
<hr />
143+
<SessionTableContainer>
144+
<SessionTable>
145+
<thead>
146+
<th></th>
147+
{Object.keys(rooms).map((room) => <th key={room}>{t(room)}</th>)}
148+
</thead>
149+
<tbody>
150+
<tr><td colSpan={roomCount + 1}></td></tr>
151+
{
152+
Object.entries(selectedTableData).map(([time, roomData], i, a) => {
153+
return <tr>
154+
<td>{time}</td>
155+
{
156+
Object.values(rooms).some((c) => c >= 1) || Object.values(roomData).some((room) => room !== undefined)
157+
? Object.keys(rooms).map((room) => {
158+
const roomDatum = roomData[room]
159+
if (roomDatum === undefined) {
160+
// 진행 중인 세션이 없는 경우, 해당 줄에서는 해당 room의 빈 column을 생성합니다.
161+
if (rooms[room] <= 0) return <BlankColumn />
162+
// 진행 중인 세션이 있는 경우, 이번 줄에서는 해당 세션들만큼 column을 생성하지 않습니다.
163+
rooms[room] -= 1
164+
return null
165+
}
166+
// 세션이 여러 줄에 걸쳐있는 경우, n-1 줄만큼 해당 room에 column을 생성하지 않도록 합니다.
167+
if (roomDatum.rowSpan > 1) rooms[room] = roomDatum.rowSpan - 1
168+
return <SessionColumn key={room} rowSpan={roomDatum.rowSpan} session={roomDatum.session} />
169+
})
170+
: <BreakColumn colSpan={roomCount} hideText={i === a.length - 1} />
171+
}
172+
</tr>
173+
})
174+
}
175+
</tbody>
176+
</SessionTable>
177+
</SessionTableContainer>
178+
</>
179+
})
180+
181+
return (
182+
<Page>
183+
<h1>{t("세션 시간표")}</h1>
184+
<hr />
185+
<h6 style={{ paddingLeft: '1rem' }}>* {t('발표 목록은 발표자 사정에 따라 변동될 수 있습니다.')}</h6>
186+
<SessionTimeTable />
187+
</Page>
188+
)
189+
}
190+
191+
const TD_HEIGHT = 2.5
192+
const TD_WIDTH = 12.5
193+
194+
const SessionDateTabContainer = styled.div`
195+
display: flex;
196+
gap: 2rem;
197+
justify-content: center;
198+
align-items: center;
199+
200+
button {
201+
background-color: unset;
202+
color: rgba(255, 255, 255, 0.5);
203+
border: unset;
204+
205+
&.selected {
206+
color: rgba(255, 255, 255, 1);
207+
}
208+
}
209+
210+
h1, h2, h3, h4, h5, h6 {
211+
margin: 0;
212+
color: inherit;
213+
}
214+
`
215+
216+
const SessionTableContainer = styled.div`
217+
display: flex;
218+
flex-direction: column;
219+
align-items: center;
220+
justify-content: center;
221+
gap: 1rem;
222+
`
223+
224+
const SessionTable = styled.table`
225+
width: 100%;
226+
max-width: 60rem;
227+
228+
* {
229+
background-color: unset;
230+
text-align: center;
231+
margin: 0;
232+
padding: 0;
233+
border: unset;
234+
}
235+
236+
tbody > th {
237+
border: unset;
238+
}
239+
240+
tr:first-child td {
241+
border-top: unset;
242+
transform: unset;
243+
height: ${TD_HEIGHT / 2}rem;
244+
}
245+
246+
td {
247+
height: ${TD_HEIGHT}rem;
248+
}
249+
250+
td:first-child {
251+
border-top: unset;
252+
transform: translateY(-${TD_HEIGHT / 2}rem);
253+
width: 1.5rem;
254+
max-width: 1.5rem;
255+
256+
font-size: 0.75rem;
257+
color: rgba(255, 255, 255, 0.5);
258+
}
259+
260+
td:not(:first-child) {
261+
width: ${TD_WIDTH}vw;
262+
max-width: ${TD_WIDTH}vw;
263+
border-top: 1px solid rgba(255, 255, 255, 0.1);
264+
}
265+
`
266+
267+
const SessionBox = styled.div`
268+
height: 100%;
269+
margin: 0.25rem;
270+
padding: 0.25rem;
271+
display: flex;
272+
flex-direction: column;
273+
justify-content: center;
274+
align-items: center;
275+
border: 1px solid rgba(176, 168, 254, 0.75);
276+
border-radius: 0.5rem;
277+
278+
background-color: rgba(176, 168, 254, 0.1);
279+
font-size: 1rem;
280+
transition: all 0.25s ease;
281+
282+
cursor: pointer;
283+
284+
h6 {
285+
margin: 0;
286+
color: rgba(255, 255, 255, 0.6);
287+
font-size: 0.9rem;
288+
transition: all 0.25s ease;
289+
}
290+
291+
kbd {
292+
background-color: rgba(222, 240, 128, 0.5);
293+
padding: 0.1rem 0.25rem;
294+
margin: 0.5rem 0.25rem 0 0.25rem;
295+
border-radius: 0.25rem;
296+
297+
color: black;
298+
font-size: 0.6rem;
299+
transition: all 0.25s ease;
300+
}
301+
302+
&:hover {
303+
background-color: rgba(176, 168, 254, 0.25);
304+
transition: all 0.25s ease;
305+
306+
h6 {
307+
color: rgba(255, 255, 255, 1);
308+
transition: all 0.25s ease;
309+
}
310+
311+
kbd {
312+
background-color: rgba(222, 240, 128, 0.75);
313+
transition: all 0.25s ease;
314+
}
315+
}
316+
317+
@media only screen and (max-width: 810px) {
318+
font-size: 0.75rem;
319+
margin: 0.1rem;
320+
padding: 0.1rem;
321+
322+
h6 {
323+
font-size: 0.666rem;
324+
}
325+
326+
kbd {
327+
font-size: 0.45rem;
328+
margin: 0.25rem 0.1rem;
329+
}
330+
}
331+
`
332+
333+
const SessionSpeakerContainer = styled.div`
334+
display: flex;
335+
align-items: center;
336+
justify-content: flex-start;
337+
`

src/routes.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import NotFound from "pages/NotFound"
1212
import PrivacyPolicy from "pages/PrivacyPolicy"
1313
import { SessionDetailPage } from "pages/Session/detail"
1414
import { SessionListPage } from "pages/Session/list"
15+
import { SessionTimeTablePage } from "pages/Session/timetable"
1516
import SponsorPage from "pages/Sponsor"
1617
import TermsOfService from "pages/TermsOfService"
17-
import Health from "./pages/About/health";
18+
import Health from "./pages/About/health"
1819

1920
const Router = () => {
2021
return (
@@ -28,6 +29,7 @@ const Router = () => {
2829
<Route path="/sponsoring/sponsor/prospectus" element={<SponsorPage />} />
2930
<Route path="/session" element={<SessionListPage />} />
3031
<Route path="/session/:code" element={<SessionDetailPage />} />
32+
<Route path="/session/timetable" element={<SessionTimeTablePage />} />
3133
<Route path="/contribution/cfp" element={<Cfp />} />
3234
<Route path="/terms-of-service" element={<TermsOfService />} />
3335
<Route path="/privacy-policy" element={<PrivacyPolicy />} />

0 commit comments

Comments
 (0)