onfroy2's picture
Build a website where content exists as physical objects on an interactive tabletop:
4d9d3b3 verified
// Tabletop State Management
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'; // chronological, categorical, groups
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})`;
}
}
// Initialize state
const state = new TabletopState();
// Sample data for demonstration
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'
}
]
};
// Generate objects from sample data
function generateObjects() {
const objects = [];
// Generate magazines
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
});
});
// Generate resources
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
});
});
// Generate art
sampleData.art.forEach((art, index) => {
objects.push({
...art,
x: 1000 + (index % 2) * 250,
y: 300 + Math.floor(index / 2) * 300,
rotation: 0
});
});
// Generate demos
sampleData.demos.forEach((demo, index) => {
objects.push({
...demo,
x: 1200,
y: 600,
rotation: 0
});
});
state.objects = objects;
state.filteredObjects = [...objects];
}
// Render objects on tabletop
function renderObjects() {
const container = document.getElementById('objectsContainer');
container.innerHTML = '';
state.filteredObjects.forEach(obj => {
const element = createTabletopObject(obj);
container.appendChild(element);
});
}
// Create individual tabletop object
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;
// Add event listeners
wrapper.addEventListener('click', () => handleObjectClick(obj));
wrapper.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleObjectClick(obj);
}
});
// Add drag functionality
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;
}
// Handle object click
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;
}
}
// Magazine Viewer
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');
}
// Resource Viewer (placeholder)
function openResourceViewer(resource) {
alert(`Opening ${resource.title}\nItems: ${resource.items.join(', ')}`);
}
// Art Viewer (placeholder)
function openArtViewer(art) {
alert(`Viewing ${art.title}\nDescription: ${art.description}`);
}
// Demo Viewer (placeholder)
function openDemoViewer(demo) {
alert(`Launching ${demo.title}`);
}
// Pan and Zoom functionality
function setupPanZoom() {
const tabletop = document.getElementById('tabletop');
let lastX = 0;
let lastY = 0;
// Pan with mouse
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';
});
// Zoom with mouse wheel
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();
});
// Zoom controls
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);
});
}
// Touch support
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();
}
});
}
// Search functionality
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('');
});
}
// Focus on object by ID
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');
}
// Theme toggle
function setupThemeToggle() {
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
// Check for saved theme preference or default to light mode
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');
});
}
// View mode toggle
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();
});
}
// Close magazine modal
document.getElementById('closeMagazine').addEventListener('click', () => {
const modal = document.getElementById('magazineModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
});
// Keyboard navigation
function setupKeyboardNav() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close any open modals
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();
}
// Arrow keys for navigation
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();
}
});
}
// Save layout to localStorage
function saveLayout() {
localStorage.setItem('tabletopLayout', JSON.stringify({
objects: state.objects,
viewMode: state.viewMode,
transform: {
x: state.translateX,
y: state.translateY,
scale: state.scale
}
}));
}
// Load layout from localStorage
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);
}
}
}
// Initialize everything
document.addEventListener('DOMContentLoaded', () => {
generateObjects();
loadLayout();
renderObjects();
setupPanZoom();
setupTouchSupport();
setupSearch();
setupThemeToggle();
setupViewMode();
setupKeyboardNav();
// Close magazine modal on escape
document.getElementById('magazineModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('magazineModal')) {
document.getElementById('closeMagazine').click();
}
});
});
// Handle window resize
window.addEventListener('resize', () => {
saveLayout();
});
// Save layout periodically
setInterval(saveLayout, 10000);
// Offline capability
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
console.log('Service worker registration failed');
});
});
}