|
|
|
|
|
class TabletopState { |
|
|
constructor() { |
|
|
this.scale = 1; |
|
|
this.translateX = 0; |
|
|
this.translateY = 0; |
|
|
this.isDragging = false; |
|
|
this.startX = 0; |
|
|
this.startY = 0; |
|
|
this.selectedObject = null; |
|
|
this.isDarkMode = false; |
|
|
this.viewMode = 'chronological'; |
|
|
this.objects = []; |
|
|
this.filteredObjects = []; |
|
|
} |
|
|
|
|
|
setTransform(x, y, scale) { |
|
|
this.translateX = x; |
|
|
this.translateY = y; |
|
|
this.scale = scale; |
|
|
this.updateTransform(); |
|
|
} |
|
|
|
|
|
updateTransform() { |
|
|
const surface = document.getElementById('surface'); |
|
|
surface.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const state = new TabletopState(); |
|
|
|
|
|
|
|
|
const sampleData = { |
|
|
magazines: [ |
|
|
{ |
|
|
id: 'mag-1', |
|
|
type: 'magazine', |
|
|
title: 'Design Systems Quarterly', |
|
|
excerpt: 'Exploring the latest trends in digital design systems', |
|
|
date: '2024-01', |
|
|
color: 'blue', |
|
|
pages: [ |
|
|
{ type: 'cover', content: 'Cover Design' }, |
|
|
{ type: 'page', content: 'Understanding Design Tokens' }, |
|
|
{ type: 'page', content: 'Component Library Best Practices' }, |
|
|
{ type: 'back', content: 'Resources & Tools' } |
|
|
] |
|
|
}, |
|
|
{ |
|
|
id: 'mag-2', |
|
|
type: 'magazine', |
|
|
title: 'Creative Coding', |
|
|
excerpt: 'Art meets technology in creative programming', |
|
|
date: '2024-02', |
|
|
color: 'purple', |
|
|
pages: [ |
|
|
{ type: 'cover', content: 'Creative Coding Cover' }, |
|
|
{ type: 'page', content: 'Generative Art Techniques' }, |
|
|
{ type: 'page', content: 'Interactive Installations' }, |
|
|
{ type: 'back', content: 'Gallery Showcase' } |
|
|
] |
|
|
} |
|
|
], |
|
|
resources: [ |
|
|
{ |
|
|
id: 'res-1', |
|
|
type: 'resource', |
|
|
title: 'Development Tools', |
|
|
items: ['Code Editors', 'Version Control', 'Testing Frameworks', 'Deployment Tools'] |
|
|
}, |
|
|
{ |
|
|
id: 'res-2', |
|
|
type: 'resource', |
|
|
title: 'Design Assets', |
|
|
items: ['Icons & Illustrations', 'Color Palettes', 'Typography', 'Stock Photos'] |
|
|
} |
|
|
], |
|
|
art: [ |
|
|
{ |
|
|
id: 'art-1', |
|
|
type: 'art', |
|
|
title: 'Generative Landscape', |
|
|
image: 'http://static.photos/nature/640x360/42', |
|
|
description: 'Algorithmic nature scenes' |
|
|
}, |
|
|
{ |
|
|
id: 'art-2', |
|
|
type: 'art', |
|
|
title: 'Abstract Geometry', |
|
|
image: 'http://static.photos/abstract/640x360/133', |
|
|
description: 'Mathematical beauty in art' |
|
|
} |
|
|
], |
|
|
demos: [ |
|
|
{ |
|
|
id: 'demo-1', |
|
|
type: 'demo', |
|
|
title: 'Interactive Dashboard', |
|
|
preview: 'http://static.photos/technology/640x360/1' |
|
|
} |
|
|
] |
|
|
}; |
|
|
|
|
|
|
|
|
function generateObjects() { |
|
|
const objects = []; |
|
|
|
|
|
|
|
|
sampleData.magazines.forEach((magazine, index) => { |
|
|
objects.push({ |
|
|
...magazine, |
|
|
x: 200 + (index % 3) * 300, |
|
|
y: 200 + Math.floor(index / 3) * 400, |
|
|
rotation: Math.random() * 15 - 7.5 |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
sampleData.resources.forEach((resource, index) => { |
|
|
objects.push({ |
|
|
...resource, |
|
|
x: 600 + (index % 2) * 200, |
|
|
y: 800 + Math.floor(index / 2) * 200, |
|
|
rotation: Math.random() * 10 - 5 |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
sampleData.art.forEach((art, index) => { |
|
|
objects.push({ |
|
|
...art, |
|
|
x: 1000 + (index % 2) * 250, |
|
|
y: 300 + Math.floor(index / 2) * 300, |
|
|
rotation: 0 |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
sampleData.demos.forEach((demo, index) => { |
|
|
objects.push({ |
|
|
...demo, |
|
|
x: 1200, |
|
|
y: 600, |
|
|
rotation: 0 |
|
|
}); |
|
|
}); |
|
|
|
|
|
state.objects = objects; |
|
|
state.filteredObjects = [...objects]; |
|
|
} |
|
|
|
|
|
|
|
|
function renderObjects() { |
|
|
const container = document.getElementById('objectsContainer'); |
|
|
container.innerHTML = ''; |
|
|
|
|
|
state.filteredObjects.forEach(obj => { |
|
|
const element = createTabletopObject(obj); |
|
|
container.appendChild(element); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function createTabletopObject(obj) { |
|
|
const wrapper = document.createElement('div'); |
|
|
wrapper.className = 'tabletop-object absolute cursor-pointer'; |
|
|
wrapper.style.left = `${obj.x}px`; |
|
|
wrapper.style.top = `${obj.y}px`; |
|
|
wrapper.style.transform = `rotate(${obj.rotation}deg)`; |
|
|
wrapper.dataset.objectId = obj.id; |
|
|
wrapper.dataset.objectType = obj.type; |
|
|
wrapper.tabIndex = 0; |
|
|
wrapper.setAttribute('role', 'button'); |
|
|
wrapper.setAttribute('aria-label', `${obj.title} - ${obj.type}`); |
|
|
|
|
|
let content = ''; |
|
|
|
|
|
switch(obj.type) { |
|
|
case 'magazine': |
|
|
content = ` |
|
|
<div class="magazine-spine w-32 h-44 rounded shadow-xl relative"> |
|
|
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div> |
|
|
<div class="p-3 h-full flex flex-col justify-between"> |
|
|
<div class="text-white text-xs font-bold transform -rotate-90 origin-center whitespace-nowrap"> |
|
|
${obj.title} |
|
|
</div> |
|
|
<div class="text-white/70 text-xs"> |
|
|
${obj.date} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
break; |
|
|
|
|
|
case 'resource': |
|
|
content = ` |
|
|
<div class="file-cabinet w-24 h-32 rounded shadow-lg relative"> |
|
|
<div class="absolute inset-2 bg-gray-700 rounded"> |
|
|
<div class="p-2"> |
|
|
<div class="bg-gray-600 h-1 mb-2 rounded"></div> |
|
|
<div class="bg-gray-600 h-1 mb-2 rounded"></div> |
|
|
<div class="bg-gray-600 h-1 mb-2 rounded"></div> |
|
|
<div class="text-white text-xs mt-4">${obj.title}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
break; |
|
|
|
|
|
case 'art': |
|
|
content = ` |
|
|
<div class="art-frame shadow-2xl"> |
|
|
<div class="art-canvas w-48 h-32 relative overflow-hidden"> |
|
|
<img src="${obj.image}" alt="${obj.title}" class="w-full h-full object-cover"> |
|
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2"> |
|
|
<div class="text-white text-xs font-semibold">${obj.title}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
break; |
|
|
|
|
|
case 'demo': |
|
|
content = ` |
|
|
<div class="device-mockup shadow-2xl relative floating"> |
|
|
<div class="device-screen w-64 h-40 relative overflow-hidden"> |
|
|
<img src="${obj.preview}" alt="${obj.title}" class="w-full h-full object-cover"> |
|
|
</div> |
|
|
<div class="absolute -bottom-6 left-0 right-0 text-center"> |
|
|
<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"> |
|
|
${obj.title} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
break; |
|
|
} |
|
|
|
|
|
wrapper.innerHTML = content; |
|
|
|
|
|
|
|
|
wrapper.addEventListener('click', () => handleObjectClick(obj)); |
|
|
wrapper.addEventListener('keydown', (e) => { |
|
|
if (e.key === 'Enter' || e.key === ' ') { |
|
|
handleObjectClick(obj); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
let isDragging = false; |
|
|
let startX, startY, initialX, initialY; |
|
|
|
|
|
wrapper.addEventListener('mousedown', (e) => { |
|
|
isDragging = true; |
|
|
startX = e.clientX; |
|
|
startY = e.clientY; |
|
|
initialX = obj.x; |
|
|
initialY = obj.y; |
|
|
wrapper.classList.add('dragging'); |
|
|
e.stopPropagation(); |
|
|
}); |
|
|
|
|
|
document.addEventListener('mousemove', (e) => { |
|
|
if (!isDragging) return; |
|
|
|
|
|
const dx = (e.clientX - startX) / state.scale; |
|
|
const dy = (e.clientY - startY) / state.scale; |
|
|
|
|
|
obj.x = initialX + dx; |
|
|
obj.y = initialY + dy; |
|
|
|
|
|
wrapper.style.left = `${obj.x}px`; |
|
|
wrapper.style.top = `${obj.y}px`; |
|
|
}); |
|
|
|
|
|
document.addEventListener('mouseup', () => { |
|
|
if (isDragging) { |
|
|
isDragging = false; |
|
|
wrapper.classList.remove('dragging'); |
|
|
saveLayout(); |
|
|
} |
|
|
}); |
|
|
|
|
|
return wrapper; |
|
|
} |
|
|
|
|
|
|
|
|
function handleObjectClick(obj) { |
|
|
state.selectedObject = obj; |
|
|
|
|
|
switch(obj.type) { |
|
|
case 'magazine': |
|
|
openMagazineViewer(obj); |
|
|
break; |
|
|
case 'resource': |
|
|
openResourceViewer(obj); |
|
|
break; |
|
|
case 'art': |
|
|
openArtViewer(obj); |
|
|
break; |
|
|
case 'demo': |
|
|
openDemoViewer(obj); |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function openMagazineViewer(magazine) { |
|
|
const modal = document.getElementById('magazineModal'); |
|
|
const content = document.getElementById('magazineContent'); |
|
|
|
|
|
content.innerHTML = ` |
|
|
<magazine-viewer |
|
|
title="${magazine.title}" |
|
|
pages='${JSON.stringify(magazine.pages)}' |
|
|
color="${magazine.color}"> |
|
|
</magazine-viewer> |
|
|
`; |
|
|
|
|
|
modal.classList.remove('hidden'); |
|
|
modal.classList.add('flex'); |
|
|
} |
|
|
|
|
|
|
|
|
function openResourceViewer(resource) { |
|
|
alert(`Opening ${resource.title}\nItems: ${resource.items.join(', ')}`); |
|
|
} |
|
|
|
|
|
|
|
|
function openArtViewer(art) { |
|
|
alert(`Viewing ${art.title}\nDescription: ${art.description}`); |
|
|
} |
|
|
|
|
|
|
|
|
function openDemoViewer(demo) { |
|
|
alert(`Launching ${demo.title}`); |
|
|
} |
|
|
|
|
|
|
|
|
function setupPanZoom() { |
|
|
const tabletop = document.getElementById('tabletop'); |
|
|
let lastX = 0; |
|
|
let lastY = 0; |
|
|
|
|
|
|
|
|
tabletop.addEventListener('mousedown', (e) => { |
|
|
if (e.target === tabletop || e.target.id === 'surface') { |
|
|
state.isDragging = true; |
|
|
state.startX = e.clientX - state.translateX; |
|
|
state.startY = e.clientY - state.translateY; |
|
|
tabletop.style.cursor = 'grabbing'; |
|
|
} |
|
|
}); |
|
|
|
|
|
document.addEventListener('mousemove', (e) => { |
|
|
if (!state.isDragging) return; |
|
|
|
|
|
e.preventDefault(); |
|
|
state.translateX = e.clientX - state.startX; |
|
|
state.translateY = e.clientY - state.startY; |
|
|
state.updateTransform(); |
|
|
}); |
|
|
|
|
|
document.addEventListener('mouseup', () => { |
|
|
state.isDragging = false; |
|
|
tabletop.style.cursor = 'grab'; |
|
|
}); |
|
|
|
|
|
|
|
|
tabletop.addEventListener('wheel', (e) => { |
|
|
e.preventDefault(); |
|
|
|
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1; |
|
|
const newScale = Math.min(Math.max(state.scale * delta, 0.5), 3); |
|
|
|
|
|
const rect = tabletop.getBoundingClientRect(); |
|
|
const x = e.clientX - rect.left; |
|
|
const y = e.clientY - rect.top; |
|
|
|
|
|
state.translateX = x - (x - state.translateX) * (newScale / state.scale); |
|
|
state.translateY = y - (y - state.translateY) * (newScale / state.scale); |
|
|
state.scale = newScale; |
|
|
|
|
|
state.updateTransform(); |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('zoomIn').addEventListener('click', () => { |
|
|
state.scale = Math.min(state.scale * 1.2, 3); |
|
|
state.updateTransform(); |
|
|
}); |
|
|
|
|
|
document.getElementById('zoomOut').addEventListener('click', () => { |
|
|
state.scale = Math.max(state.scale * 0.8, 0.5); |
|
|
state.updateTransform(); |
|
|
}); |
|
|
|
|
|
document.getElementById('resetView').addEventListener('click', () => { |
|
|
state.setTransform(0, 0, 1); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function setupTouchSupport() { |
|
|
const tabletop = document.getElementById('tabletop'); |
|
|
let touchStartX, touchStartY; |
|
|
let initialDistance = 0; |
|
|
let initialScale = 1; |
|
|
|
|
|
tabletop.addEventListener('touchstart', (e) => { |
|
|
if (e.touches.length === 2) { |
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX; |
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY; |
|
|
initialDistance = Math.sqrt(dx * dx + dy * dy); |
|
|
initialScale = state.scale; |
|
|
} else if (e.touches.length === 1) { |
|
|
touchStartX = e.touches[0].clientX - state.translateX; |
|
|
touchStartY = e.touches[0].clientY - state.translateY; |
|
|
} |
|
|
}); |
|
|
|
|
|
tabletop.addEventListener('touchmove', (e) => { |
|
|
e.preventDefault(); |
|
|
|
|
|
if (e.touches.length === 2) { |
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX; |
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY; |
|
|
const distance = Math.sqrt(dx * dx + dy * dy); |
|
|
const scale = (distance / initialDistance) * initialScale; |
|
|
|
|
|
state.scale = Math.min(Math.max(scale, 0.5), 3); |
|
|
state.updateTransform(); |
|
|
} else if (e.touches.length === 1) { |
|
|
state.translateX = e.touches[0].clientX - touchStartX; |
|
|
state.translateY = e.touches[0].clientY - touchStartY; |
|
|
state.updateTransform(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function setupSearch() { |
|
|
const searchBtn = document.getElementById('searchBtn'); |
|
|
const searchModal = document.getElementById('searchModal'); |
|
|
const searchInput = document.getElementById('searchInput'); |
|
|
const searchResults = document.getElementById('searchResults'); |
|
|
|
|
|
searchBtn.addEventListener('click', () => { |
|
|
searchModal.classList.remove('hidden'); |
|
|
searchModal.classList.add('flex'); |
|
|
searchInput.focus(); |
|
|
}); |
|
|
|
|
|
searchModal.addEventListener('click', (e) => { |
|
|
if (e.target === searchModal) { |
|
|
searchModal.classList.add('hidden'); |
|
|
searchModal.classList.remove('flex'); |
|
|
} |
|
|
}); |
|
|
|
|
|
searchInput.addEventListener('input', (e) => { |
|
|
const query = e.target.value.toLowerCase(); |
|
|
|
|
|
if (query.length < 2) { |
|
|
searchResults.innerHTML = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
const results = state.objects.filter(obj => |
|
|
obj.title.toLowerCase().includes(query) || |
|
|
(obj.excerpt && obj.excerpt.toLowerCase().includes(query)) |
|
|
); |
|
|
|
|
|
searchResults.innerHTML = results.map(obj => ` |
|
|
<div class="p-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors" |
|
|
onclick="focusOnObject('${obj.id}')"> |
|
|
<div class="font-semibold">${obj.title}</div> |
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">${obj.type}</div> |
|
|
</div> |
|
|
`).join(''); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function focusOnObject(id) { |
|
|
const obj = state.objects.find(o => o.id === id); |
|
|
if (!obj) return; |
|
|
|
|
|
state.setTransform( |
|
|
-(obj.x - window.innerWidth / 4), |
|
|
-(obj.y - window.innerHeight / 4), |
|
|
1.5 |
|
|
); |
|
|
|
|
|
document.getElementById('searchModal').classList.add('hidden'); |
|
|
document.getElementById('searchModal').classList.remove('flex'); |
|
|
} |
|
|
|
|
|
|
|
|
function setupThemeToggle() { |
|
|
const themeToggle = document.getElementById('themeToggle'); |
|
|
const html = document.documentElement; |
|
|
|
|
|
|
|
|
const savedTheme = localStorage.getItem('theme'); |
|
|
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { |
|
|
html.classList.add('dark'); |
|
|
state.isDarkMode = true; |
|
|
} |
|
|
|
|
|
themeToggle.addEventListener('click', () => { |
|
|
html.classList.toggle('dark'); |
|
|
state.isDarkMode = !state.isDarkMode; |
|
|
localStorage.setItem('theme', state.isDarkMode ? 'dark' : 'light'); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function setupViewMode() { |
|
|
const viewModeBtn = document.getElementById('viewModeBtn'); |
|
|
|
|
|
viewModeBtn.addEventListener('click', () => { |
|
|
const modes = ['chronological', 'categorical', 'groups']; |
|
|
const currentIndex = modes.indexOf(state.viewMode); |
|
|
state.viewMode = modes[(currentIndex + 1) % modes.length]; |
|
|
|
|
|
renderObjects(); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('closeMagazine').addEventListener('click', () => { |
|
|
const modal = document.getElementById('magazineModal'); |
|
|
modal.classList.add('hidden'); |
|
|
modal.classList.remove('flex'); |
|
|
}); |
|
|
|
|
|
|
|
|
function setupKeyboardNav() { |
|
|
document.addEventListener('keydown', (e) => { |
|
|
if (e.key === 'Escape') { |
|
|
|
|
|
const modals = document.querySelectorAll('.modal:not(.hidden)'); |
|
|
modals.forEach(modal => { |
|
|
modal.classList.add('hidden'); |
|
|
modal.classList.remove('flex'); |
|
|
}); |
|
|
} |
|
|
|
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) { |
|
|
e.preventDefault(); |
|
|
document.getElementById('searchBtn').click(); |
|
|
} |
|
|
|
|
|
|
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { |
|
|
e.preventDefault(); |
|
|
const step = e.shiftKey ? 50 : 10; |
|
|
|
|
|
switch(e.key) { |
|
|
case 'ArrowUp': |
|
|
state.translateY += step; |
|
|
break; |
|
|
case 'ArrowDown': |
|
|
state.translateY -= step; |
|
|
break; |
|
|
case 'ArrowLeft': |
|
|
state.translateX += step; |
|
|
break; |
|
|
case 'ArrowRight': |
|
|
state.translateX -= step; |
|
|
break; |
|
|
} |
|
|
|
|
|
state.updateTransform(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function saveLayout() { |
|
|
localStorage.setItem('tabletopLayout', JSON.stringify({ |
|
|
objects: state.objects, |
|
|
viewMode: state.viewMode, |
|
|
transform: { |
|
|
x: state.translateX, |
|
|
y: state.translateY, |
|
|
scale: state.scale |
|
|
} |
|
|
})); |
|
|
} |
|
|
|
|
|
|
|
|
function loadLayout() { |
|
|
const saved = localStorage.getItem('tabletopLayout'); |
|
|
if (saved) { |
|
|
const data = JSON.parse(saved); |
|
|
state.objects = data.objects || state.objects; |
|
|
state.viewMode = data.viewMode || state.viewMode; |
|
|
|
|
|
if (data.transform) { |
|
|
state.setTransform(data.transform.x, data.transform.y, data.transform.scale); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
generateObjects(); |
|
|
loadLayout(); |
|
|
renderObjects(); |
|
|
setupPanZoom(); |
|
|
setupTouchSupport(); |
|
|
setupSearch(); |
|
|
setupThemeToggle(); |
|
|
setupViewMode(); |
|
|
setupKeyboardNav(); |
|
|
|
|
|
|
|
|
document.getElementById('magazineModal').addEventListener('click', (e) => { |
|
|
if (e.target === document.getElementById('magazineModal')) { |
|
|
document.getElementById('closeMagazine').click(); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => { |
|
|
saveLayout(); |
|
|
}); |
|
|
|
|
|
|
|
|
setInterval(saveLayout, 10000); |
|
|
|
|
|
|
|
|
if ('serviceWorker' in navigator) { |
|
|
window.addEventListener('load', () => { |
|
|
navigator.serviceWorker.register('/sw.js').catch(() => { |
|
|
console.log('Service worker registration failed'); |
|
|
}); |
|
|
}); |
|
|
} |