onfroy2/tabletop-gallery-explorer
0
1// Tabletop State Management2class TabletopState {3 constructor() {4 this.scale = 1;5 this.translateX = 0;6 this.translateY = 0;7 this.isDragging = false;8 this.startX = 0;9 this.startY = 0;10 this.selectedObject = null;11 this.isDarkMode = false;12 this.viewMode = 'chronological'; // chronological, categorical, groups13 this.objects = [];14 this.filteredObjects = [];15 }16 17 setTransform(x, y, scale) {18 this.translateX = x;19 this.translateY = y;20 this.scale = scale;21 this.updateTransform();22 }23 24 updateTransform() {25 const surface = document.getElementById('surface');26 surface.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;27 }28}29 30// Initialize state31const state = new TabletopState();32 33// Sample data for demonstration34const sampleData = {35 magazines: [36 {37 id: 'mag-1',38 type: 'magazine',39 title: 'Design Systems Quarterly',40 excerpt: 'Exploring the latest trends in digital design systems',41 date: '2024-01',42 color: 'blue',43 pages: [44 { type: 'cover', content: 'Cover Design' },45 { type: 'page', content: 'Understanding Design Tokens' },46 { type: 'page', content: 'Component Library Best Practices' },47 { type: 'back', content: 'Resources & Tools' }48 ]49 },50 {51 id: 'mag-2',52 type: 'magazine',53 title: 'Creative Coding',54 excerpt: 'Art meets technology in creative programming',55 date: '2024-02',56 color: 'purple',57 pages: [58 { type: 'cover', content: 'Creative Coding Cover' },59 { type: 'page', content: 'Generative Art Techniques' },60 { type: 'page', content: 'Interactive Installations' },61 { type: 'back', content: 'Gallery Showcase' }62 ]63 }64 ],65 resources: [66 {67 id: 'res-1',68 type: 'resource',69 title: 'Development Tools',70 items: ['Code Editors', 'Version Control', 'Testing Frameworks', 'Deployment Tools']71 },72 {73 id: 'res-2',74 type: 'resource',75 title: 'Design Assets',76 items: ['Icons & Illustrations', 'Color Palettes', 'Typography', 'Stock Photos']77 }78 ],79 art: [80 {81 id: 'art-1',82 type: 'art',83 title: 'Generative Landscape',84 image: 'http://static.photos/nature/640x360/42',85 description: 'Algorithmic nature scenes'86 },87 {88 id: 'art-2',89 type: 'art',90 title: 'Abstract Geometry',91 image: 'http://static.photos/abstract/640x360/133',92 description: 'Mathematical beauty in art'93 }94 ],95 demos: [96 {97 id: 'demo-1',98 type: 'demo',99 title: 'Interactive Dashboard',100 preview: 'http://static.photos/technology/640x360/1'101 }102 ]103};104 105// Generate objects from sample data106function generateObjects() {107 const objects = [];108 109 // Generate magazines110 sampleData.magazines.forEach((magazine, index) => {111 objects.push({112 ...magazine,113 x: 200 + (index % 3) * 300,114 y: 200 + Math.floor(index / 3) * 400,115 rotation: Math.random() * 15 - 7.5116 });117 });118 119 // Generate resources120 sampleData.resources.forEach((resource, index) => {121 objects.push({122 ...resource,123 x: 600 + (index % 2) * 200,124 y: 800 + Math.floor(index / 2) * 200,125 rotation: Math.random() * 10 - 5126 });127 });128 129 // Generate art130 sampleData.art.forEach((art, index) => {131 objects.push({132 ...art,133 x: 1000 + (index % 2) * 250,134 y: 300 + Math.floor(index / 2) * 300,135 rotation: 0136 });137 });138 139 // Generate demos140 sampleData.demos.forEach((demo, index) => {141 objects.push({142 ...demo,143 x: 1200,144 y: 600,145 rotation: 0146 });147 });148 149 state.objects = objects;150 state.filteredObjects = [...objects];151}152 153// Render objects on tabletop154function renderObjects() {155 const container = document.getElementById('objectsContainer');156 container.innerHTML = '';157 158 state.filteredObjects.forEach(obj => {159 const element = createTabletopObject(obj);160 container.appendChild(element);161 });162}163 164// Create individual tabletop object165function createTabletopObject(obj) {166 const wrapper = document.createElement('div');167 wrapper.className = 'tabletop-object absolute cursor-pointer';168 wrapper.style.left = `${obj.x}px`;169 wrapper.style.top = `${obj.y}px`;170 wrapper.style.transform = `rotate(${obj.rotation}deg)`;171 wrapper.dataset.objectId = obj.id;172 wrapper.dataset.objectType = obj.type;173 wrapper.tabIndex = 0;174 wrapper.setAttribute('role', 'button');175 wrapper.setAttribute('aria-label', `${obj.title} - ${obj.type}`);176 177 let content = '';178 179 switch(obj.type) {180 case 'magazine':181 content = `182 <div class="magazine-spine w-32 h-44 rounded shadow-xl relative">183 <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>184 <div class="p-3 h-full flex flex-col justify-between">185 <div class="text-white text-xs font-bold transform -rotate-90 origin-center whitespace-nowrap">186 ${obj.title}187 </div>188 <div class="text-white/70 text-xs">189 ${obj.date}190 </div>191 </div>192 </div>193 `;194 break;195 196 case 'resource':197 content = `198 <div class="file-cabinet w-24 h-32 rounded shadow-lg relative">199 <div class="absolute inset-2 bg-gray-700 rounded">200 <div class="p-2">201 <div class="bg-gray-600 h-1 mb-2 rounded"></div>202 <div class="bg-gray-600 h-1 mb-2 rounded"></div>203 <div class="bg-gray-600 h-1 mb-2 rounded"></div>204 <div class="text-white text-xs mt-4">${obj.title}</div>205 </div>206 </div>207 </div>208 `;209 break;210 211 case 'art':212 content = `213 <div class="art-frame shadow-2xl">214 <div class="art-canvas w-48 h-32 relative overflow-hidden">215 <img src="${obj.image}" alt="${obj.title}" class="w-full h-full object-cover">216 <div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">217 <div class="text-white text-xs font-semibold">${obj.title}</div>218 </div>219 </div>220 </div>221 `;222 break;223 224 case 'demo':225 content = `226 <div class="device-mockup shadow-2xl relative floating">227 <div class="device-screen w-64 h-40 relative overflow-hidden">228 <img src="${obj.preview}" alt="${obj.title}" class="w-full h-full object-cover">229 </div>230 <div class="absolute -bottom-6 left-0 right-0 text-center">231 <div class="text-gray-700 dark:text-gray-300 text-sm bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-full px-3 py-1 inline-block">232 ${obj.title}233 </div>234 </div>235 </div>236 `;237 break;238 }239 240 wrapper.innerHTML = content;241 242 // Add event listeners243 wrapper.addEventListener('click', () => handleObjectClick(obj));244 wrapper.addEventListener('keydown', (e) => {245 if (e.key === 'Enter' || e.key === ' ') {246 handleObjectClick(obj);247 }248 });249 250 // Add drag functionality251 let isDragging = false;252 let startX, startY, initialX, initialY;253 254 wrapper.addEventListener('mousedown', (e) => {255 isDragging = true;256 startX = e.clientX;257 startY = e.clientY;258 initialX = obj.x;259 initialY = obj.y;260 wrapper.classList.add('dragging');261 e.stopPropagation();262 });263 264 document.addEventListener('mousemove', (e) => {265 if (!isDragging) return;266 267 const dx = (e.clientX - startX) / state.scale;268 const dy = (e.clientY - startY) / state.scale;269 270 obj.x = initialX + dx;271 obj.y = initialY + dy;272 273 wrapper.style.left = `${obj.x}px`;274 wrapper.style.top = `${obj.y}px`;275 });276 277 document.addEventListener('mouseup', () => {278 if (isDragging) {279 isDragging = false;280 wrapper.classList.remove('dragging');281 saveLayout();282 }283 });284 285 return wrapper;286}287 288// Handle object click289function handleObjectClick(obj) {290 state.selectedObject = obj;291 292 switch(obj.type) {293 case 'magazine':294 openMagazineViewer(obj);295 break;296 case 'resource':297 openResourceViewer(obj);298 break;299 case 'art':300 openArtViewer(obj);301 break;302 case 'demo':303 openDemoViewer(obj);304 break;305 }306}307 308// Magazine Viewer309function openMagazineViewer(magazine) {310 const modal = document.getElementById('magazineModal');311 const content = document.getElementById('magazineContent');312 313 content.innerHTML = `314 <magazine-viewer 315 title="${magazine.title}"316 pages='${JSON.stringify(magazine.pages)}'317 color="${magazine.color}">318 </magazine-viewer>319 `;320 321 modal.classList.remove('hidden');322 modal.classList.add('flex');323}324 325// Resource Viewer (placeholder)326function openResourceViewer(resource) {327 alert(`Opening ${resource.title}\nItems: ${resource.items.join(', ')}`);328}329 330// Art Viewer (placeholder)331function openArtViewer(art) {332 alert(`Viewing ${art.title}\nDescription: ${art.description}`);333}334 335// Demo Viewer (placeholder)336function openDemoViewer(demo) {337 alert(`Launching ${demo.title}`);338}339 340// Pan and Zoom functionality341function setupPanZoom() {342 const tabletop = document.getElementById('tabletop');343 let lastX = 0;344 let lastY = 0;345 346 // Pan with mouse347 tabletop.addEventListener('mousedown', (e) => {348 if (e.target === tabletop || e.target.id === 'surface') {349 state.isDragging = true;350 state.startX = e.clientX - state.translateX;351 state.startY = e.clientY - state.translateY;352 tabletop.style.cursor = 'grabbing';353 }354 });355 356 document.addEventListener('mousemove', (e) => {357 if (!state.isDragging) return;358 359 e.preventDefault();360 state.translateX = e.clientX - state.startX;361 state.translateY = e.clientY - state.startY;362 state.updateTransform();363 });364 365 document.addEventListener('mouseup', () => {366 state.isDragging = false;367 tabletop.style.cursor = 'grab';368 });369 370 // Zoom with mouse wheel371 tabletop.addEventListener('wheel', (e) => {372 e.preventDefault();373 374 const delta = e.deltaY > 0 ? 0.9 : 1.1;375 const newScale = Math.min(Math.max(state.scale * delta, 0.5), 3);376 377 const rect = tabletop.getBoundingClientRect();378 const x = e.clientX - rect.left;379 const y = e.clientY - rect.top;380 381 state.translateX = x - (x - state.translateX) * (newScale / state.scale);382 state.translateY = y - (y - state.translateY) * (newScale / state.scale);383 state.scale = newScale;384 385 state.updateTransform();386 });387 388 // Zoom controls389 document.getElementById('zoomIn').addEventListener('click', () => {390 state.scale = Math.min(state.scale * 1.2, 3);391 state.updateTransform();392 });393 394 document.getElementById('zoomOut').addEventListener('click', () => {395 state.scale = Math.max(state.scale * 0.8, 0.5);396 state.updateTransform();397 });398 399 document.getElementById('resetView').addEventListener('click', () => {400 state.setTransform(0, 0, 1);401 });402}403 404// Touch support405function setupTouchSupport() {406 const tabletop = document.getElementById('tabletop');407 let touchStartX, touchStartY;408 let initialDistance = 0;409 let initialScale = 1;410 411 tabletop.addEventListener('touchstart', (e) => {412 if (e.touches.length === 2) {413 const dx = e.touches[0].clientX - e.touches[1].clientX;414 const dy = e.touches[0].clientY - e.touches[1].clientY;415 initialDistance = Math.sqrt(dx * dx + dy * dy);416 initialScale = state.scale;417 } else if (e.touches.length === 1) {418 touchStartX = e.touches[0].clientX - state.translateX;419 touchStartY = e.touches[0].clientY - state.translateY;420 }421 });422 423 tabletop.addEventListener('touchmove', (e) => {424 e.preventDefault();425 426 if (e.touches.length === 2) {427 const dx = e.touches[0].clientX - e.touches[1].clientX;428 const dy = e.touches[0].clientY - e.touches[1].clientY;429 const distance = Math.sqrt(dx * dx + dy * dy);430 const scale = (distance / initialDistance) * initialScale;431 432 state.scale = Math.min(Math.max(scale, 0.5), 3);433 state.updateTransform();434 } else if (e.touches.length === 1) {435 state.translateX = e.touches[0].clientX - touchStartX;436 state.translateY = e.touches[0].clientY - touchStartY;437 state.updateTransform();438 }439 });440}441 442// Search functionality443function setupSearch() {444 const searchBtn = document.getElementById('searchBtn');445 const searchModal = document.getElementById('searchModal');446 const searchInput = document.getElementById('searchInput');447 const searchResults = document.getElementById('searchResults');448 449 searchBtn.addEventListener('click', () => {450 searchModal.classList.remove('hidden');451 searchModal.classList.add('flex');452 searchInput.focus();453 });454 455 searchModal.addEventListener('click', (e) => {456 if (e.target === searchModal) {457 searchModal.classList.add('hidden');458 searchModal.classList.remove('flex');459 }460 });461 462 searchInput.addEventListener('input', (e) => {463 const query = e.target.value.toLowerCase();464 465 if (query.length < 2) {466 searchResults.innerHTML = '';467 return;468 }469 470 const results = state.objects.filter(obj => 471 obj.title.toLowerCase().includes(query) || 472 (obj.excerpt && obj.excerpt.toLowerCase().includes(query))473 );474 475 searchResults.innerHTML = results.map(obj => `476 <div class="p-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors"477 onclick="focusOnObject('${obj.id}')">478 <div class="font-semibold">${obj.title}</div>479 <div class="text-sm text-gray-500 dark:text-gray-400">${obj.type}</div>480 </div>481 `).join('');482 });483}484 485// Focus on object by ID486function focusOnObject(id) {487 const obj = state.objects.find(o => o.id === id);488 if (!obj) return;489 490 state.setTransform(491 -(obj.x - window.innerWidth / 4),492 -(obj.y - window.innerHeight / 4),493 1.5494 );495 496 document.getElementById('searchModal').classList.add('hidden');497 document.getElementById('searchModal').classList.remove('flex');498}499 500// Theme toggle501function setupThemeToggle() {502 const themeToggle = document.getElementById('themeToggle');503 const html = document.documentElement;504 505 // Check for saved theme preference or default to light mode506 const savedTheme = localStorage.getItem('theme');507 if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {508 html.classList.add('dark');509 state.isDarkMode = true;510 }511 512 themeToggle.addEventListener('click', () => {513 html.classList.toggle('dark');514 state.isDarkMode = !state.isDarkMode;515 localStorage.setItem('theme', state.isDarkMode ? 'dark' : 'light');516 });517}518 519// View mode toggle520function setupViewMode() {521 const viewModeBtn = document.getElementById('viewModeBtn');522 523 viewModeBtn.addEventListener('click', () => {524 const modes = ['chronological', 'categorical', 'groups'];525 const currentIndex = modes.indexOf(state.viewMode);526 state.viewMode = modes[(currentIndex + 1) % modes.length];527 528 renderObjects();529 });530}531 532// Close magazine modal533document.getElementById('closeMagazine').addEventListener('click', () => {534 const modal = document.getElementById('magazineModal');535 modal.classList.add('hidden');536 modal.classList.remove('flex');537});538 539// Keyboard navigation540function setupKeyboardNav() {541 document.addEventListener('keydown', (e) => {542 if (e.key === 'Escape') {543 // Close any open modals544 const modals = document.querySelectorAll('.modal:not(.hidden)');545 modals.forEach(modal => {546 modal.classList.add('hidden');547 modal.classList.remove('flex');548 });549 }550 551 if (e.key === '/' && !e.ctrlKey && !e.metaKey) {552 e.preventDefault();553 document.getElementById('searchBtn').click();554 }555 556 // Arrow keys for navigation557 if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {558 e.preventDefault();559 const step = e.shiftKey ? 50 : 10;560 561 switch(e.key) {562 case 'ArrowUp':563 state.translateY += step;564 break;565 case 'ArrowDown':566 state.translateY -= step;567 break;568 case 'ArrowLeft':569 state.translateX += step;570 break;571 case 'ArrowRight':572 state.translateX -= step;573 break;574 }575 576 state.updateTransform();577 }578 });579}580 581// Save layout to localStorage582function saveLayout() {583 localStorage.setItem('tabletopLayout', JSON.stringify({584 objects: state.objects,585 viewMode: state.viewMode,586 transform: {587 x: state.translateX,588 y: state.translateY,589 scale: state.scale590 }591 }));592}593 594// Load layout from localStorage595function loadLayout() {596 const saved = localStorage.getItem('tabletopLayout');597 if (saved) {598 const data = JSON.parse(saved);599 state.objects = data.objects || state.objects;600 state.viewMode = data.viewMode || state.viewMode;601 602 if (data.transform) {603 state.setTransform(data.transform.x, data.transform.y, data.transform.scale);604 }605 }606}607 608// Initialize everything609document.addEventListener('DOMContentLoaded', () => {610 generateObjects();611 loadLayout();612 renderObjects();613 setupPanZoom();614 setupTouchSupport();615 setupSearch();616 setupThemeToggle();617 setupViewMode();618 setupKeyboardNav();619 620 // Close magazine modal on escape621 document.getElementById('magazineModal').addEventListener('click', (e) => {622 if (e.target === document.getElementById('magazineModal')) {623 document.getElementById('closeMagazine').click();624 }625 });626});627 628// Handle window resize629window.addEventListener('resize', () => {630 saveLayout();631});632 633// Save layout periodically634setInterval(saveLayout, 10000);635 636// Offline capability637if ('serviceWorker' in navigator) {638 window.addEventListener('load', () => {639 navigator.serviceWorker.register('/sw.js').catch(() => {640 console.log('Service worker registration failed');641 });642 });643}