Better UI

This commit is contained in:
2025-10-15 11:27:58 +02:00
parent bbdb01200e
commit 65c3422610
6 changed files with 270 additions and 183 deletions

View File

@ -1,38 +1,48 @@
<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 TemplateDialog from './lib/components/ui/TemplateDialog.svelte';
import { createCsoundDerivedStores, type LogEntry, type EvalSource } from './lib/csound';
import { type CsoundProject } from './lib/project-system';
import { templateRegistry, type CsoundTemplate } from './lib/templates/template-registry';
import { createAppContext, setAppContext } from './lib/contexts/app-context';
import type { ProjectMode } from './lib/project-system/types';
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 TemplateDialog from "./lib/components/ui/TemplateDialog.svelte";
import {
createCsoundDerivedStores,
type LogEntry,
type EvalSource,
} from "./lib/csound";
import { type CsoundProject } from "./lib/project-system";
import {
templateRegistry,
type CsoundTemplate,
} from "./lib/templates/template-registry";
import { createAppContext, setAppContext } from "./lib/contexts/app-context";
import type { ProjectMode } from "./lib/project-system/types";
import {
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
PanelBottomClose,
PanelBottomOpen,
LayoutGrid,
Save,
Share2,
Activity
} from 'lucide-svelte';
Activity,
FileStack,
PanelLeftOpen,
PanelRightOpen,
} from "lucide-svelte";
const appContext = createAppContext();
setAppContext(appContext);
const { csound, projectManager, editorSettings, projectEditor, uiState, executionContext } = appContext;
const {
csound,
projectManager,
editorSettings,
projectEditor,
uiState,
executionContext,
} = appContext;
const csoundDerived = createCsoundDerivedStores(csound);
let analyserNode = $state<AnalyserNode | null>(null);
@ -45,19 +55,19 @@
const result = await projectManager.getAllProjects();
if (result.success && result.data.length === 0) {
const classicTemplate = templateRegistry.getById('classic');
const classicTemplate = templateRegistry.getById("classic");
if (classicTemplate) {
await projectManager.createProject({
title: 'Welcome',
author: 'System',
title: "Welcome",
author: "System",
content: classicTemplate.content,
tags: [],
mode: classicTemplate.mode
mode: classicTemplate.mode,
});
}
}
logsUnsubscribe = csoundDerived.logs.subscribe(logs => {
logsUnsubscribe = csoundDerived.logs.subscribe((logs) => {
interpreterLogs = logs;
});
});
@ -72,7 +82,7 @@
await csound.init();
uiState.closeAudioPermission();
} catch (error) {
console.error('Failed to initialize audio:', error);
console.error("Failed to initialize audio:", error);
}
}
@ -82,11 +92,11 @@
function handleNewEmptyFile() {
const emptyTemplate = templateRegistry.getEmpty();
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(emptyTemplate.content)
const result = projectEditor.requestSwitch(() =>
projectEditor.createNew(emptyTemplate.content),
);
if (result === 'confirm-unsaved') {
if (result === "confirm-unsaved") {
uiState.showUnsavedChangesDialog();
}
}
@ -98,11 +108,11 @@
function handleTemplateSelect(template: CsoundTemplate) {
uiState.hideTemplateDialog();
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(template.content)
const result = projectEditor.requestSwitch(() =>
projectEditor.createNew(template.content),
);
if (result === 'confirm-unsaved') {
if (result === "confirm-unsaved") {
uiState.showUnsavedChangesDialog();
}
}
@ -110,11 +120,11 @@
function handleFileSelect(project: CsoundProject | null) {
if (!project) return;
const result = projectEditor.requestSwitch(
() => projectEditor.loadProject(project)
const result = projectEditor.requestSwitch(() =>
projectEditor.loadProject(project),
);
if (result === 'confirm-unsaved') {
if (result === "confirm-unsaved") {
uiState.showUnsavedChangesDialog();
}
}
@ -123,7 +133,7 @@
try {
await executionContext.execute(code, source);
} catch (error) {
console.error('Execution error:', error);
console.error("Execution error:", error);
}
}
@ -143,14 +153,21 @@
}
}
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string; mode?: import('./lib/project-system/types').ProjectMode }) {
async function handleMetadataUpdate(
projectId: string,
updates: {
title?: string;
author?: string;
mode?: import("./lib/project-system/types").ProjectMode;
},
) {
await projectEditor.updateMetadata(updates);
}
async function handleSwitchSave() {
const result = await projectEditor.confirmSaveAndSwitch();
if (result === 'show-save-as') {
if (result === "show-save-as") {
uiState.hideUnsavedChangesDialog();
uiState.showSaveAsDialog();
} else {
@ -166,12 +183,14 @@
async function handleShare() {
if (!projectEditor.currentProjectId) return;
const result = await projectManager.exportProjectToUrl(projectEditor.currentProjectId);
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);
console.error("Failed to copy to clipboard:", err);
}
uiState.showShare(result.data);
}
@ -192,7 +211,7 @@
});
$effect(() => {
const mode = projectEditor.currentProject?.mode || 'composition';
const mode = projectEditor.currentProject?.mode || "composition";
const projectId = projectEditor.currentProjectId;
if (mode !== executionContext.mode) {
@ -208,15 +227,15 @@
const panelTabs = [
{
id: 'editor',
label: 'Editor',
content: editorTabContent
id: "editor",
label: "Editor",
content: editorTabContent,
},
{
id: 'files',
label: 'Files',
content: filesTabContent
}
id: "files",
label: "Files",
content: filesTabContent,
},
];
</script>
@ -238,46 +257,33 @@
<div class="app-container">
<TopBar title="OldBoy">
{#snippet leftActions()}
<button
class="icon-button"
onclick={handleNewFromTemplate}
title="New from template"
>
<FileStack size={18} />
</button>
<button
class="icon-button"
onclick={handleSave}
disabled={!projectEditor.hasUnsavedChanges}
title="Save {projectEditor.hasUnsavedChanges ? '(unsaved changes)' : ''}"
title="Save {projectEditor.hasUnsavedChanges
? '(unsaved changes)'
: ''}"
class:has-changes={projectEditor.hasUnsavedChanges}
>
<Save size={18} />
</button>
<button
onclick={handleShare}
class="icon-button"
disabled={!projectEditor.currentProjectId}
title="Share project"
>
<Share2 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"
@ -290,22 +296,44 @@
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
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>
{#if !uiState.sidePanelVisible}
<button
class="icon-button"
onclick={() => uiState.toggleSidePanel()}
title="Open side panel"
>
{#if uiState.sidePanelPosition === "left"}
<PanelLeftOpen size={18} />
{:else}
<PanelRightOpen size={18} />
{/if}
</button>
{/if}
</TopBar>
<div class="main-content" class:panel-bottom={uiState.sidePanelPosition === 'bottom'}>
{#if uiState.sidePanelPosition === 'left'}
<div class="main-content">
{#if uiState.sidePanelPosition === "left"}
<SidePanel
bind:visible={uiState.sidePanelVisible}
bind:position={uiState.sidePanelPosition}
tabs={panelTabs}
onClose={() => uiState.toggleSidePanel()}
onCyclePosition={() => uiState.cyclePanelPosition()}
/>
{/if}
@ -320,22 +348,13 @@
/>
</div>
{#if uiState.sidePanelPosition === 'right'}
{#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}
onClose={() => uiState.toggleSidePanel()}
onCyclePosition={() => uiState.cyclePanelPosition()}
/>
{/if}
</div>
@ -343,8 +362,8 @@
<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)}
x={typeof window !== "undefined" ? window.innerWidth / 2 - 300 : 300}
y={typeof window !== "undefined" ? window.innerHeight / 2 - 100 : 200}
width={600}
height={200}
minWidth={400}
@ -362,7 +381,9 @@
onclick={(e) => e.currentTarget.select()}
/>
</div>
<p class="share-instructions">Anyone with this link can import the project.</p>
<p class="share-instructions">
Anyone with this link can import the project.
</p>
</div>
{/snippet}
</ResizablePopup>
@ -370,10 +391,10 @@
<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)}
x={typeof window !== "undefined" ? window.innerWidth / 2 - 250 : 250}
y={typeof window !== "undefined" ? window.innerHeight / 2 - 175 : 200}
width={500}
height={350}
height={270}
minWidth={400}
minHeight={300}
closable={false}
@ -382,7 +403,6 @@
<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>
@ -393,8 +413,8 @@
<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)}
x={typeof window !== "undefined" ? window.innerWidth / 2 - 400 : 100}
y={typeof window !== "undefined" ? window.innerHeight / 2 - 300 : 100}
width={800}
height={600}
minWidth={400}
@ -402,15 +422,15 @@
noPadding={true}
>
{#snippet children()}
<AudioScope analyserNode={analyserNode} />
<AudioScope {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)}
x={typeof window !== "undefined" ? window.innerWidth / 2 - 400 : 150}
y={typeof window !== "undefined" ? window.innerHeight / 2 - 300 : 150}
width={800}
height={600}
minWidth={400}
@ -418,7 +438,7 @@
noPadding={true}
>
{#snippet children()}
<Spectrogram analyserNode={analyserNode} />
<Spectrogram {analyserNode} />
{/snippet}
</ResizablePopup>
@ -462,10 +482,6 @@
overflow: hidden;
}
.main-content.panel-bottom {
flex-direction: column;
}
.editor-area {
flex: 1;
overflow: hidden;
@ -578,4 +594,3 @@
background-color: #424ab8;
}
</style>

View File

@ -28,6 +28,8 @@
let isResizing = $state(false);
let startY = $state(0);
let startHeight = $state(0);
let previousHeight = $state(70);
let isCollapsed = $state(false);
function handleResizeStart(e: MouseEvent) {
isResizing = true;
@ -54,6 +56,17 @@
isResizing = false;
}
function toggleCollapse() {
if (isCollapsed) {
editorHeight = previousHeight;
isCollapsed = false;
} else {
previousHeight = editorHeight;
editorHeight = 96;
isCollapsed = true;
}
}
onMount(() => {
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
@ -80,7 +93,7 @@
<div class="resize-divider" onmousedown={handleResizeStart}></div>
<div class="logs-section" style="height: {100 - editorHeight}%;">
<LogPanel bind:this={logPanelRef} {logs} />
<LogPanel bind:this={logPanelRef} {logs} onHeaderClick={toggleCollapse} collapsed={isCollapsed} />
</div>
</div>

View File

@ -6,9 +6,11 @@
interface Props {
logs?: LogEntry[];
onHeaderClick?: () => void;
collapsed?: boolean;
}
let { logs = [] }: Props = $props();
let { logs = [], onHeaderClick, collapsed = false }: Props = $props();
const { csound } = getAppContext();
@ -109,48 +111,50 @@
</script>
<div class="log-panel">
<div class="log-header">
<span class="log-title">Output</span>
<div class="log-actions">
<button
class="action-button"
class:search-active={searchVisible}
onclick={toggleSearch}
title={searchVisible ? 'Hide search' : 'Show search'}
>
<Search size={14} />
</button>
<button
class="action-button"
class:pause-active={!autoFollow}
onclick={toggleAutoFollow}
title={autoFollow ? 'Pause auto-follow' : 'Resume auto-follow'}
>
{#if autoFollow}
<Pause size={14} />
{:else}
<Play size={14} />
{/if}
</button>
<button
class="action-button"
onclick={copyLogs}
disabled={logs.length === 0}
title="Copy logs"
>
<Copy size={14} />
</button>
<button
class="action-button"
onclick={clearLogs}
disabled={logs.length === 0}
title="Clear logs"
>
<Trash2 size={14} />
</button>
</div>
<div class="log-header" onclick={onHeaderClick}>
<span class="log-title">Logs</span>
{#if !collapsed}
<div class="log-actions" onclick={(e) => e.stopPropagation()}>
<button
class="action-button"
class:search-active={searchVisible}
onclick={toggleSearch}
title={searchVisible ? 'Hide search' : 'Show search'}
>
<Search size={14} />
</button>
<button
class="action-button"
class:pause-active={!autoFollow}
onclick={toggleAutoFollow}
title={autoFollow ? 'Pause auto-follow' : 'Resume auto-follow'}
>
{#if autoFollow}
<Pause size={14} />
{:else}
<Play size={14} />
{/if}
</button>
<button
class="action-button"
onclick={copyLogs}
disabled={logs.length === 0}
title="Copy logs"
>
<Copy size={14} />
</button>
<button
class="action-button"
onclick={clearLogs}
disabled={logs.length === 0}
title="Clear logs"
>
<Trash2 size={14} />
</button>
</div>
{/if}
</div>
{#if searchVisible}
{#if searchVisible && !collapsed}
<div class="search-bar">
<Search size={14} class="search-icon" />
<input
@ -161,7 +165,8 @@
/>
</div>
{/if}
<div class="log-content" bind:this={logContentEl} onscroll={handleScroll}>
{#if !collapsed}
<div class="log-content" bind:this={logContentEl} onscroll={handleScroll}>
{#if filteredLogs.length === 0 && searchQuery.trim() !== ''}
<div class="empty-state">No matching logs found...</div>
{:else if logs.length === 0}
@ -174,7 +179,8 @@
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<style>
@ -192,6 +198,12 @@
padding: 0.5rem 0.75rem;
background-color: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
cursor: pointer;
transition: background-color 0.2s;
}
.log-header:hover {
background-color: #323232;
}
.search-bar {

View File

@ -130,7 +130,7 @@
<button class="action-button" onclick={handleNewEmptyFile} title="New empty file">
<FilePlus size={18} />
</button>
<button class="action-button template-button" onclick={handleNewFromTemplate} title="New from template">
<button class="action-button" onclick={handleNewFromTemplate} title="New from template">
<FileStack size={18} />
</button>
</div>
@ -277,15 +277,6 @@
background-color: rgba(255, 255, 255, 0.1);
}
.action-button.template-button {
color: rgba(100, 200, 255, 0.8);
}
.action-button.template-button:hover {
color: rgba(100, 200, 255, 1);
background-color: rgba(100, 200, 255, 0.15);
}
.browser-content {
flex: 1;
overflow-y: auto;

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, ArrowLeftRight } from 'lucide-svelte';
interface Props {
visible?: boolean;
@ -8,6 +9,8 @@
maxWidth?: number;
position?: 'left' | 'right' | 'bottom';
tabs?: Array<{ id: string; label: string; content: any }>;
onClose?: () => void;
onCyclePosition?: () => void;
}
let {
@ -16,7 +19,9 @@
minWidth = 200,
maxWidth = 600,
position = $bindable('right'),
tabs = []
tabs = [],
onClose,
onCyclePosition
}: Props = $props();
let width = $state(initialWidth);
@ -94,6 +99,14 @@
{tab.label}
</button>
{/each}
<div class="panel-controls">
<button class="control-button" onclick={onCyclePosition} title="Switch panel side">
<ArrowLeftRight size={16} />
</button>
<button class="control-button" onclick={onClose} title="Close panel">
<X size={16} />
</button>
</div>
</div>
<div class="side-panel-content">
@ -106,6 +119,16 @@
{/each}
</div>
{:else}
<div class="panel-header-fallback">
<div class="panel-controls">
<button class="control-button" onclick={onCyclePosition} title="Switch panel side">
<ArrowLeftRight size={16} />
</button>
<button class="control-button" onclick={onClose} title="Close panel">
<X size={16} />
</button>
</div>
</div>
<div class="side-panel-content">
{@render children?.()}
</div>
@ -177,10 +200,45 @@
.tabs {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #333;
background-color: #1a1a1a;
}
.panel-controls {
display: flex;
gap: 0.25rem;
margin-left: auto;
padding-right: 0.5rem;
}
.control-button {
padding: 0.375rem;
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-button:hover {
color: rgba(255, 255, 255, 1);
background-color: rgba(255, 255, 255, 0.1);
}
.panel-header-fallback {
display: flex;
justify-content: flex-end;
align-items: center;
border-bottom: 1px solid #333;
background-color: #1a1a1a;
padding: 0.5rem 0;
}
.tab {
padding: 0.75rem 1rem;
background: none;

View File

@ -1,4 +1,4 @@
type PanelPosition = 'left' | 'right' | 'bottom';
type PanelPosition = 'left' | 'right';
export class UIState {
sidePanelVisible = $state(true);
@ -21,8 +21,6 @@ export class UIState {
cyclePanelPosition() {
if (this.sidePanelPosition === 'right') {
this.sidePanelPosition = 'left';
} else if (this.sidePanelPosition === 'left') {
this.sidePanelPosition = 'bottom';
} else {
this.sidePanelPosition = 'right';
}