| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | |
| 4 | <head> |
| 5 | <meta charset="UTF-8"> |
| 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 7 | <meta name="description" content="PPT Master Slide Gallery — Browse and view SVG presentation slides across various topics"> |
| 8 | <meta name="keywords" content="slides, SVG, presentation, PPT, PowerPoint"> |
| 9 | <meta property="og:title" content="Slide Gallery — PPT Master"> |
| 10 | <meta property="og:description" content="Browse example presentations — AI-generated, natively editable PowerPoint from any document."> |
| 11 | <meta property="og:url" content="https://hugohe3.github.io/ppt-master/viewer.html"> |
| 12 | <meta property="og:type" content="website"> |
| 13 | <title>Slide Gallery — PPT Master</title> |
| 14 | |
| 15 | <!-- Tailwind CSS --> |
| 16 | <script src="https://cdn.tailwindcss.com"></script> |
| 17 | <script> |
| 18 | tailwind.config = { |
| 19 | theme: { |
| 20 | extend: { |
| 21 | colors: { |
| 22 | brand: { |
| 23 | 50: '#eef3ff', |
| 24 | 100: '#dde7ff', |
| 25 | 200: '#c0d2ff', |
| 26 | 300: '#9bb6ff', |
| 27 | 400: '#7da0ff', |
| 28 | 500: '#5b8def', |
| 29 | 600: '#4a7be0', |
| 30 | 700: '#3b65c2', |
| 31 | 800: '#2f4f99', |
| 32 | 900: '#243d75', |
| 33 | } |
| 34 | }, |
| 35 | fontFamily: { |
| 36 | sans: ['Inter', 'system-ui', '-apple-system', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'], |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | </script> |
| 42 | |
| 43 | <!-- Fonts & Icons --> |
| 44 | <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> |
| 45 | |
| 46 | <!-- Favicon --> |
| 47 | <link rel="icon" |
| 48 | href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 32' fill='none' stroke='%235b8def' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><rect x='2' y='2' width='44' height='28' rx='2.5'/><line x1='9' y1='11' x2='22' y2='11'/><line x1='9' y1='17' x2='32' y2='17'/><line x1='9' y1='23' x2='26' y2='23'/></svg>"> |
| 49 | |
| 50 | <style> |
| 51 | :root { |
| 52 | --primary: #5b8def; |
| 53 | --primary-light: #88aaff; |
| 54 | --darker: #0d0d0f; |
| 55 | --surface: #16161a; |
| 56 | --surface-hover: #1d1d20; |
| 57 | --border: #26262a; |
| 58 | --text: #e6e6e9; |
| 59 | --text-muted: #8a8a93; |
| 60 | } |
| 61 | |
| 62 | body { |
| 63 | font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; |
| 64 | background: var(--darker); |
| 65 | color: var(--text); |
| 66 | min-height: 100vh; |
| 67 | -webkit-font-smoothing: antialiased; |
| 68 | -moz-osx-font-smoothing: grayscale; |
| 69 | } |
| 70 | |
| 71 | /* Subtle ambient glow at the top of the page */ |
| 72 | body::before { |
| 73 | content: ''; |
| 74 | position: fixed; |
| 75 | top: -300px; |
| 76 | left: 50%; |
| 77 | transform: translateX(-50%); |
| 78 | width: 1200px; |
| 79 | height: 700px; |
| 80 | background: radial-gradient(circle, rgba(91, 141, 239, 0.08) 0%, transparent 60%); |
| 81 | pointer-events: none; |
| 82 | z-index: 0; |
| 83 | } |
| 84 | |
| 85 | .glass { |
| 86 | background: var(--surface); |
| 87 | border: 1px solid var(--border); |
| 88 | backdrop-filter: blur(10px); |
| 89 | } |
| 90 | |
| 91 | /* Override Tailwind utility colors for dark theme */ |
| 92 | .text-gray-800, |
| 93 | .text-gray-700 { color: var(--text) !important; } |
| 94 | .text-gray-600, |
| 95 | .text-gray-500, |
| 96 | .text-gray-400 { color: var(--text-muted) !important; } |
| 97 | .bg-white { background: var(--surface) !important; } |
| 98 | .bg-gray-50 { background: var(--surface-hover) !important; } |
| 99 | .bg-gray-100 { background: var(--surface) !important; } |
| 100 | .border-gray-200, |
| 101 | .border-gray-100 { border-color: var(--border) !important; } |
| 102 | .hover\:bg-gray-50:hover { background: var(--surface-hover) !important; } |
| 103 | .bg-brand-50 { background: rgba(91, 141, 239, 0.08) !important; } |
| 104 | .drop-shadow-lg, |
| 105 | .drop-shadow { filter: none !important; } |
| 106 | |
| 107 | kbd { |
| 108 | background: var(--surface) !important; |
| 109 | color: var(--text-muted) !important; |
| 110 | border: 1px solid var(--border) !important; |
| 111 | } |
| 112 | |
| 113 | .collection-card { |
| 114 | display: block; |
| 115 | transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; |
| 116 | cursor: pointer; |
| 117 | border: 1px solid var(--border); |
| 118 | color: inherit; |
| 119 | text-decoration: none; |
| 120 | } |
| 121 | |
| 122 | .collection-card:hover { |
| 123 | transform: translateY(-3px); |
| 124 | border-color: #3a3a40; |
| 125 | box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4); |
| 126 | } |
| 127 | |
| 128 | .slide-container { |
| 129 | box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); |
| 130 | transition: transform 0.3s ease; |
| 131 | } |
| 132 | |
| 133 | .thumbnail { |
| 134 | transition: transform 0.18s ease, box-shadow 0.18s ease; |
| 135 | cursor: pointer; |
| 136 | } |
| 137 | |
| 138 | .thumbnail:hover { |
| 139 | transform: scale(1.04); |
| 140 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); |
| 141 | } |
| 142 | |
| 143 | .nav-btn { |
| 144 | transition: transform 0.18s ease, background 0.18s ease, color 0.18s ease, border-color 0.18s ease; |
| 145 | } |
| 146 | |
| 147 | .nav-btn:hover:not(:disabled) { |
| 148 | transform: scale(1.06); |
| 149 | } |
| 150 | |
| 151 | .nav-btn:disabled { |
| 152 | opacity: 0.4; |
| 153 | cursor: not-allowed; |
| 154 | } |
| 155 | |
| 156 | /* Custom Scrollbar */ |
| 157 | ::-webkit-scrollbar { |
| 158 | width: 6px; |
| 159 | height: 6px; |
| 160 | } |
| 161 | |
| 162 | ::-webkit-scrollbar-track { |
| 163 | background: var(--darker); |
| 164 | border-radius: 3px; |
| 165 | } |
| 166 | |
| 167 | ::-webkit-scrollbar-thumb { |
| 168 | background: var(--border); |
| 169 | border-radius: 3px; |
| 170 | } |
| 171 | |
| 172 | ::-webkit-scrollbar-thumb:hover { |
| 173 | background: #3a3a40; |
| 174 | } |
| 175 | |
| 176 | .fade-in { |
| 177 | animation: fadeIn 0.3s ease-out; |
| 178 | } |
| 179 | |
| 180 | @keyframes fadeIn { |
| 181 | from { |
| 182 | opacity: 0; |
| 183 | transform: scale(0.98); |
| 184 | } |
| 185 | |
| 186 | to { |
| 187 | opacity: 1; |
| 188 | transform: scale(1); |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | .slide-enter { |
| 193 | animation: slideEnter 0.4s ease-out; |
| 194 | } |
| 195 | |
| 196 | @keyframes slideEnter { |
| 197 | from { |
| 198 | opacity: 0; |
| 199 | transform: translateY(20px); |
| 200 | } |
| 201 | |
| 202 | to { |
| 203 | opacity: 1; |
| 204 | transform: translateY(0); |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | /* Fullscreen styles */ |
| 209 | .fullscreen-overlay { |
| 210 | position: fixed; |
| 211 | top: 0; |
| 212 | left: 0; |
| 213 | width: 100vw; |
| 214 | height: 100vh; |
| 215 | background: rgba(0, 0, 0, 0.95); |
| 216 | z-index: 1000; |
| 217 | display: flex; |
| 218 | align-items: center; |
| 219 | justify-content: center; |
| 220 | } |
| 221 | |
| 222 | .fullscreen-overlay.hidden { |
| 223 | display: none; |
| 224 | } |
| 225 | |
| 226 | .fullscreen-overlay object, |
| 227 | .fullscreen-overlay img { |
| 228 | width: 95vw; |
| 229 | height: 90vh; |
| 230 | max-width: 95vw; |
| 231 | max-height: 90vh; |
| 232 | object-fit: contain; |
| 233 | } |
| 234 | |
| 235 | .progress-bar { |
| 236 | height: 4px; |
| 237 | transition: width 0.3s ease; |
| 238 | } |
| 239 | |
| 240 | .dropdown-menu { |
| 241 | max-height: min(70vh, 560px); |
| 242 | overflow-x: hidden; |
| 243 | overflow-y: auto; |
| 244 | overscroll-behavior: contain; |
| 245 | } |
| 246 | |
| 247 | /* Expanded view for slide viewer */ |
| 248 | .viewer-expanded { |
| 249 | max-width: 95vw !important; |
| 250 | } |
| 251 | |
| 252 | /* Theater mode - hide sidebar */ |
| 253 | .theater-mode .sidebar-panel { |
| 254 | display: none; |
| 255 | } |
| 256 | |
| 257 | .theater-mode .main-slide-area { |
| 258 | flex: 1; |
| 259 | max-width: 100%; |
| 260 | } |
| 261 | |
| 262 | .theater-mode .slide-container object { |
| 263 | max-height: 75vh; |
| 264 | width: auto; |
| 265 | margin: 0 auto; |
| 266 | } |
| 267 | |
| 268 | /* Better slide sizing for large screens */ |
| 269 | @media (min-width: 1920px) { |
| 270 | .viewer-expanded { |
| 271 | max-width: 90vw !important; |
| 272 | } |
| 273 | |
| 274 | .slide-container object { |
| 275 | max-height: 70vh; |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | @media (min-width: 2560px) { |
| 280 | .viewer-expanded { |
| 281 | max-width: 85vw !important; |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /* View mode toggle button */ |
| 286 | .view-mode-btn { |
| 287 | transition: background 0.18s ease, color 0.18s ease; |
| 288 | } |
| 289 | |
| 290 | .view-mode-btn.active { |
| 291 | background: var(--primary); |
| 292 | color: #fff; |
| 293 | } |
| 294 | |
| 295 | /* SVG object styling */ |
| 296 | .slide-container object { |
| 297 | width: 100%; |
| 298 | height: auto; |
| 299 | min-height: 400px; |
| 300 | } |
| 301 | </style> |
| 302 | </head> |
| 303 | |
| 304 | <body class="antialiased"> |
| 305 | <!-- Header --> |
| 306 | <header class="glass shadow-lg sticky top-0 z-50"> |
| 307 | <div class="max-w-7xl mx-auto px-4 py-4"> |
| 308 | <div class="flex items-center justify-between"> |
| 309 | <div class="flex items-center space-x-4"> |
| 310 | <a href="index.html" class="text-gray-600 hover:text-brand-600 transition-colors"> |
| 311 | <i class="fas fa-arrow-left"></i> |
| 312 | <span class="ml-2">Back to Projects</span> |
| 313 | </a> |
| 314 | </div> |
| 315 | |
| 316 | <!-- Collection Selector (shown when viewing slides) --> |
| 317 | <div id="collectionSelector" class="hidden relative"> |
| 318 | <button onclick="toggleDropdown()" |
| 319 | class="flex items-center space-x-2 px-4 py-2 bg-white rounded-lg shadow border border-gray-200 hover:border-brand-400 transition-colors"> |
| 320 | <span id="currentCollectionIcon" class="text-xl">🎴</span> |
| 321 | <span id="currentCollectionName" class="font-medium text-gray-700">Select Collection</span> |
| 322 | <i class="fas fa-chevron-down text-gray-400"></i> |
| 323 | </button> |
| 324 | <div id="dropdownMenu" |
| 325 | class="dropdown-menu hidden absolute top-full left-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-100 z-50"> |
| 326 | <!-- Dropdown items will be generated by JavaScript --> |
| 327 | </div> |
| 328 | </div> |
| 329 | |
| 330 | <div id="keyboardHints" class="hidden items-center space-x-2 text-sm"> |
| 331 | <!-- View Mode Toggle --> |
| 332 | <div class="flex items-center space-x-1 mr-4 bg-gray-100 rounded-lg p-1"> |
| 333 | <button onclick="setViewMode('normal')" id="normalModeBtn" |
| 334 | class="view-mode-btn px-3 py-1 rounded-md text-xs font-medium text-gray-600 hover:bg-white" |
| 335 | title="Normal View"> |
| 336 | <i class="fas fa-columns"></i> |
| 337 | </button> |
| 338 | <button onclick="setViewMode('theater')" id="theaterModeBtn" |
| 339 | class="view-mode-btn px-3 py-1 rounded-md text-xs font-medium text-gray-600 hover:bg-white" |
| 340 | title="Theater Mode (T)"> |
| 341 | <i class="fas fa-tv"></i> |
| 342 | </button> |
| 343 | </div> |
| 344 | <span class="text-gray-500">Shortcuts:</span> |
| 345 | <kbd class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded border">←</kbd> |
| 346 | <kbd class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded border">→</kbd> |
| 347 | <span class="text-gray-500">Navigate</span> |
| 348 | <kbd class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded border ml-2">T</kbd> |
| 349 | <span class="text-gray-500">Theater</span> |
| 350 | <kbd class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded border ml-2">F</kbd> |
| 351 | <span class="text-gray-500">Fullscreen</span> |
| 352 | <kbd class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded border ml-2">ESC</kbd> |
| 353 | <span class="text-gray-500">Back</span> |
| 354 | </div> |
| 355 | </div> |
| 356 | </div> |
| 357 | <!-- Progress bar --> |
| 358 | <div id="progressBar" class="progress-bar bg-brand-500" style="width: 0%"></div> |
| 359 | </header> |
| 360 | |
| 361 | <!-- Main Content --> |
| 362 | <main id="mainContent" class="max-w-7xl mx-auto px-4 py-8 transition-all duration-300"> |
| 363 | <!-- Library View (Collection Selection) --> |
| 364 | <div id="libraryView"> |
| 365 | <div class="text-center mb-12 pt-6"> |
| 366 | <h1 class="text-4xl font-bold mb-3" style="color: var(--text); letter-spacing: -0.025em;"> |
| 367 | Slide Gallery |
| 368 | </h1> |
| 369 | <p style="color: var(--text-muted);" class="text-lg"> |
| 370 | Select a collection to start browsing |
| 371 | </p> |
| 372 | </div> |
| 373 | |
| 374 | <!-- Collection Cards Grid --> |
| 375 | <div id="collectionsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> |
| 376 | <!-- Cards will be generated by JavaScript --> |
| 377 | </div> |
| 378 | |
| 379 | <!-- Empty State --> |
| 380 | <div id="emptyState" class="hidden text-center py-20"> |
| 381 | <h3 class="text-xl font-medium" style="color: var(--text);">No slide collections found</h3> |
| 382 | <p style="color: var(--text-muted);" class="mt-2">Please add slide configurations</p> |
| 383 | </div> |
| 384 | </div> |
| 385 | |
| 386 | <!-- Slide Viewer (Hidden by default) --> |
| 387 | <div id="viewerView" class="hidden"> |
| 388 | <!-- Title Section --> |
| 389 | <div class="text-center mb-8 slide-enter"> |
| 390 | <button onclick="backToLibrary()" |
| 391 | class="inline-flex items-center px-4 py-2 mb-4 transition-colors" |
| 392 | style="color: var(--text-muted);" |
| 393 | onmouseover="this.style.color='var(--text)'" |
| 394 | onmouseout="this.style.color='var(--text-muted)'"> |
| 395 | <i class="fas fa-th-large mr-2"></i> |
| 396 | Back to Gallery |
| 397 | </button> |
| 398 | <h1 id="viewerTitle" class="text-3xl font-bold mb-2" style="color: var(--text); letter-spacing: -0.02em;"> |
| 399 | Slide Title |
| 400 | </h1> |
| 401 | <p id="viewerDescription" style="color: var(--text-muted);"> |
| 402 | Slide Description |
| 403 | </p> |
| 404 | </div> |
| 405 | |
| 406 | <!-- Slide Viewer --> |
| 407 | <div id="slideViewerContainer" class="flex flex-col lg:flex-row gap-6 slide-enter"> |
| 408 | <!-- Main Slide --> |
| 409 | <div class="main-slide-area flex-1"> |
| 410 | <div class="glass rounded-2xl p-4 slide-container"> |
| 411 | <object id="mainSlide" data="" type="image/svg+xml" |
| 412 | class="w-full rounded-lg fade-in cursor-pointer" onclick="toggleFullscreen()"> |
| 413 | <p class="text-center text-gray-500 py-10">Loading SVG...</p> |
| 414 | </object> |
| 415 | </div> |
| 416 | |
| 417 | <!-- Navigation Controls --> |
| 418 | <div class="flex items-center justify-center mt-6 space-x-4"> |
| 419 | <button id="prevBtn" onclick="prevSlide()" |
| 420 | class="nav-btn w-12 h-12 rounded-full glass shadow-md flex items-center justify-center text-gray-600 border border-gray-200 hover:bg-brand-500 hover:text-white hover:border-brand-500"> |
| 421 | <i class="fas fa-chevron-left"></i> |
| 422 | </button> |
| 423 | |
| 424 | <div class="flex items-center space-x-2 px-6 py-2 glass rounded-full shadow-md"> |
| 425 | <span id="currentPage" class="text-brand-600 font-bold">1</span> |
| 426 | <span class="text-gray-400">/</span> |
| 427 | <span id="totalPages" class="text-gray-600">1</span> |
| 428 | </div> |
| 429 | |
| 430 | <button id="nextBtn" onclick="nextSlide()" |
| 431 | class="nav-btn w-12 h-12 rounded-full glass shadow-md flex items-center justify-center text-gray-600 border border-gray-200 hover:bg-brand-500 hover:text-white hover:border-brand-500"> |
| 432 | <i class="fas fa-chevron-right"></i> |
| 433 | </button> |
| 434 | |
| 435 | <button onclick="toggleFullscreen()" |
| 436 | class="nav-btn w-12 h-12 rounded-full glass shadow-md flex items-center justify-center text-gray-600 border border-gray-200 hover:bg-brand-500 hover:text-white hover:border-brand-500 ml-4"> |
| 437 | <i class="fas fa-expand"></i> |
| 438 | </button> |
| 439 | </div> |
| 440 | </div> |
| 441 | |
| 442 | <!-- Thumbnail Sidebar --> |
| 443 | <div class="sidebar-panel lg:w-64 xl:w-72"> |
| 444 | <div class="glass rounded-2xl p-4 shadow-lg"> |
| 445 | <h3 class="text-sm font-semibold text-gray-600 mb-3 flex items-center"> |
| 446 | <i class="fas fa-th-large mr-2 text-brand-600"></i> |
| 447 | Slide Index |
| 448 | </h3> |
| 449 | <div id="thumbnailContainer" class="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> |
| 450 | <!-- Thumbnails will be generated by JavaScript --> |
| 451 | </div> |
| 452 | </div> |
| 453 | |
| 454 | <!-- Slide Info --> |
| 455 | <div class="glass rounded-2xl p-4 shadow-lg mt-4"> |
| 456 | <h3 class="text-sm font-semibold text-gray-600 mb-3 flex items-center"> |
| 457 | <i class="fas fa-info-circle mr-2 text-brand-600"></i> |
| 458 | Current Slide |
| 459 | </h3> |
| 460 | <p id="slideTitle" class="text-gray-800 font-medium">Title</p> |
| 461 | <p id="slideDesc" class="text-sm text-gray-500 mt-1">Description</p> |
| 462 | </div> |
| 463 | </div> |
| 464 | </div> |
| 465 | |
| 466 | <!-- Slide Overview --> |
| 467 | <div class="mt-12 slide-enter"> |
| 468 | <h2 class="text-xl font-bold mb-6 flex items-center" style="color: var(--text); letter-spacing: -0.015em;"> |
| 469 | <i class="fas fa-list-ul mr-3" style="color: var(--text-muted);"></i> |
| 470 | Overview |
| 471 | </h2> |
| 472 | <div id="overviewGrid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"> |
| 473 | <!-- Overview thumbnails will be generated by JavaScript --> |
| 474 | </div> |
| 475 | </div> |
| 476 | </div> |
| 477 | </main> |
| 478 | |
| 479 | <!-- Footer --> |
| 480 | <footer class="mt-12 py-6" style="border-top: 1px solid var(--border);"> |
| 481 | <div class="max-w-7xl mx-auto px-4 text-center"> |
| 482 | <p class="text-sm" style="color: var(--text-muted);"> |
| 483 | PPT Master © 2025–2026 · Built by <a href="https://www.hehugo.com/" target="_blank" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">Hugo He</a> |
| 484 | </p> |
| 485 | </div> |
| 486 | </footer> |
| 487 | |
| 488 | <!-- Fullscreen Overlay --> |
| 489 | <div id="fullscreenOverlay" class="fullscreen-overlay hidden" onclick="toggleFullscreen()"> |
| 490 | <button class="absolute top-4 right-4 text-white text-2xl hover:text-gray-300 transition-colors"> |
| 491 | <i class="fas fa-times"></i> |
| 492 | </button> |
| 493 | <button onclick="event.stopPropagation(); prevSlide()" |
| 494 | class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"> |
| 495 | <i class="fas fa-chevron-left"></i> |
| 496 | </button> |
| 497 | <object id="fullscreenImage" data="" type="image/svg+xml" class="fade-in"> |
| 498 | <p>Loading SVG...</p> |
| 499 | </object> |
| 500 | <button onclick="event.stopPropagation(); nextSlide()" |
| 501 | class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"> |
| 502 | <i class="fas fa-chevron-right"></i> |
| 503 | </button> |
| 504 | <div class="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-black/50 rounded-full text-white text-sm"> |
| 505 | <span id="fullscreenPage">1</span> / <span id="fullscreenTotal">1</span> |
| 506 | </div> |
| 507 | </div> |
| 508 | |
| 509 | <script> |
| 510 | const EXAMPLES_DATA_URL = 'examples/examples.json'; |
| 511 | |
| 512 | // State |
| 513 | let collections = []; |
| 514 | let currentCollection = null; |
| 515 | let currentSlide = 0; |
| 516 | let isFullscreen = false; |
| 517 | let dropdownOpen = false; |
| 518 | let viewMode = 'normal'; |
| 519 | |
| 520 | // Initialize |
| 521 | document.addEventListener('DOMContentLoaded', initViewer); |
| 522 | |
| 523 | async function initViewer() { |
| 524 | try { |
| 525 | collections = await loadCollections(); |
| 526 | renderCollections(); |
| 527 | |
| 528 | // Check URL for collection parameter |
| 529 | const urlParams = new URLSearchParams(window.location.search); |
| 530 | const collectionId = urlParams.get('project'); |
| 531 | if (collectionId) { |
| 532 | const collection = collections.find(c => c.id === collectionId); |
| 533 | if (collection) { |
| 534 | openCollection(collection); |
| 535 | } |
| 536 | } |
| 537 | } catch (error) { |
| 538 | showLoadError(error); |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | async function loadCollections() { |
| 543 | const response = await fetch(EXAMPLES_DATA_URL, { cache: 'no-cache' }); |
| 544 | if (!response.ok) { |
| 545 | throw new Error(`Failed to load ${EXAMPLES_DATA_URL}: ${response.status}`); |
| 546 | } |
| 547 | const data = await response.json(); |
| 548 | return (data.projects || []).map(project => ({ |
| 549 | ...project, |
| 550 | slides: project.slides || [], |
| 551 | cover: project.cover || project.slides?.[0]?.file || '' |
| 552 | })); |
| 553 | } |
| 554 | |
| 555 | function showLoadError(error) { |
| 556 | console.error(error); |
| 557 | document.getElementById('emptyState').classList.remove('hidden'); |
| 558 | document.querySelector('#emptyState h3').textContent = 'Unable to load slide collections'; |
| 559 | document.querySelector('#emptyState p').textContent = 'Start a local web server or check examples/examples.json.'; |
| 560 | } |
| 561 | |
| 562 | // Render collection cards |
| 563 | function renderCollections() { |
| 564 | const grid = document.getElementById('collectionsGrid'); |
| 565 | |
| 566 | if (collections.length === 0) { |
| 567 | document.getElementById('emptyState').classList.remove('hidden'); |
| 568 | return; |
| 569 | } |
| 570 | |
| 571 | grid.innerHTML = collections.map(collection => { |
| 572 | const cover = collection.cover || collection.slides[0]?.file || ''; |
| 573 | const coverPath = encodeURI(`examples/${collection.folder}/${cover}`); |
| 574 | const collectionUrl = `viewer.html?project=${encodeURIComponent(collection.id)}`; |
| 575 | return ` |
| 576 | <a href="${collectionUrl}" data-collection-id="${collection.id}" class="collection-card rounded-xl overflow-hidden" style="background: var(--surface);"> |
| 577 | <div class="relative" style="aspect-ratio: 16 / 9; background: var(--darker); border-bottom: 1px solid var(--border);"> |
| 578 | <object data="${coverPath}" type="image/svg+xml" class="w-full h-full object-contain" style="pointer-events:none"> |
| 579 | <div class="w-full h-full flex items-center justify-center text-3xl" style="opacity: 0.5;">${collection.icon}</div> |
| 580 | </object> |
| 581 | <div class="absolute bottom-3 right-3 px-2.5 py-1 rounded-md text-xs font-medium" |
| 582 | style="background: rgba(13, 13, 15, 0.85); color: var(--text); border: 1px solid var(--border); backdrop-filter: blur(8px);"> |
| 583 | ${collection.slides.length} slides |
| 584 | </div> |
| 585 | </div> |
| 586 | <div class="p-5" style="border-left: 3px solid ${collection.color};"> |
| 587 | <div class="flex items-center gap-2 mb-2"> |
| 588 | <span class="text-base">${collection.icon}</span> |
| 589 | <h3 class="font-semibold text-base" style="color: var(--text); letter-spacing: -0.01em;">${collection.title}</h3> |
| 590 | </div> |
| 591 | <p class="text-sm line-clamp-2" style="color: var(--text-muted); line-height: 1.55;">${collection.description}</p> |
| 592 | </div> |
| 593 | </a> |
| 594 | `}).join(''); |
| 595 | |
| 596 | grid.querySelectorAll('.collection-card').forEach(card => { |
| 597 | card.addEventListener('click', handleCollectionCardClick); |
| 598 | }); |
| 599 | |
| 600 | renderDropdown(); |
| 601 | } |
| 602 | |
| 603 | function handleCollectionCardClick(event) { |
| 604 | if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { |
| 605 | return; |
| 606 | } |
| 607 | |
| 608 | event.preventDefault(); |
| 609 | const collection = collections.find(item => item.id === event.currentTarget.dataset.collectionId); |
| 610 | if (collection) { |
| 611 | openCollection(collection); |
| 612 | } |
| 613 | } |
| 614 | |
| 615 | // Render dropdown menu |
| 616 | function renderDropdown() { |
| 617 | const dropdown = document.getElementById('dropdownMenu'); |
| 618 | dropdown.innerHTML = collections.map(collection => ` |
| 619 | <div class="flex items-center px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors ${currentCollection?.id === collection.id ? 'bg-brand-50' : ''}" |
| 620 | onclick='switchCollection(${JSON.stringify(collection).replace(/'/g, "'")})'> |
| 621 | <span class="text-2xl mr-3">${collection.icon}</span> |
| 622 | <div class="flex-1 min-w-0"> |
| 623 | <p class="font-medium text-gray-800 truncate">${collection.title}</p> |
| 624 | <p class="text-xs text-gray-500">${collection.slides.length} slides</p> |
| 625 | </div> |
| 626 | ${currentCollection?.id === collection.id ? '<i class="fas fa-check text-brand-500"></i>' : ''} |
| 627 | </div> |
| 628 | `).join(''); |
| 629 | } |
| 630 | |
| 631 | // Toggle dropdown |
| 632 | function toggleDropdown() { |
| 633 | dropdownOpen = !dropdownOpen; |
| 634 | document.getElementById('dropdownMenu').classList.toggle('hidden', !dropdownOpen); |
| 635 | } |
| 636 | |
| 637 | // Close dropdown when clicking outside |
| 638 | document.addEventListener('click', (e) => { |
| 639 | if (!e.target.closest('#collectionSelector')) { |
| 640 | dropdownOpen = false; |
| 641 | document.getElementById('dropdownMenu').classList.add('hidden'); |
| 642 | } |
| 643 | }); |
| 644 | |
| 645 | // Open a collection |
| 646 | function openCollection(collection) { |
| 647 | currentCollection = collection; |
| 648 | currentSlide = 0; |
| 649 | |
| 650 | // Update URL |
| 651 | const url = new URL(window.location); |
| 652 | url.searchParams.set('project', collection.id); |
| 653 | window.history.pushState({}, '', url); |
| 654 | |
| 655 | // Update UI |
| 656 | document.getElementById('libraryView').classList.add('hidden'); |
| 657 | document.getElementById('viewerView').classList.remove('hidden'); |
| 658 | document.getElementById('collectionSelector').classList.remove('hidden'); |
| 659 | document.getElementById('keyboardHints').classList.remove('hidden'); |
| 660 | document.getElementById('keyboardHints').classList.add('flex'); |
| 661 | document.getElementById('mainContent').classList.add('viewer-expanded'); |
| 662 | |
| 663 | setViewMode('normal'); |
| 664 | |
| 665 | // Update header |
| 666 | document.getElementById('currentCollectionIcon').textContent = collection.icon; |
| 667 | document.getElementById('currentCollectionName').textContent = collection.title; |
| 668 | |
| 669 | // Update viewer |
| 670 | document.getElementById('viewerTitle').innerHTML = `${collection.icon} ${collection.title}`; |
| 671 | document.getElementById('viewerDescription').textContent = collection.description; |
| 672 | document.getElementById('totalPages').textContent = collection.slides.length; |
| 673 | document.getElementById('fullscreenTotal').textContent = collection.slides.length; |
| 674 | |
| 675 | // Set theme color |
| 676 | document.getElementById('progressBar').style.background = `linear-gradient(90deg, ${collection.color} 0%, ${collection.color}cc 100%)`; |
| 677 | |
| 678 | generateThumbnails(); |
| 679 | generateOverview(); |
| 680 | updateSlide(); |
| 681 | renderDropdown(); |
| 682 | |
| 683 | window.scrollTo({ top: 0, behavior: 'smooth' }); |
| 684 | } |
| 685 | |
| 686 | // Switch collection from dropdown |
| 687 | function switchCollection(collection) { |
| 688 | toggleDropdown(); |
| 689 | openCollection(collection); |
| 690 | } |
| 691 | |
| 692 | // Back to library |
| 693 | function backToLibrary() { |
| 694 | currentCollection = null; |
| 695 | |
| 696 | const url = new URL(window.location); |
| 697 | url.searchParams.delete('project'); |
| 698 | window.history.pushState({}, '', url); |
| 699 | |
| 700 | document.getElementById('libraryView').classList.remove('hidden'); |
| 701 | document.getElementById('viewerView').classList.add('hidden'); |
| 702 | document.getElementById('collectionSelector').classList.add('hidden'); |
| 703 | document.getElementById('keyboardHints').classList.add('hidden'); |
| 704 | document.getElementById('keyboardHints').classList.remove('flex'); |
| 705 | document.getElementById('progressBar').style.width = '0%'; |
| 706 | document.getElementById('mainContent').classList.remove('viewer-expanded'); |
| 707 | document.getElementById('slideViewerContainer').classList.remove('theater-mode'); |
| 708 | |
| 709 | window.scrollTo({ top: 0, behavior: 'smooth' }); |
| 710 | } |
| 711 | |
| 712 | // Set view mode |
| 713 | function setViewMode(mode) { |
| 714 | viewMode = mode; |
| 715 | const container = document.getElementById('slideViewerContainer'); |
| 716 | const normalBtn = document.getElementById('normalModeBtn'); |
| 717 | const theaterBtn = document.getElementById('theaterModeBtn'); |
| 718 | |
| 719 | if (mode === 'theater') { |
| 720 | container.classList.add('theater-mode'); |
| 721 | normalBtn.classList.remove('active'); |
| 722 | theaterBtn.classList.add('active'); |
| 723 | } else { |
| 724 | container.classList.remove('theater-mode'); |
| 725 | normalBtn.classList.add('active'); |
| 726 | theaterBtn.classList.remove('active'); |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | function toggleViewMode() { |
| 731 | setViewMode(viewMode === 'normal' ? 'theater' : 'normal'); |
| 732 | } |
| 733 | |
| 734 | // Generate thumbnail sidebar |
| 735 | function generateThumbnails() { |
| 736 | if (!currentCollection) return; |
| 737 | |
| 738 | const container = document.getElementById('thumbnailContainer'); |
| 739 | const basePath = `examples/${currentCollection.folder}/`; |
| 740 | |
| 741 | container.innerHTML = currentCollection.slides.map((slide, index) => { |
| 742 | const slidePath = encodeURI(basePath + slide.file); |
| 743 | return ` |
| 744 | <div class="thumbnail rounded-lg overflow-hidden border-2 ${index === 0 ? 'border-brand-500' : 'border-transparent'}" |
| 745 | onclick="goToSlide(${index})" |
| 746 | id="thumb-${index}"> |
| 747 | <object data="${slidePath}" type="image/svg+xml" class="w-full" style="pointer-events:none"> |
| 748 | <div class="w-full h-16 flex items-center justify-center bg-gray-100">📄</div> |
| 749 | </object> |
| 750 | <div class="px-2 py-1 bg-gray-50 text-xs text-gray-600 truncate"> |
| 751 | ${index + 1}. ${slide.title} |
| 752 | </div> |
| 753 | </div> |
| 754 | `}).join(''); |
| 755 | } |
| 756 | |
| 757 | // Generate overview grid |
| 758 | function generateOverview() { |
| 759 | if (!currentCollection) return; |
| 760 | |
| 761 | const container = document.getElementById('overviewGrid'); |
| 762 | const basePath = `examples/${currentCollection.folder}/`; |
| 763 | |
| 764 | container.innerHTML = currentCollection.slides.map((slide, index) => { |
| 765 | const slidePath = encodeURI(basePath + slide.file); |
| 766 | return ` |
| 767 | <div class="glass rounded-xl p-2 shadow-md hover:shadow-lg transition-shadow cursor-pointer" |
| 768 | onclick="goToSlide(${index}); window.scrollTo({top: 0, behavior: 'smooth'})"> |
| 769 | <object data="${slidePath}" type="image/svg+xml" class="w-full rounded-lg" style="pointer-events:none"> |
| 770 | <div class="w-full h-24 flex items-center justify-center bg-gray-100">📄</div> |
| 771 | </object> |
| 772 | <div class="mt-2 px-1"> |
| 773 | <p class="text-sm font-medium text-gray-800 truncate">${index + 1}. ${slide.title}</p> |
| 774 | <p class="text-xs text-gray-500 truncate">${slide.desc}</p> |
| 775 | </div> |
| 776 | </div> |
| 777 | `}).join(''); |
| 778 | } |
| 779 | |
| 780 | // Update slide display |
| 781 | function updateSlide() { |
| 782 | if (!currentCollection) return; |
| 783 | |
| 784 | const slide = currentCollection.slides[currentSlide]; |
| 785 | const basePath = `examples/${currentCollection.folder}/`; |
| 786 | const slidePath = encodeURI(basePath + slide.file); |
| 787 | |
| 788 | const mainSlide = document.getElementById('mainSlide'); |
| 789 | const fullscreenImage = document.getElementById('fullscreenImage'); |
| 790 | |
| 791 | // Update main slide |
| 792 | mainSlide.classList.remove('fade-in'); |
| 793 | void mainSlide.offsetWidth; |
| 794 | mainSlide.classList.add('fade-in'); |
| 795 | mainSlide.data = slidePath; |
| 796 | |
| 797 | // Update fullscreen |
| 798 | fullscreenImage.data = slidePath; |
| 799 | |
| 800 | // Update page numbers |
| 801 | document.getElementById('currentPage').textContent = currentSlide + 1; |
| 802 | document.getElementById('fullscreenPage').textContent = currentSlide + 1; |
| 803 | |
| 804 | // Update slide info |
| 805 | document.getElementById('slideTitle').textContent = slide.title; |
| 806 | document.getElementById('slideDesc').textContent = slide.desc; |
| 807 | |
| 808 | // Update progress bar |
| 809 | const progress = ((currentSlide + 1) / currentCollection.slides.length) * 100; |
| 810 | document.getElementById('progressBar').style.width = progress + '%'; |
| 811 | |
| 812 | // Update navigation buttons |
| 813 | document.getElementById('prevBtn').disabled = currentSlide === 0; |
| 814 | document.getElementById('nextBtn').disabled = currentSlide === currentCollection.slides.length - 1; |
| 815 | |
| 816 | // Update thumbnail highlights |
| 817 | currentCollection.slides.forEach((_, i) => { |
| 818 | const thumb = document.getElementById(`thumb-${i}`); |
| 819 | if (thumb) { |
| 820 | if (i === currentSlide) { |
| 821 | thumb.classList.add('border-brand-500'); |
| 822 | thumb.classList.remove('border-transparent'); |
| 823 | thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| 824 | } else { |
| 825 | thumb.classList.remove('border-brand-500'); |
| 826 | thumb.classList.add('border-transparent'); |
| 827 | } |
| 828 | } |
| 829 | }); |
| 830 | } |
| 831 | |
| 832 | // Navigation |
| 833 | function nextSlide() { |
| 834 | if (currentCollection && currentSlide < currentCollection.slides.length - 1) { |
| 835 | currentSlide++; |
| 836 | updateSlide(); |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | function prevSlide() { |
| 841 | if (currentSlide > 0) { |
| 842 | currentSlide--; |
| 843 | updateSlide(); |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | function goToSlide(index) { |
| 848 | currentSlide = index; |
| 849 | updateSlide(); |
| 850 | } |
| 851 | |
| 852 | // Fullscreen toggle |
| 853 | function toggleFullscreen() { |
| 854 | const overlay = document.getElementById('fullscreenOverlay'); |
| 855 | isFullscreen = !isFullscreen; |
| 856 | |
| 857 | if (isFullscreen) { |
| 858 | overlay.classList.remove('hidden'); |
| 859 | document.body.style.overflow = 'hidden'; |
| 860 | } else { |
| 861 | overlay.classList.add('hidden'); |
| 862 | document.body.style.overflow = ''; |
| 863 | } |
| 864 | } |
| 865 | |
| 866 | // Keyboard navigation |
| 867 | document.addEventListener('keydown', (e) => { |
| 868 | if (!currentCollection && e.key !== 'Escape') return; |
| 869 | |
| 870 | switch (e.key) { |
| 871 | case 'ArrowLeft': |
| 872 | prevSlide(); |
| 873 | break; |
| 874 | case 'ArrowRight': |
| 875 | nextSlide(); |
| 876 | break; |
| 877 | case 'Escape': |
| 878 | if (isFullscreen) { |
| 879 | toggleFullscreen(); |
| 880 | } else if (currentCollection) { |
| 881 | backToLibrary(); |
| 882 | } |
| 883 | break; |
| 884 | case 'f': |
| 885 | case 'F': |
| 886 | if (currentCollection) toggleFullscreen(); |
| 887 | break; |
| 888 | case 't': |
| 889 | case 'T': |
| 890 | if (currentCollection && !isFullscreen) toggleViewMode(); |
| 891 | break; |
| 892 | } |
| 893 | }); |
| 894 | |
| 895 | // Handle browser back/forward |
| 896 | window.addEventListener('popstate', () => { |
| 897 | const urlParams = new URLSearchParams(window.location.search); |
| 898 | const collectionId = urlParams.get('project'); |
| 899 | |
| 900 | if (collectionId) { |
| 901 | const collection = collections.find(c => c.id === collectionId); |
| 902 | if (collection) { |
| 903 | openCollection(collection); |
| 904 | } |
| 905 | } else { |
| 906 | backToLibrary(); |
| 907 | } |
| 908 | }); |
| 909 | |
| 910 | // Touch/swipe support |
| 911 | let touchStartX = 0; |
| 912 | let touchEndX = 0; |
| 913 | |
| 914 | document.addEventListener('touchstart', (e) => { |
| 915 | touchStartX = e.changedTouches[0].screenX; |
| 916 | }); |
| 917 | |
| 918 | document.addEventListener('touchend', (e) => { |
| 919 | touchEndX = e.changedTouches[0].screenX; |
| 920 | const diff = touchStartX - touchEndX; |
| 921 | |
| 922 | if (Math.abs(diff) > 50 && currentCollection) { |
| 923 | if (diff > 0) nextSlide(); |
| 924 | else prevSlide(); |
| 925 | } |
| 926 | }); |
| 927 | </script> |
| 928 | </body> |
| 929 | |
| 930 | </html> |
| 931 |