Installing basic UI

This commit is contained in:
2025-10-14 21:58:12 +02:00
parent 8bceae2396
commit ece02406bd
12 changed files with 1320 additions and 78 deletions

158
src/lib/Editor.svelte Normal file
View File

@ -0,0 +1,158 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, minimalSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, keymap } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { oneDark } from '@codemirror/theme-one-dark';
import { vim } from '@replit/codemirror-vim';
import { editorSettings } from './stores/editorSettings';
interface Props {
initialValue?: string;
language?: 'javascript' | 'html' | 'css';
onChange?: (value: string) => void;
}
let {
initialValue = '',
language = 'javascript',
onChange
}: Props = $props();
let editorContainer: HTMLDivElement;
let editorView: EditorView | null = null;
const languageExtensions = {
javascript: javascript(),
html: html(),
css: css()
};
const lineNumbersCompartment = new Compartment();
const lineWrappingCompartment = new Compartment();
const vimCompartment = new Compartment();
onMount(() => {
const settings = $editorSettings;
const baseExtensions = [
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
])
];
editorView = new EditorView({
doc: initialValue,
extensions: [
...baseExtensions,
languageExtensions[language],
oneDark,
lineNumbersCompartment.of(settings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.of(settings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.of(settings.vimMode ? vim() : []),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
}
}),
EditorView.theme({
'&': {
fontSize: `${settings.fontSize}px`,
fontFamily: settings.fontFamily
}
})
],
parent: editorContainer
});
const unsubscribe = editorSettings.subscribe((newSettings) => {
if (!editorView) return;
editorView.dispatch({
effects: [
lineNumbersCompartment.reconfigure(newSettings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.reconfigure(newSettings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.reconfigure(newSettings.vimMode ? vim() : [])
]
});
editorView.dom.style.fontSize = `${newSettings.fontSize}px`;
editorView.dom.style.fontFamily = newSettings.fontFamily;
});
return () => {
unsubscribe();
};
});
onDestroy(() => {
if (editorView) {
editorView.destroy();
}
});
export function getValue(): string {
return editorView?.state.doc.toString() || '';
}
export function setValue(value: string): void {
if (editorView) {
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: value }
});
}
}
</script>
<div class="editor-wrapper">
<div bind:this={editorContainer} class="editor-container"></div>
</div>
<style>
.editor-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
}
.editor-container {
width: 100%;
height: 100%;
}
:global(.cm-editor) {
height: 100%;
}
:global(.cm-scroller) {
overflow: auto;
}
</style>

View File

@ -0,0 +1,220 @@
<script lang="ts">
import { editorSettings } from './stores/editorSettings';
let settings = $state($editorSettings);
$effect(() => {
settings = $editorSettings;
});
function updateSetting(key: keyof typeof settings, value: any) {
editorSettings.updatePartial({ [key]: value });
}
</script>
<div class="editor-settings">
<div class="setting">
<label>
<span class="label-text">Font Size: {settings.fontSize}px</span>
<input
type="range"
min="10"
max="24"
step="1"
value={settings.fontSize}
oninput={(e) => updateSetting('fontSize', parseInt(e.currentTarget.value))}
/>
</label>
</div>
<div class="setting">
<label>
<span class="label-text">Font Family</span>
<select
value={settings.fontFamily}
onchange={(e) => updateSetting('fontFamily', e.currentTarget.value)}
>
<option value="'Roboto Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace">Roboto Mono</option>
<option value="'JetBrains Mono', 'Roboto Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace">JetBrains Mono</option>
<option value="'Fira Code', 'Roboto Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace">Fira Code</option>
<option value="Monaco, 'Roboto Mono', Consolas, 'Liberation Mono', 'Courier New', monospace">Monaco</option>
<option value="Consolas, 'Roboto Mono', Monaco, 'Liberation Mono', 'Courier New', monospace">Consolas</option>
<option value="'Courier New', 'Roboto Mono', Monaco, Consolas, monospace">Courier New</option>
</select>
</label>
</div>
<div class="setting">
<label>
<span class="label-text">Tab Size: {settings.tabSize}</span>
<input
type="range"
min="2"
max="8"
step="2"
value={settings.tabSize}
oninput={(e) => updateSetting('tabSize', parseInt(e.currentTarget.value))}
/>
</label>
</div>
<div class="checkboxes">
<label class="checkbox-label">
<input
type="checkbox"
checked={settings.vimMode}
onchange={(e) => updateSetting('vimMode', e.currentTarget.checked)}
/>
<span>Vim mode</span>
</label>
<label class="checkbox-label">
<input
type="checkbox"
checked={settings.showLineNumbers}
onchange={(e) => updateSetting('showLineNumbers', e.currentTarget.checked)}
/>
<span>Display line numbers</span>
</label>
<label class="checkbox-label">
<input
type="checkbox"
checked={settings.enableLineWrapping}
onchange={(e) => updateSetting('enableLineWrapping', e.currentTarget.checked)}
/>
<span>Enable line wrapping</span>
</label>
</div>
</div>
<style>
.editor-settings {
padding: 0.5rem;
color: rgba(255, 255, 255, 0.87);
}
.setting {
margin-bottom: 1rem;
}
label {
display: block;
}
.label-text {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0.25rem;
}
input[type="range"] {
width: 100%;
height: 8px;
background: #4b5563;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #646cff;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
background: #646cff;
cursor: pointer;
border: none;
}
input[type="range"]:hover {
background: #6b7280;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #818cf8;
}
input[type="range"]::-moz-range-thumb:hover {
background: #818cf8;
}
select {
width: 100%;
background-color: #1a1a1a;
color: rgba(255, 255, 255, 0.87);
border: 1px solid #555;
padding: 0.4rem;
font-size: 0.75rem;
cursor: pointer;
}
select:hover {
border-color: #646cff;
}
select:focus {
outline: none;
border-color: #646cff;
}
.checkboxes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
}
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
background-color: #4b5563;
border: 1px solid #6b7280;
cursor: pointer;
transition: all 0.2s;
}
input[type="checkbox"]:hover {
background-color: #6b7280;
border-color: #9ca3af;
}
input[type="checkbox"]:checked {
background-color: #646cff;
border-color: #646cff;
}
input[type="checkbox"]:checked:hover {
background-color: #818cf8;
border-color: #818cf8;
}
input[type="checkbox"]:checked::before {
content: '✓';
color: white;
font-size: 12px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from 'svelte';
import Editor from './Editor.svelte';
import LogPanel from './LogPanel.svelte';
interface Props {
initialValue?: string;
language?: 'javascript' | 'html' | 'css';
onChange?: (value: string) => void;
logs?: string[];
}
let {
initialValue = '',
language = 'javascript',
onChange,
logs = []
}: Props = $props();
let editorRef: Editor;
let logPanelRef: LogPanel;
let editorHeight = $state(70);
let isResizing = $state(false);
let startY = $state(0);
let startHeight = $state(0);
function handleResizeStart(e: MouseEvent) {
isResizing = true;
startY = e.clientY;
startHeight = editorHeight;
e.preventDefault();
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing) return;
const container = document.querySelector('.editor-with-logs');
if (!container) return;
const containerHeight = container.clientHeight;
const deltaY = e.clientY - startY;
const deltaPercent = (deltaY / containerHeight) * 100;
const newHeight = Math.max(20, Math.min(80, startHeight + deltaPercent));
editorHeight = newHeight;
}
function handleResizeEnd() {
isResizing = false;
}
onMount(() => {
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
return () => {
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
};
});
export function getValue(): string {
return editorRef?.getValue() || '';
}
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">
<div class="editor-section" style="height: {editorHeight}%;">
<Editor
bind:this={editorRef}
{initialValue}
{language}
{onChange}
/>
</div>
<div class="resize-divider" onmousedown={handleResizeStart}></div>
<div class="logs-section" style="height: {100 - editorHeight}%;">
<LogPanel bind:this={logPanelRef} {logs} />
</div>
</div>
<style>
.editor-with-logs {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.editor-section {
position: relative;
overflow: hidden;
}
.resize-divider {
height: 4px;
background-color: #2a2a2a;
cursor: ns-resize;
transition: background-color 0.2s;
z-index: 10;
}
.resize-divider:hover,
.resize-divider:active {
background-color: #646cff;
}
.logs-section {
position: relative;
overflow: hidden;
}
</style>

76
src/lib/LogPanel.svelte Normal file
View File

@ -0,0 +1,76 @@
<script lang="ts">
interface Props {
logs?: string[];
}
let { logs = [] }: Props = $props();
export function addLog(message: string) {
logs.push(message);
}
export function clearLogs() {
logs = [];
}
</script>
<div class="log-panel">
<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>
{/each}
{/if}
</div>
</div>
<style>
.log-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: #1a1a1a;
}
.log-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
.empty-state {
color: rgba(255, 255, 255, 0.4);
text-align: center;
padding: 2rem;
}
.log-entry {
display: flex;
gap: 0.75rem;
padding: 0.25rem 0.5rem;
color: rgba(255, 255, 255, 0.87);
border-bottom: 1px solid #2a2a2a;
}
.log-entry:hover {
background-color: #252525;
}
.log-index {
color: rgba(255, 255, 255, 0.4);
min-width: 2rem;
text-align: right;
}
.log-message {
flex: 1;
word-break: break-word;
}
</style>

135
src/lib/Popup.svelte Normal file
View File

@ -0,0 +1,135 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
title?: string;
visible?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
onClose?: () => void;
}
let {
title = 'Popup',
visible = $bindable(false),
x = 100,
y = 100,
width = 400,
height = 300,
onClose
}: Props = $props();
let isDragging = $state(false);
let dragStartX = $state(0);
let dragStartY = $state(0);
let popupX = $state(x);
let popupY = $state(y);
function handleMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('popup-header')) {
isDragging = true;
dragStartX = e.clientX - popupX;
dragStartY = e.clientY - popupY;
}
}
function handleMouseMove(e: MouseEvent) {
if (isDragging) {
popupX = e.clientX - dragStartX;
popupY = e.clientY - dragStartY;
}
}
function handleMouseUp() {
isDragging = false;
}
function handleClose() {
visible = false;
if (onClose) {
onClose();
}
}
onMount(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
});
</script>
{#if visible}
<div
class="popup"
style="left: {popupX}px; top: {popupY}px; width: {width}px; height: {height}px;"
>
<div class="popup-header" onmousedown={handleMouseDown}>
<span class="popup-title">{title}</span>
<button class="close-button" onclick={handleClose}>×</button>
</div>
<div class="popup-content">
<slot />
</div>
</div>
{/if}
<style>
.popup {
position: fixed;
z-index: 9999;
background-color: #1a1a1a;
border: 1px solid #333;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: #252525;
border-bottom: 1px solid #333;
cursor: move;
user-select: none;
}
.popup-title {
font-weight: 600;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.87);
}
.close-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.5rem;
line-height: 1;
padding: 0;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-button:hover {
color: rgba(255, 255, 255, 1);
}
.popup-content {
flex: 1;
overflow: auto;
padding: 1rem;
}
</style>

214
src/lib/SidePanel.svelte Normal file
View File

@ -0,0 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
visible?: boolean;
initialWidth?: number;
minWidth?: number;
maxWidth?: number;
position?: 'left' | 'right' | 'bottom';
tabs?: Array<{ id: string; label: string; content: any }>;
}
let {
visible = $bindable(true),
initialWidth = 300,
minWidth = 200,
maxWidth = 600,
position = $bindable('right'),
tabs = []
}: Props = $props();
let width = $state(initialWidth);
let isResizing = $state(false);
let startPos = $state(0);
let startWidth = $state(0);
let activeTab = $state(tabs[0]?.id || '');
function handleResizeStart(e: MouseEvent) {
isResizing = true;
if (position === 'bottom') {
startPos = e.clientY;
} else {
startPos = e.clientX;
}
startWidth = width;
e.preventDefault();
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing) return;
let delta = 0;
if (position === 'left') {
delta = e.clientX - startPos;
} else if (position === 'right') {
delta = startPos - e.clientX;
} else if (position === 'bottom') {
delta = startPos - e.clientY;
}
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
width = newWidth;
}
function handleResizeEnd() {
isResizing = false;
}
onMount(() => {
if (tabs.length > 0 && !activeTab) {
activeTab = tabs[0].id;
}
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
return () => {
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
};
});
export function toggle() {
visible = !visible;
}
export function setPosition(newPosition: 'left' | 'right' | 'bottom') {
position = newPosition;
}
</script>
{#if visible}
<div class="side-panel" class:left={position === 'left'} class:right={position === 'right'} class:bottom={position === 'bottom'} style="{position === 'bottom' ? 'height' : 'width'}: {width}px;">
<div class="resize-handle" onmousedown={handleResizeStart}></div>
{#if tabs.length > 0}
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={activeTab === tab.id}
onclick={() => activeTab = tab.id}
>
{tab.label}
</button>
{/each}
</div>
<div class="side-panel-content">
{#each tabs as tab}
{#if activeTab === tab.id}
<div class="tab-content">
{@render tab.content()}
</div>
{/if}
{/each}
</div>
{:else}
<div class="side-panel-content">
{@render children?.()}
</div>
{/if}
</div>
{/if}
<style>
.side-panel {
position: relative;
background-color: #1a1a1a;
display: flex;
flex-direction: column;
}
.side-panel.left,
.side-panel.right {
height: 100%;
}
.side-panel.left {
border-right: 1px solid #333;
}
.side-panel.right {
border-left: 1px solid #333;
}
.side-panel.bottom {
width: 100%;
border-top: 1px solid #333;
}
.resize-handle {
position: absolute;
background-color: transparent;
transition: background-color 0.2s;
z-index: 10;
}
.left .resize-handle {
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
}
.right .resize-handle {
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
}
.bottom .resize-handle {
top: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.resize-handle:hover,
.resize-handle:active {
background-color: #646cff;
}
.tabs {
display: flex;
border-bottom: 1px solid #333;
background-color: #1a1a1a;
}
.tab {
padding: 0.75rem 1rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: rgba(255, 255, 255, 0.87);
}
.tab.active {
color: rgba(255, 255, 255, 0.87);
border-bottom-color: #646cff;
}
.side-panel-content {
flex: 1;
overflow: auto;
padding: 1rem;
}
.tab-content {
height: 100%;
}
</style>

51
src/lib/TopBar.svelte Normal file
View File

@ -0,0 +1,51 @@
<script lang="ts">
import { Code2 } from 'lucide-svelte';
interface Props {
title?: string;
children?: import('svelte').Snippet;
}
let { title = 'oldboy', children }: Props = $props();
</script>
<div class="top-bar">
<div class="title-section">
<Code2 size={20} />
<span class="title">{title}</span>
</div>
<div class="actions">
{@render children?.()}
</div>
</div>
<style>
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
height: 48px;
box-sizing: border-box;
}
.title-section {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgba(255, 255, 255, 0.87);
}
.title {
font-weight: 600;
font-size: 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@ -0,0 +1,50 @@
import { writable } from 'svelte/store';
export interface EditorSettings {
fontSize: number;
fontFamily: string;
showLineNumbers: boolean;
enableLineWrapping: boolean;
tabSize: number;
vimMode: boolean;
}
const defaultSettings: EditorSettings = {
fontSize: 14,
fontFamily: "'Roboto Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
showLineNumbers: true,
enableLineWrapping: false,
tabSize: 2,
vimMode: false
};
function createEditorSettings() {
const stored = localStorage.getItem('editorSettings');
const initial = stored ? { ...defaultSettings, ...JSON.parse(stored) } : defaultSettings;
const { subscribe, set, update } = writable<EditorSettings>(initial);
return {
subscribe,
set: (value: EditorSettings) => {
localStorage.setItem('editorSettings', JSON.stringify(value));
set(value);
},
update: (updater: (value: EditorSettings) => EditorSettings) => {
update((current) => {
const newValue = updater(current);
localStorage.setItem('editorSettings', JSON.stringify(newValue));
return newValue;
});
},
updatePartial: (partial: Partial<EditorSettings>) => {
update((current) => {
const newValue = { ...current, ...partial };
localStorage.setItem('editorSettings', JSON.stringify(newValue));
return newValue;
});
}
};
}
export const editorSettings = createEditorSettings();