Files
oldboy/src/App.svelte
2025-10-15 01:52:43 +02:00

537 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import TopBar from './lib/components/ui/TopBar.svelte';
import EditorWithLogs from './lib/components/editor/EditorWithLogs.svelte';
import EditorSettings from './lib/components/editor/EditorSettings.svelte';
import FileBrowser from './lib/components/ui/FileBrowser.svelte';
import SidePanel from './lib/components/ui/SidePanel.svelte';
import ResizablePopup from './lib/components/ui/ResizablePopup.svelte';
import AudioScope from './lib/components/audio/AudioScope.svelte';
import Spectrogram from './lib/components/audio/Spectrogram.svelte';
import ConfirmDialog from './lib/components/ui/ConfirmDialog.svelte';
import InputDialog from './lib/components/ui/InputDialog.svelte';
import { createCsoundDerivedStores, type LogEntry } from './lib/csound';
import { type CsoundProject } from './lib/project-system';
import { DEFAULT_CSOUND_TEMPLATE } from './lib/config/templates';
import { createAppContext, setAppContext } from './lib/contexts/app-context';
import {
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
PanelBottomClose,
PanelBottomOpen,
LayoutGrid,
Save,
Share2,
Activity
} from 'lucide-svelte';
const appContext = createAppContext();
setAppContext(appContext);
const { csound, projectManager, editorSettings, projectEditor, uiState } = appContext;
const csoundDerived = createCsoundDerivedStores(csound);
let analyserNode = $state<AnalyserNode | null>(null);
let interpreterLogs = $state<LogEntry[]>([]);
let logsUnsubscribe: (() => void) | undefined;
onMount(async () => {
await projectManager.init();
const result = await projectManager.getAllProjects();
if (result.success && result.data.length === 0) {
await projectManager.createProject({
title: 'Template',
author: 'System',
content: DEFAULT_CSOUND_TEMPLATE,
tags: []
});
}
logsUnsubscribe = csoundDerived.logs.subscribe(logs => {
interpreterLogs = logs;
});
});
onDestroy(async () => {
logsUnsubscribe?.();
await csound.destroy();
});
async function handleEnableAudio() {
try {
await csound.init();
uiState.closeAudioPermission();
} catch (error) {
console.error('Failed to initialize audio:', error);
}
}
function handleEditorChange(value: string) {
projectEditor.setContent(value);
}
function handleNewFile() {
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE)
);
if (result === 'confirm-unsaved') {
uiState.showUnsavedChangesDialog();
}
}
function handleFileSelect(project: CsoundProject | null) {
if (!project) return;
const result = projectEditor.requestSwitch(
() => projectEditor.loadProject(project)
);
if (result === 'confirm-unsaved') {
uiState.showUnsavedChangesDialog();
}
}
async function handleExecute(code: string) {
try {
await csound.evaluate(code);
} catch (error) {
console.error('Execution error:', error);
}
}
async function handleSave() {
if (projectEditor.isNewUnsavedBuffer) {
uiState.showSaveAsDialog();
return;
}
await projectEditor.save();
}
async function handleSaveAs(title: string) {
const success = await projectEditor.saveAs(title);
if (success) {
uiState.hideSaveAsDialog();
}
}
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
await projectEditor.updateMetadata(updates);
}
async function handleSwitchSave() {
const result = await projectEditor.confirmSaveAndSwitch();
if (result === 'show-save-as') {
uiState.hideUnsavedChangesDialog();
uiState.showSaveAsDialog();
} else {
uiState.hideUnsavedChangesDialog();
}
}
function handleSwitchDiscard() {
projectEditor.confirmDiscardAndSwitch();
uiState.hideUnsavedChangesDialog();
}
async function handleShare() {
if (!projectEditor.currentProjectId) return;
const result = await projectManager.exportProjectToUrl(projectEditor.currentProjectId);
if (result.success) {
try {
await navigator.clipboard.writeText(result.data);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
uiState.showShare(result.data);
}
}
$effect(() => {
if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) {
analyserNode = csound.getAnalyserNode();
const unsubscribe = csound.onAnalyserNodeCreated((node) => {
analyserNode = node;
});
return unsubscribe;
} else {
analyserNode = null;
}
});
const panelTabs = [
{
id: 'editor',
label: 'Editor',
content: editorTabContent
},
{
id: 'files',
label: 'Files',
content: filesTabContent
}
];
</script>
{#snippet editorTabContent()}
<EditorSettings />
{/snippet}
{#snippet filesTabContent()}
<FileBrowser
{projectManager}
onFileSelect={handleFileSelect}
onNewFile={handleNewFile}
onMetadataUpdate={handleMetadataUpdate}
selectedProjectId={projectEditor.currentProjectId}
/>
{/snippet}
<div class="app-container">
<TopBar title="OldBoy">
{#snippet leftActions()}
<button
class="icon-button"
onclick={handleSave}
disabled={!projectEditor.hasUnsavedChanges}
title="Save {projectEditor.hasUnsavedChanges ? '(unsaved changes)' : ''}"
class:has-changes={projectEditor.hasUnsavedChanges}
>
<Save size={18} />
</button>
{/snippet}
<button onclick={() => uiState.toggleSidePanel()} class="icon-button">
{#if uiState.sidePanelVisible}
{#if uiState.sidePanelPosition === 'left'}
<PanelLeftClose size={18} />
{:else if uiState.sidePanelPosition === 'right'}
<PanelRightClose size={18} />
{:else}
<PanelBottomClose size={18} />
{/if}
{:else}
{#if uiState.sidePanelPosition === 'left'}
<PanelLeftOpen size={18} />
{:else if uiState.sidePanelPosition === 'right'}
<PanelRightOpen size={18} />
{:else}
<PanelBottomOpen size={18} />
{/if}
{/if}
</button>
<button onclick={() => uiState.cyclePanelPosition()} class="icon-button" title="Change panel position">
<LayoutGrid size={18} />
</button>
<button
onclick={handleShare}
class="icon-button"
disabled={!projectEditor.currentProjectId}
title="Share project"
>
<Share2 size={18} />
</button>
<button
onclick={() => uiState.openScope()}
class="icon-button"
title="Open audio scope"
>
<Activity size={18} />
</button>
<button
onclick={() => uiState.openSpectrogram()}
class="icon-button"
title="Open spectrogram"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
</button>
</TopBar>
<div class="main-content" class:panel-bottom={uiState.sidePanelPosition === 'bottom'}>
{#if uiState.sidePanelPosition === 'left'}
<SidePanel
bind:visible={uiState.sidePanelVisible}
bind:position={uiState.sidePanelPosition}
tabs={panelTabs}
/>
{/if}
<div class="editor-area">
<EditorWithLogs
value={projectEditor.content}
language="javascript"
onChange={handleEditorChange}
onExecute={handleExecute}
logs={interpreterLogs}
{editorSettings}
/>
</div>
{#if uiState.sidePanelPosition === 'right'}
<SidePanel
bind:visible={uiState.sidePanelVisible}
bind:position={uiState.sidePanelPosition}
tabs={panelTabs}
/>
{/if}
{#if uiState.sidePanelPosition === 'bottom'}
<SidePanel
bind:visible={uiState.sidePanelVisible}
bind:position={uiState.sidePanelPosition}
initialWidth={200}
minWidth={100}
maxWidth={400}
tabs={panelTabs}
/>
{/if}
</div>
<ResizablePopup
bind:visible={uiState.sharePopupVisible}
title="Share Project"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 300 : 300)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 100 : 200)}
width={600}
height={200}
minWidth={400}
minHeight={150}
>
{#snippet children()}
<div class="share-content">
<p>Link copied to clipboard!</p>
<div class="share-url-container">
<input
type="text"
readonly
value={uiState.shareUrl}
class="share-url-input"
onclick={(e) => e.currentTarget.select()}
/>
</div>
<p class="share-instructions">Anyone with this link can import the project.</p>
</div>
{/snippet}
</ResizablePopup>
<ResizablePopup
visible={uiState.audioPermissionPopupVisible}
title="Audio Permission Required"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 175 : 200)}
width={500}
height={350}
minWidth={400}
minHeight={300}
closable={false}
>
{#snippet children()}
<div class="audio-permission-content">
<h3>Enable Audio Context</h3>
<p>OldBoy needs permission to use audio playback.</p>
<p>Click the button below to enable audio and start using the application.</p>
<button class="enable-audio-button" onclick={handleEnableAudio}>
Enable Audio
</button>
</div>
{/snippet}
</ResizablePopup>
<ResizablePopup
bind:visible={uiState.scopePopupVisible}
title="Audio Scope"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 100)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 100)}
width={800}
height={600}
minWidth={400}
minHeight={300}
noPadding={true}
>
{#snippet children()}
<AudioScope analyserNode={analyserNode} />
{/snippet}
</ResizablePopup>
<ResizablePopup
bind:visible={uiState.spectrogramPopupVisible}
title="Spectrogram"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 150)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 150)}
width={800}
height={600}
minWidth={400}
minHeight={300}
noPadding={true}
>
{#snippet children()}
<Spectrogram analyserNode={analyserNode} />
{/snippet}
</ResizablePopup>
<ConfirmDialog
bind:visible={uiState.unsavedChangesDialogVisible}
title="Unsaved Changes"
message="You have unsaved changes. What would you like to do?"
confirmLabel="Save"
cancelLabel="Discard"
onConfirm={handleSwitchSave}
onCancel={handleSwitchDiscard}
/>
<InputDialog
bind:visible={uiState.saveAsDialogVisible}
title="Save As"
label="File name"
placeholder="Untitled"
onConfirm={handleSaveAs}
/>
</div>
<style>
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.main-content.panel-bottom {
flex-direction: column;
}
.editor-area {
flex: 1;
overflow: hidden;
}
.icon-button {
padding: 0.5rem;
background-color: #333;
color: rgba(255, 255, 255, 0.87);
border: 1px solid #555;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.icon-button:hover:not(:disabled) {
background-color: #444;
border-color: #646cff;
}
.icon-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.icon-button.has-changes {
color: #646cff;
}
h3 {
margin-top: 0;
color: rgba(255, 255, 255, 0.87);
}
p {
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
.share-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.share-url-container {
width: 100%;
}
.share-url-input {
width: 100%;
padding: 0.75rem;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
color: rgba(255, 255, 255, 0.87);
font-size: 0.875rem;
font-family: monospace;
outline: none;
box-sizing: border-box;
}
.share-url-input:focus {
border-color: #646cff;
}
.share-instructions {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.audio-permission-content {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
text-align: center;
padding: 1rem;
}
.audio-permission-content h3 {
margin: 0;
font-size: 1.25rem;
}
.audio-permission-content p {
margin: 0;
font-size: 0.95rem;
}
.enable-audio-button {
margin-top: 1rem;
padding: 0.75rem 2rem;
background-color: #646cff;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.enable-audio-button:hover {
background-color: #535bdb;
}
.enable-audio-button:active {
background-color: #424ab8;
}
</style>