Files
bitfielder/src/components/TopBar.tsx
2025-07-14 21:58:04 +02:00

351 lines
11 KiB
TypeScript

import { useStore } from '@nanostores/react';
import { useState } from 'react';
import { $appSettings, updateAppSettings } from '../stores/appSettings';
import { VALUE_MODES, ValueMode, RENDER_MODES } from '../utils/constants';
import {
uiState,
toggleMobileMenu,
showHelp,
toggleUI,
toggleShaderLibrary,
} from '../stores/ui';
import { $shader, setShaderCode } from '../stores/shader';
import { $input } from '../stores/input';
import { FakeShader } from '../FakeShader';
import { useAudio } from '../hooks/useAudio';
import { LucideIcon } from '../hooks/useLucideIcon';
function getValueModeLabel(mode: string): string {
// Automatically generate human-readable labels from mode names
return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, ' ');
}
function getRenderModeLabel(mode: string): string {
// Automatically generate human-readable labels from render mode names
return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, ' ');
}
export function TopBar() {
const settings = useStore($appSettings);
const ui = useStore(uiState);
const shader = useStore($shader);
const input = useStore($input);
const { setupAudio, disableAudio } = useAudio();
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
const handleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleRandom = () => {
const randomCode = FakeShader.generateRandomCode();
setShaderCode(randomCode);
};
const handleShare = () => {
const shareData = {
code: shader.code,
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
hueShift: settings.hueShift,
};
try {
const encoded = btoa(JSON.stringify(shareData));
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
console.log('Sharing URL:', url);
console.log('Share data:', shareData);
navigator.clipboard
.writeText(url)
.then(() => {
console.log('URL copied to clipboard');
setShareStatus('copied');
setTimeout(() => setShareStatus('idle'), 2000);
})
.catch(() => {
console.log('Copy failed');
setShareStatus('failed');
setTimeout(() => setShareStatus('idle'), 2000);
});
} catch (error) {
console.error('Failed to create share URL:', error);
}
};
const handleExportPNG = () => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
if (canvas) {
const link = document.createElement('a');
link.download = `bitfielder-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
};
const handleAudioToggle = async () => {
if (input.audioEnabled) {
disableAudio();
} else {
await setupAudio();
}
};
return (
<div
id="topbar"
className={ui.uiVisible ? '' : 'hidden'}>
<div className="title">Bitfielder</div>
<div className="controls">
<div className="controls-desktop">
<select
value={settings.resolution}
onChange={(e) =>
updateAppSettings({ resolution: parseInt(e.target.value) })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
marginRight: '10px',
}}
>
<option value="1">Full (1x)</option>
<option value="2">Half (2x)</option>
<option value="4">Quarter (4x)</option>
<option value="8">Eighth (8x)</option>
<option value="16">Sixteenth (16x)</option>
<option value="32">Thirty-second (32x)</option>
</select>
<select
value={settings.fps}
onChange={(e) =>
updateAppSettings({ fps: parseInt(e.target.value) })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
marginRight: '10px',
}}
>
<option value="15">15 FPS</option>
<option value="30">30 FPS</option>
<option value="60">60 FPS</option>
</select>
<select
value={settings.valueMode}
onChange={(e) =>
updateAppSettings({ valueMode: e.target.value as ValueMode })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
marginRight: '10px',
}}
>
{VALUE_MODES.map((mode) => (
<option key={mode} value={mode}>
{getValueModeLabel(mode)}
</option>
))}
</select>
<select
value={settings.renderMode}
onChange={(e) =>
updateAppSettings({ renderMode: e.target.value })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
marginRight: '10px',
}}
>
{RENDER_MODES.map((mode) => (
<option key={mode} value={mode}>
{getRenderModeLabel(mode)}
</option>
))}
</select>
<input
type="range"
min="0"
max="360"
value={settings.hueShift ?? 0}
onChange={(e) =>
updateAppSettings({ hueShift: parseInt(e.target.value) })
}
style={{ width: '80px', verticalAlign: 'middle', marginRight: '10px' }}
/>
<input
type="range"
min="10"
max="100"
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
onChange={(e) =>
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
}
style={{ width: '80px', verticalAlign: 'middle', marginRight: '10px' }}
/>
<button id="help-btn" onClick={showHelp}>
<LucideIcon name="help" />
</button>
<button id="fullscreen-btn" onClick={handleFullscreen}>
<LucideIcon name="fullscreen" />
</button>
<button id="hide-ui-btn" onClick={toggleUI}>
<LucideIcon name="hide" />
</button>
<button id="random-btn" onClick={handleRandom}>
<LucideIcon name="random" />
</button>
<button id="audio-btn" onClick={handleAudioToggle}>
<LucideIcon
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
/>
</button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
id="share-btn"
onClick={handleShare}
style={{
background: shareStatus === 'copied' ? '#4CAF50' : shareStatus === 'failed' ? '#f44336' : undefined,
color: shareStatus !== 'idle' ? '#fff' : undefined,
transition: 'all 0.3s ease'
}}
>
<LucideIcon name={shareStatus === 'copied' ? 'check' : 'share'} />
</button>
{shareStatus === 'copied' && (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: '#333',
color: '#fff',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap',
zIndex: 1000,
marginTop: '5px',
animation: 'fadeIn 0.3s ease'
}}
>
Link copied to clipboard!
<div
style={{
position: 'absolute',
top: '-5px',
left: '50%',
transform: 'translateX(-50%)',
width: '0',
height: '0',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: '5px solid #333'
}}
/>
</div>
)}
{shareStatus === 'failed' && (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: '#f44336',
color: '#fff',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap',
zIndex: 1000,
marginTop: '5px',
animation: 'fadeIn 0.3s ease'
}}
>
Failed to copy link
<div
style={{
position: 'absolute',
top: '-5px',
left: '50%',
transform: 'translateX(-50%)',
width: '0',
height: '0',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: '5px solid #f44336'
}}
/>
</div>
)}
</div>
<button id="export-png-btn" onClick={handleExportPNG}>
<LucideIcon name="export" />
</button>
</div>
<div className="controls-mobile">
<button
className="icon-button"
aria-label="Shader Library"
onClick={toggleShaderLibrary}
>
<LucideIcon name="library" />
</button>
<button
className="icon-button"
aria-label="Random"
onClick={handleRandom}
>
<LucideIcon name="random" />
</button>
<button
className="icon-button"
aria-label="Hide UI"
onClick={toggleUI}
>
<LucideIcon name="hide" />
</button>
<button
className="icon-button"
aria-label="Menu"
onClick={toggleMobileMenu}
>
<LucideIcon name={ui.mobileMenuOpen ? 'close' : 'menu'} />
</button>
</div>
</div>
</div>
);
}