Cosmetic
This commit is contained in:
202
src/App.svelte
202
src/App.svelte
@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import TopBar from "./lib/components/ui/TopBar.svelte";
|
import MenuBar from "./lib/components/ui/MenuBar.svelte";
|
||||||
|
import MenuItem from "./lib/components/ui/MenuItem.svelte";
|
||||||
|
import MenuAction from "./lib/components/ui/MenuAction.svelte";
|
||||||
|
import AboutDialog from "./lib/components/ui/AboutDialog.svelte";
|
||||||
import EditorWithLogs from "./lib/components/editor/EditorWithLogs.svelte";
|
import EditorWithLogs from "./lib/components/editor/EditorWithLogs.svelte";
|
||||||
import EditorSettings from "./lib/components/editor/EditorSettings.svelte";
|
import EditorSettings from "./lib/components/editor/EditorSettings.svelte";
|
||||||
import FileBrowser from "./lib/components/ui/FileBrowser.svelte";
|
import FileBrowser from "./lib/components/ui/FileBrowser.svelte";
|
||||||
@ -261,93 +264,76 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<TopBar title="OldBoy">
|
<MenuBar onLogoClick={() => uiState.showAboutDialog()}>
|
||||||
{#snippet leftActions()}
|
<MenuItem label="File">
|
||||||
<button
|
<MenuAction
|
||||||
onclick={handleStop}
|
label="New File"
|
||||||
class="icon-button stop-button"
|
icon={FileStack}
|
||||||
disabled={!$running}
|
|
||||||
title="Stop audio (Ctrl+.)"
|
|
||||||
>
|
|
||||||
<CircleStop size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="icon-button"
|
|
||||||
onclick={handleNewFile}
|
onclick={handleNewFile}
|
||||||
title="New file"
|
/>
|
||||||
>
|
<MenuAction
|
||||||
<FileStack size={18} />
|
label="Save"
|
||||||
</button>
|
icon={Save}
|
||||||
<button
|
shortcut={navigator.platform.includes("Mac") ? "Cmd+S" : "Ctrl+S"}
|
||||||
class="icon-button"
|
|
||||||
onclick={handleSave}
|
|
||||||
disabled={!editorState.hasUnsavedChanges}
|
disabled={!editorState.hasUnsavedChanges}
|
||||||
title="Save (Ctrl+S){editorState.hasUnsavedChanges
|
onclick={handleSave}
|
||||||
? ' - unsaved changes'
|
/>
|
||||||
: ''}"
|
<MenuAction
|
||||||
class:has-changes={editorState.hasUnsavedChanges}
|
label="Share..."
|
||||||
>
|
icon={Share2}
|
||||||
<Save size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
class="icon-button"
|
|
||||||
disabled={!editorState.currentFileId}
|
disabled={!editorState.currentFileId}
|
||||||
title="Share current file"
|
onclick={handleShare}
|
||||||
>
|
/>
|
||||||
<Share2 size={18} />
|
</MenuItem>
|
||||||
</button>
|
|
||||||
{/snippet}
|
<MenuItem label="Audio">
|
||||||
<button
|
<MenuAction
|
||||||
onclick={handleExecuteFile}
|
label="Run File"
|
||||||
class="icon-button evaluate-button"
|
icon={Play}
|
||||||
disabled={!$initialized}
|
shortcut={navigator.platform.includes("Mac") ? "Cmd+R" : "Ctrl+R"}
|
||||||
class:is-running={$running}
|
disabled={!$initialized}
|
||||||
title="Run File (Cmd-R)"
|
onclick={handleExecuteFile}
|
||||||
>
|
/>
|
||||||
<Play size={18} />
|
<MenuAction
|
||||||
</button>
|
label="Stop"
|
||||||
<button
|
icon={CircleStop}
|
||||||
onclick={() => uiState.toggleScope()}
|
shortcut={navigator.platform.includes("Mac") ? "Cmd+." : "Ctrl+."}
|
||||||
class="icon-button"
|
disabled={!$running}
|
||||||
title="Toggle audio scope"
|
onclick={handleStop}
|
||||||
>
|
/>
|
||||||
<Activity size={18} />
|
</MenuItem>
|
||||||
</button>
|
|
||||||
<button
|
<MenuItem label="Visuals">
|
||||||
onclick={() => uiState.toggleSpectrogram()}
|
<MenuAction
|
||||||
class="icon-button"
|
label="Toggle Side Panel"
|
||||||
title="Toggle spectrogram"
|
icon={uiState.sidePanelPosition === "left" ? PanelLeftOpen : PanelRightOpen}
|
||||||
>
|
|
||||||
<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()}
|
onclick={() => uiState.toggleSidePanel()}
|
||||||
title="Open side panel"
|
/>
|
||||||
>
|
<MenuAction
|
||||||
{#if uiState.sidePanelPosition === "left"}
|
label="Audio Scope"
|
||||||
<PanelLeftOpen size={18} />
|
icon={Activity}
|
||||||
{:else}
|
onclick={() => uiState.toggleScope()}
|
||||||
<PanelRightOpen size={18} />
|
/>
|
||||||
{/if}
|
<MenuAction
|
||||||
</button>
|
label="Spectrogram"
|
||||||
{/if}
|
onclick={() => uiState.toggleSpectrogram()}
|
||||||
</TopBar>
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem label="Options">
|
||||||
|
<MenuAction
|
||||||
|
label="Editor Settings"
|
||||||
|
onclick={() => {}}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem label="About">
|
||||||
|
<MenuAction
|
||||||
|
label="About OldBoy..."
|
||||||
|
onclick={() => uiState.showAboutDialog()}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuBar>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
{#if uiState.sidePanelPosition === "left"}
|
{#if uiState.sidePanelPosition === "left"}
|
||||||
@ -473,6 +459,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ResizablePopup>
|
</ResizablePopup>
|
||||||
|
|
||||||
|
<AboutDialog
|
||||||
|
isOpen={uiState.aboutDialogVisible}
|
||||||
|
onClose={() => uiState.hideAboutDialog()}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -495,49 +486,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
|
||||||
padding: var(--space-sm);
|
|
||||||
background-color: var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button:hover:not(:disabled) {
|
|
||||||
background-color: var(--button-hover);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button.has-changes {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button.stop-button:not(:disabled) {
|
|
||||||
color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button.stop-button:hover:not(:disabled) {
|
|
||||||
color: var(--danger-hover);
|
|
||||||
border-color: var(--danger-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button.evaluate-button.is-running {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button.evaluate-button.is-running:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|||||||
165
src/lib/components/ui/AboutDialog.svelte
Normal file
165
src/lib/components/ui/AboutDialog.svelte
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ResizablePopup from './ResizablePopup.svelte';
|
||||||
|
import { Code2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const version = '0.0.0';
|
||||||
|
const author = 'Raphaël Maurice Forment';
|
||||||
|
const license = 'MIT License';
|
||||||
|
const website = 'https://raphaelforment.fr';
|
||||||
|
const livecoding = 'https://livecoding.fr';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<ResizablePopup
|
||||||
|
bind:visible={isOpen}
|
||||||
|
title="About OldBoy"
|
||||||
|
x={typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250}
|
||||||
|
y={typeof window !== 'undefined' ? window.innerHeight / 2 - 250 : 250}
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
minWidth={500}
|
||||||
|
minHeight={500}
|
||||||
|
closable={true}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-logo">
|
||||||
|
<Code2 size={64} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-info">
|
||||||
|
<h1 class="app-name">OldBoy</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
A modern web-based Csound editor and development environment. Write,
|
||||||
|
execute, and experiment with Csound code directly in your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="author-section">
|
||||||
|
<div class="links">
|
||||||
|
<a href={website} target="_blank" rel="noopener noreferrer">
|
||||||
|
raphaelforment.fr
|
||||||
|
</a>
|
||||||
|
<a href={livecoding} target="_blank" rel="noopener noreferrer">
|
||||||
|
livecoding.fr
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2025 {author} · {license}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ResizablePopup>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about-content {
|
||||||
|
padding: 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description p {
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-section h3 {
|
||||||
|
font-size: var(--font-base);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
src/lib/components/ui/MenuAction.svelte
Normal file
82
src/lib/components/ui/MenuAction.svelte
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
icon?: ComponentType;
|
||||||
|
shortcut?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, icon, shortcut, disabled = false, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menu-action"
|
||||||
|
class:disabled
|
||||||
|
{disabled}
|
||||||
|
onclick={() => !disabled && onclick?.()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="menu-action-content">
|
||||||
|
{#if icon}
|
||||||
|
{@const Icon = icon}
|
||||||
|
<Icon size={16} />
|
||||||
|
{/if}
|
||||||
|
<span class="menu-action-label">{label}</span>
|
||||||
|
</span>
|
||||||
|
{#if shortcut}
|
||||||
|
<span class="menu-action-shortcut">{shortcut}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
text-align: left;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action:hover:not(.disabled) {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action-shortcut {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action:hover:not(.disabled) .menu-action-shortcut {
|
||||||
|
color: var(--accent-text);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
src/lib/components/ui/MenuBar.svelte
Normal file
65
src/lib/components/ui/MenuBar.svelte
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { Code2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
onLogoClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, onLogoClick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="menu-bar">
|
||||||
|
<div class="menu-bar-left">
|
||||||
|
<button class="logo-button" onclick={onLogoClick} type="button">
|
||||||
|
<Code2 size={20} />
|
||||||
|
<span class="logo-text">OldBoy</span>
|
||||||
|
</button>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 48px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
height: 100%;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-button:hover {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
src/lib/components/ui/MenuDropdown.svelte
Normal file
79
src/lib/components/ui/MenuDropdown.svelte
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
anchorElement?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onClose, children, anchorElement }: Props = $props();
|
||||||
|
let dropdownElement: HTMLDivElement;
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
dropdownElement &&
|
||||||
|
!dropdownElement.contains(event.target as Node) &&
|
||||||
|
anchorElement &&
|
||||||
|
!anchorElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscapeKey(event: KeyboardEvent) {
|
||||||
|
if (isOpen && event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('click', handleClickOutside, true);
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside, true);
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function positionDropdown() {
|
||||||
|
if (!dropdownElement || !anchorElement) return;
|
||||||
|
|
||||||
|
const anchorRect = anchorElement.getBoundingClientRect();
|
||||||
|
dropdownElement.style.top = `${anchorRect.bottom}px`;
|
||||||
|
dropdownElement.style.left = `${anchorRect.left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen && anchorElement) {
|
||||||
|
positionDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="menu-dropdown" bind:this={dropdownElement}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/lib/components/ui/MenuItem.svelte
Normal file
67
src/lib/components/ui/MenuItem.svelte
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import MenuDropdown from './MenuDropdown.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, children }: Props = $props();
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let buttonElement: HTMLButtonElement;
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="menu-item">
|
||||||
|
<button
|
||||||
|
bind:this={buttonElement}
|
||||||
|
class="menu-item-trigger"
|
||||||
|
class:active={isOpen}
|
||||||
|
onclick={toggleMenu}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<MenuDropdown {isOpen} onClose={closeMenu} anchorElement={buttonElement}>
|
||||||
|
<div onclick={closeMenu}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</MenuDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-trigger {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-trigger:hover,
|
||||||
|
.menu-item-trigger.active {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-trigger.active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -126,7 +127,9 @@
|
|||||||
<div class="popup-header" onmousedown={handleDragStart}>
|
<div class="popup-header" onmousedown={handleDragStart}>
|
||||||
<span class="popup-title">{title}</span>
|
<span class="popup-title">{title}</span>
|
||||||
{#if closable}
|
{#if closable}
|
||||||
<button class="close-button" onclick={handleClose}>×</button>
|
<button class="close-button" onclick={handleClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="popup-content" class:no-padding={noPadding}>
|
<div class="popup-content" class:no-padding={noPadding}>
|
||||||
@ -159,11 +162,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-md) var(--space-lg);
|
padding: 8px 16px;
|
||||||
background-color: var(--surface-color);
|
background-color: var(--surface-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
@ -173,23 +177,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
background: none;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
padding: 4px;
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: color var(--transition-base);
|
transition: all var(--transition-fast);
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.close-button:hover {
|
||||||
color: var(--text-color);
|
background-color: var(--accent-color);
|
||||||
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-content {
|
.popup-content {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export class UIState {
|
|||||||
sharePopupVisible = $state(false);
|
sharePopupVisible = $state(false);
|
||||||
audioPermissionPopupVisible = $state(true);
|
audioPermissionPopupVisible = $state(true);
|
||||||
unsavedChangesDialogVisible = $state(false);
|
unsavedChangesDialogVisible = $state(false);
|
||||||
|
aboutDialogVisible = $state(false);
|
||||||
|
|
||||||
shareUrl = $state('');
|
shareUrl = $state('');
|
||||||
|
|
||||||
@ -56,4 +57,12 @@ export class UIState {
|
|||||||
hideUnsavedChangesDialog() {
|
hideUnsavedChangesDialog() {
|
||||||
this.unsavedChangesDialogVisible = false;
|
this.unsavedChangesDialogVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAboutDialog() {
|
||||||
|
this.aboutDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideAboutDialog() {
|
||||||
|
this.aboutDialogVisible = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user