|
1 | 1 | import SidebarLoader from '@components/skeleton/SidebarLoader' |
2 | 2 | import { useStore } from '@stores' |
3 | | -import React, { useEffect } from 'react' |
4 | | -import { MdCalendarToday, MdAccessTime } from 'react-icons/md' |
| 3 | +import { useState } from 'react' |
| 4 | +import { MdExpandMore, MdExpandLess } from 'react-icons/md' |
5 | 5 | import { |
6 | | - dayContainsCurrentVersion, |
7 | | - groupContainsCurrentVersion, |
8 | | - groupHistoryByDay |
| 6 | + groupSessionsByDay, |
| 7 | + dayContainsVersion, |
| 8 | + sessionContainsVersion, |
| 9 | + formatTime, |
| 10 | + formatRelativeTime, |
| 11 | + type VersionSession |
9 | 12 | } from '../helpers' |
10 | 13 | import { useVersionContent } from '../hooks/useVersionContent' |
11 | 14 | import { twMerge } from 'tailwind-merge' |
12 | 15 | import { useModal } from '@components/ui/ModalDrawer' |
| 16 | + |
13 | 17 | const Sidebar = ({ className }: { className?: string }) => { |
14 | 18 | const { loadingHistory, activeHistory, historyList } = useStore((state) => state) |
15 | 19 | const { watchVersionContent } = useVersionContent() |
16 | 20 | const { close: closeModal } = useModal() || {} |
17 | 21 |
|
18 | 22 | if (loadingHistory || !activeHistory) return <SidebarLoader /> |
19 | 23 |
|
| 24 | + const groupedByDay = groupSessionsByDay(historyList) |
| 25 | + const latestVersion = historyList[0]?.version |
| 26 | + |
20 | 27 | return ( |
21 | 28 | <div |
22 | | - className={twMerge('sidebar h-full w-[25%] border-l border-gray-200 bg-base-100', className)}> |
| 29 | + className={twMerge('sidebar bg-base-100 h-full w-[25%] border-l border-gray-200', className)}> |
23 | 30 | <div className="flex h-full flex-col"> |
24 | | - <div className="h-[94px] border-gray-200 p-4"> |
25 | | - <h2 className="mb-2 text-2xl font-bold text-base-content">Version History</h2> |
26 | | - <p className="text-sm font-medium text-base-content/60">Auto versioning enabled</p> |
| 31 | + {/* Header */} |
| 32 | + <div className="border-b border-gray-200 px-4 py-4"> |
| 33 | + <h2 className="text-base-content text-lg font-bold">Version History</h2> |
| 34 | + <p className="text-base-content/60 mt-0.5 text-xs"> |
| 35 | + {historyList.length} version{historyList.length !== 1 ? 's' : ''} |
| 36 | + </p> |
27 | 37 | </div> |
28 | | - <div className="flex-1 overflow-y-auto px-4"> |
29 | | - <ul className="menu w-full rounded-box"> |
30 | | - {Object.entries(groupHistoryByDay(historyList)).map(([date, hourGroups], dateIndex) => ( |
31 | | - <li key={date}> |
32 | | - <details open={dayContainsCurrentVersion(hourGroups, activeHistory.version)}> |
33 | | - <summary className="font-medium"> |
34 | | - <MdCalendarToday className="h-4 w-4" /> |
35 | | - {date} |
36 | | - </summary> |
37 | | - <ul> |
38 | | - {Object.entries(hourGroups).map(([hour, items], hourIndex) => ( |
39 | | - <li key={`${date}-${hour}`}> |
40 | | - <details open={groupContainsCurrentVersion(items, activeHistory.version)}> |
41 | | - <summary className="text-sm font-medium"> |
42 | | - <MdAccessTime className="h-4 w-4" /> |
43 | | - {hour} |
44 | | - </summary> |
45 | | - <ul> |
46 | | - {items.map((item) => ( |
47 | | - <li key={item.version}> |
48 | | - <button |
49 | | - onClick={() => { |
50 | | - watchVersionContent(item.version) |
51 | | - closeModal && closeModal() |
52 | | - }} |
53 | | - className={`w-full p-3 ${activeHistory.version === item.version ? 'active text-primary' : ''}`}> |
54 | | - <div className="flex flex-col"> |
55 | | - <span className="font-medium">Version {item.version}</span> |
56 | | - <span |
57 | | - className={`text-xs ${ |
58 | | - activeHistory.version === item.version |
59 | | - ? 'text-primary' |
60 | | - : 'text-base-content/60' |
61 | | - }`}> |
62 | | - {new Date(item.createdAt).toLocaleTimeString()} |
63 | | - </span> |
64 | | - {item.commitMessage && ( |
65 | | - <span className="mt-1 text-sm">{item.commitMessage}</span> |
66 | | - )} |
67 | | - </div> |
68 | | - </button> |
69 | | - </li> |
70 | | - ))} |
71 | | - </ul> |
72 | | - </details> |
73 | | - </li> |
74 | | - ))} |
75 | | - </ul> |
76 | | - </details> |
77 | | - </li> |
78 | | - ))} |
79 | | - </ul> |
| 38 | + |
| 39 | + {/* Version List */} |
| 40 | + <div className="flex-1 overflow-y-auto"> |
| 41 | + {Object.entries(groupedByDay).map(([dayLabel, sessions]) => ( |
| 42 | + <DayGroup |
| 43 | + key={dayLabel} |
| 44 | + label={dayLabel} |
| 45 | + sessions={sessions} |
| 46 | + activeVersion={activeHistory.version} |
| 47 | + latestVersion={latestVersion} |
| 48 | + onSelectVersion={(version) => { |
| 49 | + watchVersionContent(version) |
| 50 | + closeModal?.() |
| 51 | + }} |
| 52 | + defaultOpen={dayContainsVersion(sessions, activeHistory.version)} |
| 53 | + /> |
| 54 | + ))} |
80 | 55 | </div> |
81 | 56 | </div> |
82 | 57 | </div> |
83 | 58 | ) |
84 | 59 | } |
85 | 60 |
|
| 61 | +interface DayGroupProps { |
| 62 | + label: string |
| 63 | + sessions: VersionSession[] |
| 64 | + activeVersion: number |
| 65 | + latestVersion: number |
| 66 | + onSelectVersion: (version: number) => void |
| 67 | + defaultOpen: boolean |
| 68 | +} |
| 69 | + |
| 70 | +const DayGroup = ({ |
| 71 | + label, |
| 72 | + sessions, |
| 73 | + activeVersion, |
| 74 | + latestVersion, |
| 75 | + onSelectVersion, |
| 76 | + defaultOpen |
| 77 | +}: DayGroupProps) => { |
| 78 | + const [isOpen, setIsOpen] = useState(defaultOpen) |
| 79 | + |
| 80 | + return ( |
| 81 | + <div className="border-b border-gray-200"> |
| 82 | + {/* Day Header */} |
| 83 | + <button |
| 84 | + onClick={() => setIsOpen(!isOpen)} |
| 85 | + className="bg-base-200 hover:bg-base-300 flex w-full cursor-pointer items-center justify-between px-4 py-2.5 transition-colors"> |
| 86 | + <span className="text-base-content text-sm font-semibold">{label}</span> |
| 87 | + {isOpen ? ( |
| 88 | + <MdExpandLess className="text-base-content/50" size={18} /> |
| 89 | + ) : ( |
| 90 | + <MdExpandMore className="text-base-content/50" size={18} /> |
| 91 | + )} |
| 92 | + </button> |
| 93 | + |
| 94 | + {/* Sessions */} |
| 95 | + {isOpen && ( |
| 96 | + <div className="py-1"> |
| 97 | + {sessions.map((session) => ( |
| 98 | + <SessionItem |
| 99 | + key={session.id} |
| 100 | + session={session} |
| 101 | + activeVersion={activeVersion} |
| 102 | + latestVersion={latestVersion} |
| 103 | + onSelectVersion={onSelectVersion} |
| 104 | + /> |
| 105 | + ))} |
| 106 | + </div> |
| 107 | + )} |
| 108 | + </div> |
| 109 | + ) |
| 110 | +} |
| 111 | + |
| 112 | +interface SessionItemProps { |
| 113 | + session: VersionSession |
| 114 | + activeVersion: number |
| 115 | + latestVersion: number |
| 116 | + onSelectVersion: (version: number) => void |
| 117 | +} |
| 118 | + |
| 119 | +const SessionItem = ({ |
| 120 | + session, |
| 121 | + activeVersion, |
| 122 | + latestVersion, |
| 123 | + onSelectVersion |
| 124 | +}: SessionItemProps) => { |
| 125 | + const [isExpanded, setIsExpanded] = useState( |
| 126 | + sessionContainsVersion(session, activeVersion) && session.versions.length > 1 |
| 127 | + ) |
| 128 | + |
| 129 | + const hasMultipleVersions = session.versions.length > 1 |
| 130 | + const isActive = sessionContainsVersion(session, activeVersion) |
| 131 | + const isLatestSession = session.isLatest |
| 132 | + |
| 133 | + // Single version - show directly |
| 134 | + if (!hasMultipleVersions) { |
| 135 | + const version = session.versions[0] |
| 136 | + const isCurrentActive = version.version === activeVersion |
| 137 | + const isLatest = version.version === latestVersion |
| 138 | + |
| 139 | + return ( |
| 140 | + <button |
| 141 | + onClick={() => onSelectVersion(version.version)} |
| 142 | + className={twMerge( |
| 143 | + 'flex w-full cursor-pointer items-start gap-3 px-4 py-2 text-left transition-all', |
| 144 | + isCurrentActive |
| 145 | + ? 'bg-primary/15 hover:bg-base-300' |
| 146 | + : 'hover:bg-base-200 active:bg-base-200' |
| 147 | + )}> |
| 148 | + {/* Timeline dot */} |
| 149 | + <div className="flex flex-col items-center pt-1"> |
| 150 | + <div |
| 151 | + className={twMerge( |
| 152 | + 'hover:bg-docsy h-2.5 w-2.5 rounded-full transition-colors', |
| 153 | + isCurrentActive ? 'bg-primary' : 'bg-base-300' |
| 154 | + )} |
| 155 | + /> |
| 156 | + </div> |
| 157 | + |
| 158 | + {/* Content */} |
| 159 | + <div className="min-w-0 flex-1"> |
| 160 | + <div className="flex items-center gap-2"> |
| 161 | + <span |
| 162 | + className={twMerge( |
| 163 | + 'text-sm font-medium', |
| 164 | + isCurrentActive ? 'text-primary' : 'text-base-content' |
| 165 | + )}> |
| 166 | + {formatTime(version.createdAt)} |
| 167 | + </span> |
| 168 | + {isLatest && ( |
| 169 | + <span className="bg-primary text-primary-content rounded-sm px-1.5 py-0.5 text-[10px] font-semibold"> |
| 170 | + Latest |
| 171 | + </span> |
| 172 | + )} |
| 173 | + </div> |
| 174 | + <p |
| 175 | + className={twMerge( |
| 176 | + 'mt-0.5 text-xs', |
| 177 | + isCurrentActive ? 'text-primary/70' : 'text-base-content/50' |
| 178 | + )}> |
| 179 | + {formatRelativeTime(version.createdAt)} |
| 180 | + </p> |
| 181 | + {version.commitMessage && ( |
| 182 | + <p className="text-base-content/70 mt-1 truncate text-xs">{version.commitMessage}</p> |
| 183 | + )} |
| 184 | + </div> |
| 185 | + </button> |
| 186 | + ) |
| 187 | + } |
| 188 | + |
| 189 | + // Multiple versions - collapsible session |
| 190 | + return ( |
| 191 | + <div className={twMerge('transition-colors', isActive ? 'bg-primary/5' : '')}> |
| 192 | + {/* Session header */} |
| 193 | + <button |
| 194 | + onClick={() => setIsExpanded(!isExpanded)} |
| 195 | + className={twMerge( |
| 196 | + 'flex w-full cursor-pointer items-start gap-3 px-4 py-2 text-left transition-all', |
| 197 | + 'hover:bg-base-200/50 active:bg-base-200' |
| 198 | + )}> |
| 199 | + {/* Timeline dot with count badge */} |
| 200 | + <div className="flex flex-col items-center pt-1"> |
| 201 | + <div className="relative"> |
| 202 | + <div |
| 203 | + className={twMerge( |
| 204 | + 'h-2.5 w-2.5 rounded-full transition-colors', |
| 205 | + isActive ? 'bg-primary' : 'bg-base-300' |
| 206 | + )} |
| 207 | + /> |
| 208 | + {/* Count badge */} |
| 209 | + <span className="bg-base-content text-base-100 absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold"> |
| 210 | + {session.versions.length} |
| 211 | + </span> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + |
| 215 | + {/* Content */} |
| 216 | + <div className="min-w-0 flex-1"> |
| 217 | + <div className="flex items-center gap-2"> |
| 218 | + <span |
| 219 | + className={twMerge( |
| 220 | + 'text-sm font-medium', |
| 221 | + isActive ? 'text-primary' : 'text-base-content' |
| 222 | + )}> |
| 223 | + {formatTime(session.startTime)} – {formatTime(session.endTime)} |
| 224 | + </span> |
| 225 | + {isLatestSession && ( |
| 226 | + <span className="bg-primary text-primary-content rounded-sm px-1.5 py-0.5 text-[10px] font-semibold"> |
| 227 | + Latest |
| 228 | + </span> |
| 229 | + )} |
| 230 | + </div> |
| 231 | + <p |
| 232 | + className={twMerge( |
| 233 | + 'mt-0.5 text-xs', |
| 234 | + isActive ? 'text-primary/70' : 'text-base-content/50' |
| 235 | + )}> |
| 236 | + {session.versions.length} changes · {formatRelativeTime(session.endTime)} |
| 237 | + </p> |
| 238 | + </div> |
| 239 | + |
| 240 | + {/* Expand icon */} |
| 241 | + <div className="pt-0.5"> |
| 242 | + {isExpanded ? ( |
| 243 | + <MdExpandLess className="text-base-content/50" size={16} /> |
| 244 | + ) : ( |
| 245 | + <MdExpandMore className="text-base-content/50" size={16} /> |
| 246 | + )} |
| 247 | + </div> |
| 248 | + </button> |
| 249 | + |
| 250 | + {/* Expanded versions list */} |
| 251 | + {isExpanded && ( |
| 252 | + <div className="border-base-200 ml-[26px] border-l-2 py-1 pl-4"> |
| 253 | + {session.versions.map((version) => { |
| 254 | + const isCurrentActive = version.version === activeVersion |
| 255 | + const isLatest = version.version === latestVersion |
| 256 | + |
| 257 | + return ( |
| 258 | + <button |
| 259 | + key={version.version} |
| 260 | + onClick={() => onSelectVersion(version.version)} |
| 261 | + className={twMerge( |
| 262 | + 'flex w-full cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 text-left transition-all', |
| 263 | + isCurrentActive |
| 264 | + ? 'bg-primary/15 hover:bg-primary/20' |
| 265 | + : 'hover:bg-base-200/60 active:bg-base-200' |
| 266 | + )}> |
| 267 | + {/* Mini dot */} |
| 268 | + <div |
| 269 | + className={twMerge( |
| 270 | + 'h-1.5 w-1.5 rounded-full', |
| 271 | + isCurrentActive ? 'bg-primary' : 'bg-base-300' |
| 272 | + )} |
| 273 | + /> |
| 274 | + |
| 275 | + <span |
| 276 | + className={twMerge( |
| 277 | + 'text-xs font-medium', |
| 278 | + isCurrentActive ? 'text-primary' : 'text-base-content/80' |
| 279 | + )}> |
| 280 | + {formatTime(version.createdAt)} |
| 281 | + </span> |
| 282 | + |
| 283 | + {isLatest && ( |
| 284 | + <span className="bg-primary text-primary-content rounded-sm px-1 py-0.5 text-[9px] font-semibold"> |
| 285 | + Latest |
| 286 | + </span> |
| 287 | + )} |
| 288 | + </button> |
| 289 | + ) |
| 290 | + })} |
| 291 | + </div> |
| 292 | + )} |
| 293 | + </div> |
| 294 | + ) |
| 295 | +} |
| 296 | + |
86 | 297 | export default Sidebar |
0 commit comments