351 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|