Beginning of File System integration
This commit is contained in:
217
src/App.svelte
217
src/App.svelte
@ -1,9 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import TopBar from './lib/TopBar.svelte';
|
||||
import EditorWithLogs from './lib/EditorWithLogs.svelte';
|
||||
import EditorSettings from './lib/EditorSettings.svelte';
|
||||
import FileBrowser from './lib/FileBrowser.svelte';
|
||||
import SidePanel from './lib/SidePanel.svelte';
|
||||
import Popup from './lib/Popup.svelte';
|
||||
import { csound, csoundLogs, type LogEntry } from './lib/csound';
|
||||
import { projectManager, type CsoundProject } from './lib/project-system';
|
||||
import ConfirmDialog from './lib/ConfirmDialog.svelte';
|
||||
import InputDialog from './lib/InputDialog.svelte';
|
||||
import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
@ -11,17 +17,54 @@
|
||||
PanelRightOpen,
|
||||
PanelBottomClose,
|
||||
PanelBottomOpen,
|
||||
LayoutGrid
|
||||
LayoutGrid,
|
||||
Save
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let sidePanelVisible = $state(true);
|
||||
let sidePanelPosition = $state<'left' | 'right' | 'bottom'>('right');
|
||||
let popupVisible = $state(false);
|
||||
let editorValue = $state('// Start coding here...\n');
|
||||
let interpreterLogs = $state<string[]>([]);
|
||||
let editorValue = $state('<CsoundSynthesizer>\n<CsOptions>\n-odac\n</CsOptions>\n<CsInstruments>\n\nsr = 44100\nksmps = 32\nnchnls = 2\n0dbfs = 1\n\ninstr 1\n aOut oscili 0.5, 440\n outs aOut, aOut\nendin\n\n</CsInstruments>\n<CsScore>\ni 1 0 2\n</CsScore>\n</CsoundSynthesizer>\n');
|
||||
let interpreterLogs = $state<LogEntry[]>([]);
|
||||
|
||||
let sidePanelRef: SidePanel;
|
||||
let editorRef: EditorWithLogs;
|
||||
let fileBrowserRef: FileBrowser;
|
||||
let currentProjectId = $state<string | null>(null);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let isNewUnsavedBuffer = $state(false);
|
||||
let pendingProject: CsoundProject | null = null;
|
||||
let showUnsavedDialog = $state(false);
|
||||
let showSaveAsDialog = $state(false);
|
||||
|
||||
const TEMPLATE_CONTENT = '<CsoundSynthesizer>\n<CsOptions>\n-odac\n</CsOptions>\n<CsInstruments>\n\nsr = 44100\nksmps = 32\nnchnls = 2\n0dbfs = 1\n\ninstr 1\n aOut oscili 0.5, 440\n outs aOut, aOut\nendin\n\n</CsInstruments>\n<CsScore>\ni 1 0 2\n</CsScore>\n</CsoundSynthesizer>\n';
|
||||
|
||||
onMount(async () => {
|
||||
await csound.init();
|
||||
await projectManager.init();
|
||||
|
||||
const result = await projectManager.getAllProjects();
|
||||
if (result.success && result.data.length === 0) {
|
||||
await projectManager.createProject({
|
||||
title: 'Template',
|
||||
author: 'System',
|
||||
content: TEMPLATE_CONTENT,
|
||||
tags: []
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscribe = csoundLogs.subscribe(logs => {
|
||||
interpreterLogs = logs;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(async () => {
|
||||
await csound.destroy();
|
||||
});
|
||||
|
||||
function toggleSidePanel() {
|
||||
sidePanelVisible = !sidePanelVisible;
|
||||
@ -33,6 +76,121 @@
|
||||
|
||||
function handleEditorChange(value: string) {
|
||||
editorValue = value;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function handleNewFile() {
|
||||
if (hasUnsavedChanges) {
|
||||
pendingProject = null;
|
||||
showUnsavedDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
createNewBuffer();
|
||||
}
|
||||
|
||||
function createNewBuffer() {
|
||||
currentProjectId = null;
|
||||
editorValue = TEMPLATE_CONTENT;
|
||||
hasUnsavedChanges = false;
|
||||
isNewUnsavedBuffer = true;
|
||||
if (editorRef) {
|
||||
editorRef.setValue(TEMPLATE_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(project: CsoundProject | null) {
|
||||
if (hasUnsavedChanges) {
|
||||
pendingProject = project;
|
||||
showUnsavedDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (project) {
|
||||
loadProject(project);
|
||||
}
|
||||
}
|
||||
|
||||
function loadProject(project: CsoundProject) {
|
||||
currentProjectId = project.id;
|
||||
editorValue = project.content;
|
||||
hasUnsavedChanges = false;
|
||||
isNewUnsavedBuffer = false;
|
||||
if (editorRef) {
|
||||
editorRef.setValue(project.content);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentProject() {
|
||||
if (isNewUnsavedBuffer) {
|
||||
showSaveAsDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProjectId) return;
|
||||
|
||||
const result = await projectManager.updateProject({
|
||||
id: currentProjectId,
|
||||
content: editorValue
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
hasUnsavedChanges = false;
|
||||
if (fileBrowserRef) {
|
||||
fileBrowserRef.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAs(title: string) {
|
||||
const finalTitle = title.trim() || 'Untitled';
|
||||
|
||||
const result = await projectManager.createProject({
|
||||
title: finalTitle,
|
||||
author: 'Anonymous',
|
||||
content: editorValue,
|
||||
tags: []
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
currentProjectId = result.data.id;
|
||||
hasUnsavedChanges = false;
|
||||
isNewUnsavedBuffer = false;
|
||||
if (fileBrowserRef) {
|
||||
fileBrowserRef.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
|
||||
const result = await projectManager.updateProject({
|
||||
id: projectId,
|
||||
...updates
|
||||
});
|
||||
|
||||
if (result.success && fileBrowserRef) {
|
||||
fileBrowserRef.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveAndSwitch() {
|
||||
saveCurrentProject().then(() => {
|
||||
if (pendingProject) {
|
||||
loadProject(pendingProject);
|
||||
} else {
|
||||
createNewBuffer();
|
||||
}
|
||||
pendingProject = null;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDiscardAndSwitch() {
|
||||
if (pendingProject) {
|
||||
loadProject(pendingProject);
|
||||
} else {
|
||||
createNewBuffer();
|
||||
}
|
||||
pendingProject = null;
|
||||
}
|
||||
|
||||
function cyclePanelPosition() {
|
||||
@ -64,18 +222,26 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet filesTabContent()}
|
||||
<h3>File Browser</h3>
|
||||
<p>Your project files will be listed here.</p>
|
||||
<ul>
|
||||
<li>src/</li>
|
||||
<li>├── App.svelte</li>
|
||||
<li>├── main.ts</li>
|
||||
<li>└── lib/</li>
|
||||
</ul>
|
||||
<FileBrowser
|
||||
bind:this={fileBrowserRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
onNewFile={handleNewFile}
|
||||
onMetadataUpdate={handleMetadataUpdate}
|
||||
selectedProjectId={currentProjectId}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="app-container">
|
||||
<TopBar title="oldboy">
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={saveCurrentProject}
|
||||
disabled={!hasUnsavedChanges}
|
||||
title="Save {hasUnsavedChanges ? '(unsaved changes)' : ''}"
|
||||
class:has-changes={hasUnsavedChanges}
|
||||
>
|
||||
<Save size={18} />
|
||||
</button>
|
||||
<button onclick={toggleSidePanel} class="icon-button">
|
||||
{#if sidePanelVisible}
|
||||
{#if sidePanelPosition === 'left'}
|
||||
@ -154,6 +320,24 @@
|
||||
<p>You can drag it around by the header.</p>
|
||||
<p>It stays on top of everything else.</p>
|
||||
</Popup>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:visible={showUnsavedDialog}
|
||||
title="Unsaved Changes"
|
||||
message="You have unsaved changes. What would you like to do?"
|
||||
confirmLabel="Save"
|
||||
cancelLabel="Discard"
|
||||
onConfirm={handleSaveAndSwitch}
|
||||
onCancel={handleDiscardAndSwitch}
|
||||
/>
|
||||
|
||||
<InputDialog
|
||||
bind:visible={showSaveAsDialog}
|
||||
title="Save As"
|
||||
label="File name"
|
||||
placeholder="Untitled"
|
||||
onConfirm={handleSaveAs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -192,11 +376,20 @@
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
.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);
|
||||
|
||||
89
src/lib/ConfirmDialog.svelte
Normal file
89
src/lib/ConfirmDialog.svelte
Normal file
@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
visible = $bindable(false),
|
||||
title = 'Confirm',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
visible = false;
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible = false;
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:visible {title}>
|
||||
<div class="confirm-dialog">
|
||||
<p>{message}</p>
|
||||
<div class="button-group">
|
||||
<button class="button button-secondary" onclick={handleCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button class="button button-primary" onclick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.confirm-dialog p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
</style>
|
||||
@ -14,6 +14,7 @@
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { editorSettings } from './stores/editorSettings';
|
||||
import { csound } from './csound';
|
||||
|
||||
interface Props {
|
||||
initialValue?: string;
|
||||
@ -40,6 +41,19 @@
|
||||
const lineWrappingCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
const evaluateKeymap = keymap.of([
|
||||
{
|
||||
key: 'Mod-e',
|
||||
run: (view) => {
|
||||
const code = view.state.doc.toString();
|
||||
csound.evaluate(code).catch(err => {
|
||||
console.error('Evaluation error:', err);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
const settings = $editorSettings;
|
||||
|
||||
@ -75,6 +89,7 @@
|
||||
...baseExtensions,
|
||||
languageExtensions[language],
|
||||
oneDark,
|
||||
evaluateKeymap,
|
||||
lineNumbersCompartment.of(settings.showLineNumbers ? lineNumbers() : []),
|
||||
lineWrappingCompartment.of(settings.enableLineWrapping ? EditorView.lineWrapping : []),
|
||||
vimCompartment.of(settings.vimMode ? vim() : []),
|
||||
|
||||
@ -67,14 +67,6 @@
|
||||
export function setValue(value: string): void {
|
||||
editorRef?.setValue(value);
|
||||
}
|
||||
|
||||
export function addLog(message: string): void {
|
||||
logPanelRef?.addLog(message);
|
||||
}
|
||||
|
||||
export function clearLogs(): void {
|
||||
logPanelRef?.clearLogs();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-with-logs">
|
||||
|
||||
347
src/lib/FileBrowser.svelte
Normal file
347
src/lib/FileBrowser.svelte
Normal file
@ -0,0 +1,347 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { File, Plus, Trash2 } from 'lucide-svelte';
|
||||
import { projectManager, type CsoundProject } from './project-system';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import InputDialog from './InputDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
onFileSelect?: (project: CsoundProject | null) => void;
|
||||
onNewFile?: () => void;
|
||||
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string }) => void;
|
||||
selectedProjectId?: string | null;
|
||||
}
|
||||
|
||||
let { onFileSelect, onNewFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
|
||||
|
||||
let projects = $state<CsoundProject[]>([]);
|
||||
let loading = $state(true);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let projectToDelete = $state<CsoundProject | null>(null);
|
||||
|
||||
let selectedProject = $derived(
|
||||
projects.find(p => p.id === selectedProjectId) || null
|
||||
);
|
||||
|
||||
let editTitle = $state('');
|
||||
let editAuthor = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (selectedProject) {
|
||||
editTitle = selectedProject.title;
|
||||
editAuthor = selectedProject.author;
|
||||
}
|
||||
});
|
||||
|
||||
export async function refresh() {
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadProjects();
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
loading = true;
|
||||
try {
|
||||
await projectManager.init();
|
||||
const result = await projectManager.getAllProjects();
|
||||
if (result.success) {
|
||||
projects = result.data.sort((a, b) =>
|
||||
new Date(b.dateModified).getTime() - new Date(a.dateModified).getTime()
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewFile() {
|
||||
onNewFile?.();
|
||||
}
|
||||
|
||||
function selectProject(project: CsoundProject) {
|
||||
onFileSelect?.(project);
|
||||
}
|
||||
|
||||
function handleDeleteClick(project: CsoundProject, event: Event) {
|
||||
event.stopPropagation();
|
||||
projectToDelete = project;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
const result = await projectManager.deleteProject(projectToDelete.id);
|
||||
if (result.success) {
|
||||
await loadProjects();
|
||||
}
|
||||
projectToDelete = null;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function handleMetadataChange() {
|
||||
if (!selectedProject) return;
|
||||
|
||||
const hasChanges =
|
||||
editTitle !== selectedProject.title ||
|
||||
editAuthor !== selectedProject.author;
|
||||
|
||||
if (hasChanges) {
|
||||
onMetadataUpdate?.(selectedProject.id, {
|
||||
title: editTitle,
|
||||
author: editAuthor
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-browser">
|
||||
<div class="browser-header">
|
||||
<span class="browser-title">Files</span>
|
||||
<button class="action-button" onclick={handleNewFile} title="New file">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="browser-content">
|
||||
{#if loading}
|
||||
<div class="empty-state">Loading...</div>
|
||||
{:else}
|
||||
<div class="project-list">
|
||||
{#each projects as project}
|
||||
<div
|
||||
class="project-item"
|
||||
class:selected={selectedProjectId === project.id}
|
||||
onclick={() => selectProject(project)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="project-icon">
|
||||
<File size={16} />
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<div class="project-title">{project.title}</div>
|
||||
</div>
|
||||
<button
|
||||
class="delete-button"
|
||||
onclick={(e) => handleDeleteClick(project, e)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedProject}
|
||||
<div class="metadata-editor">
|
||||
<div class="metadata-header">Metadata</div>
|
||||
<div class="metadata-fields">
|
||||
<div class="field">
|
||||
<label for="file-title">Name</label>
|
||||
<input
|
||||
id="file-title"
|
||||
type="text"
|
||||
bind:value={editTitle}
|
||||
onchange={handleMetadataChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="file-author">Author</label>
|
||||
<input
|
||||
id="file-author"
|
||||
type="text"
|
||||
bind:value={editAuthor}
|
||||
onchange={handleMetadataChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:visible={showDeleteConfirm}
|
||||
title="Delete File"
|
||||
message="Are you sure you want to delete '{projectToDelete?.title}'?"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.browser-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0.25rem;
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.project-item.selected {
|
||||
background-color: rgba(100, 108, 255, 0.2);
|
||||
border-left: 3px solid #646cff;
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 0.25rem;
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.project-item:hover .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
color: rgba(255, 100, 100, 0.9);
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metadata-editor {
|
||||
border-top: 1px solid #2a2a2a;
|
||||
background-color: #1a1a1a;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metadata-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 0.5rem;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: #646cff;
|
||||
}
|
||||
</style>
|
||||
121
src/lib/InputDialog.svelte
Normal file
121
src/lib/InputDialog.svelte
Normal file
@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
title?: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
visible = $bindable(false),
|
||||
title = 'Input',
|
||||
label,
|
||||
placeholder = '',
|
||||
defaultValue = '',
|
||||
confirmLabel = 'OK',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let value = $state(defaultValue);
|
||||
|
||||
function handleConfirm() {
|
||||
visible = false;
|
||||
onConfirm(value);
|
||||
value = '';
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible = false;
|
||||
onCancel?.();
|
||||
value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:visible {title}>
|
||||
<div class="input-dialog">
|
||||
<label for="input-field">{label}</label>
|
||||
<input
|
||||
id="input-field"
|
||||
type="text"
|
||||
bind:value
|
||||
{placeholder}
|
||||
/>
|
||||
<div class="button-group">
|
||||
<button class="button button-secondary" onclick={handleCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button class="button button-primary" onclick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.input-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
</style>
|
||||
@ -1,28 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Copy, Trash2 } from 'lucide-svelte';
|
||||
import { csound } from './csound';
|
||||
import type { LogEntry } from './csound';
|
||||
|
||||
interface Props {
|
||||
logs?: string[];
|
||||
logs?: LogEntry[];
|
||||
}
|
||||
|
||||
let { logs = [] }: Props = $props();
|
||||
|
||||
export function addLog(message: string) {
|
||||
logs.push(message);
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
}
|
||||
|
||||
export function clearLogs() {
|
||||
logs = [];
|
||||
async function copyLogs() {
|
||||
if (logs.length === 0) return;
|
||||
|
||||
const logText = logs
|
||||
.map(log => `[${formatTime(log.timestamp)}] ${log.type.toUpperCase()}: ${log.message}`)
|
||||
.join('\n');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy logs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
csound.clearLogs();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<span class="log-title">Output</span>
|
||||
<div class="log-actions">
|
||||
<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>
|
||||
<div class="log-content">
|
||||
{#if logs.length === 0}
|
||||
<div class="empty-state">No output yet...</div>
|
||||
{:else}
|
||||
{#each logs as log, i}
|
||||
<div class="log-entry">
|
||||
<span class="log-index">{i + 1}</span>
|
||||
<span class="log-message">{log}</span>
|
||||
<div class="log-entry" class:error={log.type === 'error'}>
|
||||
<span class="log-timestamp">{formatTime(log.timestamp)}</span>
|
||||
<span class="log-message">{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@ -37,6 +82,50 @@
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0.25rem;
|
||||
background-color: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@ -63,10 +152,19 @@
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.log-index {
|
||||
.log-entry.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border-left: 3px solid rgba(255, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.log-entry.error .log-message {
|
||||
color: rgba(255, 100, 100, 0.95);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
min-width: 6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
|
||||
78
src/lib/Modal.svelte
Normal file
78
src/lib/Modal.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { visible = $bindable(false), title = '', onClose, children }: Props = $props();
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible = false;
|
||||
onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick} role="dialog" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
{#if title}
|
||||
<div class="modal-header">
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="modal-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
125
src/lib/csound/engine.ts
Normal file
125
src/lib/csound/engine.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { Csound } from '@csound/browser';
|
||||
|
||||
export interface CsoundEngineOptions {
|
||||
onMessage?: (message: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export class CsoundEngine {
|
||||
private csound: Csound | null = null;
|
||||
private initialized = false;
|
||||
private running = false;
|
||||
private options: CsoundEngineOptions;
|
||||
|
||||
constructor(options: CsoundEngineOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
this.csound = await Csound();
|
||||
|
||||
this.csound.on('message', (message: string) => {
|
||||
this.options.onMessage?.(message);
|
||||
});
|
||||
|
||||
await this.csound.setOption('-odac');
|
||||
|
||||
this.initialized = true;
|
||||
this.log('Csound initialized successfully');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Csound';
|
||||
this.error(errorMsg);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateCode(code: string): Promise<void> {
|
||||
if (!this.initialized || !this.csound) {
|
||||
throw new Error('Csound not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.running) {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
this.log('Resetting Csound...');
|
||||
await this.csound.reset();
|
||||
|
||||
this.log('Setting audio output...');
|
||||
await this.csound.setOption('-odac');
|
||||
|
||||
const orcMatch = code.match(/<CsInstruments>([\s\S]*?)<\/CsInstruments>/);
|
||||
const scoMatch = code.match(/<CsScore>([\s\S]*?)<\/CsScore>/);
|
||||
|
||||
if (!orcMatch || !scoMatch) {
|
||||
throw new Error('Invalid CSD format. Must contain <CsInstruments> and <CsScore> sections.');
|
||||
}
|
||||
|
||||
const orc = orcMatch[1].trim();
|
||||
const sco = scoMatch[1].trim();
|
||||
|
||||
this.log('Compiling orchestra...');
|
||||
await this.csound.compileOrc(orc);
|
||||
|
||||
this.log('Reading score...');
|
||||
await this.csound.readScore(sco);
|
||||
|
||||
this.log('Starting...');
|
||||
await this.csound.start();
|
||||
|
||||
this.log('Performing...');
|
||||
this.running = true;
|
||||
await this.csound.perform();
|
||||
|
||||
this.log('Performance complete');
|
||||
this.running = false;
|
||||
} catch (error) {
|
||||
this.running = false;
|
||||
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
|
||||
this.error(errorMsg);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.csound || !this.running) return;
|
||||
|
||||
try {
|
||||
await this.csound.stop();
|
||||
await this.csound.reset();
|
||||
this.running = false;
|
||||
this.log('Stopped');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop';
|
||||
this.error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
this.options.onMessage?.(message);
|
||||
}
|
||||
|
||||
private error(message: string): void {
|
||||
this.options.onError?.(message);
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
if (this.running) {
|
||||
await this.stop();
|
||||
}
|
||||
this.csound = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
4
src/lib/csound/index.ts
Normal file
4
src/lib/csound/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { CsoundEngine } from './engine';
|
||||
export type { CsoundEngineOptions } from './engine';
|
||||
export { csound, csoundLogs, csoundInitialized, csoundRunning } from './store';
|
||||
export type { LogEntry } from './store';
|
||||
109
src/lib/csound/store.ts
Normal file
109
src/lib/csound/store.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { CsoundEngine } from './engine';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
message: string;
|
||||
type: 'info' | 'error';
|
||||
}
|
||||
|
||||
interface CsoundState {
|
||||
initialized: boolean;
|
||||
running: boolean;
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
function createCsoundStore() {
|
||||
const initialState: CsoundState = {
|
||||
initialized: false,
|
||||
running: false,
|
||||
logs: []
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<CsoundState>(initialState);
|
||||
|
||||
let engine: CsoundEngine | null = null;
|
||||
|
||||
function addLog(message: string, type: 'info' | 'error' = 'info') {
|
||||
update(state => ({
|
||||
...state,
|
||||
logs: [...state.logs, { timestamp: new Date(), message, type }]
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
async init() {
|
||||
if (engine) return;
|
||||
|
||||
try {
|
||||
engine = new CsoundEngine({
|
||||
onMessage: (msg) => addLog(msg, 'info'),
|
||||
onError: (err) => addLog(err, 'error')
|
||||
});
|
||||
|
||||
await engine.init();
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
initialized: true
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize';
|
||||
addLog(errorMsg, 'error');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async evaluate(code: string) {
|
||||
if (!engine) {
|
||||
throw new Error('CSound engine not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
update(state => ({ ...state, running: true }));
|
||||
await engine.evaluateCode(code);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
|
||||
addLog(errorMsg, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
update(state => ({ ...state, running: false }));
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
await engine.stop();
|
||||
update(state => ({ ...state, running: false }));
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Stop failed';
|
||||
addLog(errorMsg, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
clearLogs() {
|
||||
update(state => ({
|
||||
...state,
|
||||
logs: []
|
||||
}));
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
if (engine) {
|
||||
await engine.destroy();
|
||||
engine = null;
|
||||
}
|
||||
set(initialState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const csound = createCsoundStore();
|
||||
|
||||
export const csoundLogs = derived(csound, $csound => $csound.logs);
|
||||
export const csoundInitialized = derived(csound, $csound => $csound.initialized);
|
||||
export const csoundRunning = derived(csound, $csound => $csound.running);
|
||||
Reference in New Issue
Block a user