feat(ui): add CodeBlock and CodeEditor components with Monaco and hljs
- add CodeBlock for read-only syntax-highlighted display (hljs, js/json) - add CodeEditor wrapping Monaco editor with theme sync, focus state, and error state - replace code textareas in snippet, credential, and step pages with CodeEditor - replace code <pre> blocks in snippet and credential detail pages with CodeBlock - make form cards full-width on pages with code editors - add resize:vertical support to CodeEditor wrapper with automaticLayout
This commit is contained in:
@@ -14,6 +14,8 @@
|
|||||||
"format": "prettier --write \"src/**/*.{ts,tsx}\" \".storybook/**/*.{ts,tsx}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx}\" \".storybook/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.0.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import javascript from 'highlight.js/lib/languages/javascript';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
|
||||||
|
hljs.registerLanguage('javascript', javascript);
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
|
export default hljs;
|
||||||
|
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
@@ -196,6 +196,10 @@
|
|||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formCardFull {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.formFields {
|
.formFields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function CreateCredentialPage() {
|
export function CreateCredentialPage() {
|
||||||
@@ -58,7 +58,7 @@ export function CreateCredentialPage() {
|
|||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('credentials.form_name')}
|
label={t('credentials.form_name')}
|
||||||
@@ -72,17 +72,23 @@ export function CreateCredentialPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<div className={styles.formField}>
|
||||||
label={t('credentials.form_data')}
|
<label className={styles.fieldLabel}>
|
||||||
placeholder={t('credentials.form_data_placeholder')}
|
{t('credentials.form_data')}
|
||||||
value={data}
|
<span className={styles.requiredMark}> *</span>
|
||||||
onChange={(e) => {
|
</label>
|
||||||
setData(e.target.value);
|
<CodeEditor
|
||||||
setDataError('');
|
language="json"
|
||||||
}}
|
value={data}
|
||||||
error={dataError || undefined}
|
onChange={(v) => {
|
||||||
rows={6}
|
setData(v);
|
||||||
/>
|
setDataError('');
|
||||||
|
}}
|
||||||
|
rows={6}
|
||||||
|
error={!!dataError}
|
||||||
|
/>
|
||||||
|
{dataError && <span className={styles.fieldError}>{dataError}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
import { Upload, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { CodeBlock } from '../../ui';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import type { Credential } from '../../api';
|
import type { Credential } from '../../api';
|
||||||
@@ -55,7 +56,7 @@ export function CredentialDetailPage() {
|
|||||||
{credential && (
|
{credential && (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||||
<Download size={14} />
|
<Upload size={14} />
|
||||||
{t('credentials.action_export')}
|
{t('credentials.action_export')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -116,15 +117,16 @@ export function CredentialDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<h2 className={styles.sectionHeading}>{t('credentials.section_data')}</h2>
|
<h2 className={styles.sectionHeading}>{t('credentials.section_data')}</h2>
|
||||||
<Card>
|
<Card>
|
||||||
<pre className={styles.codeBlock}>
|
<CodeBlock
|
||||||
{(() => {
|
language="json"
|
||||||
|
code={(() => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(credential.data), null, 2);
|
return JSON.stringify(JSON.parse(credential.data), null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return credential.data;
|
return credential.data;
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</pre>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Settings, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
import { Settings, Pencil, Trash2, Plus, Download } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import type { Credential } from '../../api';
|
import type { Credential } from '../../api';
|
||||||
@@ -141,7 +141,7 @@ export function CredentialsPage() {
|
|||||||
onChange={handleImport}
|
onChange={handleImport}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Upload size={14} />
|
<Download size={14} />
|
||||||
{t('credentials.action_import')}
|
{t('credentials.action_import')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import type { Credential } from '../../api';
|
import type { Credential } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function EditCredentialPage() {
|
export function EditCredentialPage() {
|
||||||
@@ -84,7 +84,7 @@ export function EditCredentialPage() {
|
|||||||
|
|
||||||
{!loading && credential && (
|
{!loading && credential && (
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('credentials.form_name')}
|
label={t('credentials.form_name')}
|
||||||
@@ -98,17 +98,23 @@ export function EditCredentialPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<div className={styles.formField}>
|
||||||
label={t('credentials.form_data')}
|
<label className={styles.fieldLabel}>
|
||||||
placeholder={t('credentials.form_data_placeholder')}
|
{t('credentials.form_data')}
|
||||||
value={data}
|
<span className={styles.requiredMark}> *</span>
|
||||||
onChange={(e) => {
|
</label>
|
||||||
setData(e.target.value);
|
<CodeEditor
|
||||||
setDataError('');
|
language="json"
|
||||||
}}
|
value={data}
|
||||||
error={dataError || undefined}
|
onChange={(v) => {
|
||||||
rows={6}
|
setData(v);
|
||||||
/>
|
setDataError('');
|
||||||
|
}}
|
||||||
|
rows={6}
|
||||||
|
error={!!dataError}
|
||||||
|
/>
|
||||||
|
{dataError && <span className={styles.fieldError}>{dataError}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { scenarios, steps } from '../../api';
|
import { scenarios, steps } from '../../api';
|
||||||
import type { Scenario, StepType } from '../../api';
|
import type { Scenario, StepType } from '../../api';
|
||||||
import type { CreateStepPayload } from '../../api/client';
|
import type { CreateStepPayload } from '../../api/client';
|
||||||
import { Breadcrumbs, Button, Card, Input, Select } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
||||||
@@ -75,7 +75,7 @@ export function CreateStepPage() {
|
|||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('steps.form_order')}
|
label={t('steps.form_order')}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { scenarios, steps } from '../../api';
|
import { scenarios, steps } from '../../api';
|
||||||
import type { Scenario, ScenarioStep, StepType } from '../../api';
|
import type { Scenario, ScenarioStep, StepType } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, Select } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
||||||
@@ -91,7 +91,7 @@ export function EditStepPage() {
|
|||||||
|
|
||||||
{!loading && step && (
|
{!loading && step && (
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('steps.form_order')}
|
label={t('steps.form_order')}
|
||||||
@@ -118,24 +118,18 @@ export function EditStepPage() {
|
|||||||
/>
|
/>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||||
<textarea
|
<CodeEditor
|
||||||
className={styles.textarea}
|
|
||||||
rows={6}
|
|
||||||
value={execCode}
|
value={execCode}
|
||||||
onChange={(e) => setExecCode(e.target.value)}
|
onChange={setExecCode}
|
||||||
placeholder={t('steps.form_exec_code_placeholder')}
|
rows={6}
|
||||||
spellCheck={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
||||||
<textarea
|
<CodeEditor
|
||||||
className={styles.textarea}
|
|
||||||
rows={6}
|
|
||||||
value={validateCode}
|
value={validateCode}
|
||||||
onChange={(e) => setValidateCode(e.target.value)}
|
onChange={setValidateCode}
|
||||||
placeholder={t('steps.form_validate_code_placeholder')}
|
rows={6}
|
||||||
spellCheck={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Pencil, Plus, History, Trash2, Download } from 'lucide-react';
|
import { Play, Pencil, Plus, History, Trash2, Upload } from 'lucide-react';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||||
@@ -227,7 +227,7 @@ export function ScenarioDetailPage() {
|
|||||||
{t('scenarios.action_runs')}
|
{t('scenarios.action_runs')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||||
<Download size={14} />
|
<Upload size={14} />
|
||||||
{t('scenarios.action_export')}
|
{t('scenarios.action_export')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Plus, Trash2, Upload } from 'lucide-react';
|
import { Play, Plus, Trash2, Download } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { scenarios } from '../../api';
|
import { scenarios } from '../../api';
|
||||||
import type { Scenario } from '../../api';
|
import type { Scenario } from '../../api';
|
||||||
@@ -99,7 +99,7 @@ export function ScenariosPage() {
|
|||||||
onChange={handleImport}
|
onChange={handleImport}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Upload size={14} />
|
<Download size={14} />
|
||||||
{t('scenarios.action_import')}
|
{t('scenarios.action_import')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => navigate('/scenarios/new')}>
|
<Button size="sm" onClick={() => navigate('/scenarios/new')}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function CreateSnippetPage() {
|
export function CreateSnippetPage() {
|
||||||
@@ -59,7 +59,7 @@ export function CreateSnippetPage() {
|
|||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('snippets.form_name')}
|
label={t('snippets.form_name')}
|
||||||
@@ -84,16 +84,14 @@ export function CreateSnippetPage() {
|
|||||||
{t('snippets.form_code')}
|
{t('snippets.form_code')}
|
||||||
<span className={styles.requiredMark}> *</span>
|
<span className={styles.requiredMark}> *</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<CodeEditor
|
||||||
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
|
|
||||||
rows={14}
|
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setCode(e.target.value);
|
setCode(v);
|
||||||
setCodeError('');
|
setCodeError('');
|
||||||
}}
|
}}
|
||||||
placeholder={t('snippets.form_code_placeholder')}
|
rows={14}
|
||||||
spellCheck={false}
|
error={!!codeError}
|
||||||
/>
|
/>
|
||||||
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import type { Snippet } from '../../api';
|
import type { Snippet } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function EditSnippetPage() {
|
export function EditSnippetPage() {
|
||||||
@@ -83,7 +83,7 @@ export function EditSnippetPage() {
|
|||||||
|
|
||||||
{!loading && snippet && (
|
{!loading && snippet && (
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCard}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
<Input
|
||||||
label={t('snippets.form_name')}
|
label={t('snippets.form_name')}
|
||||||
@@ -108,16 +108,14 @@ export function EditSnippetPage() {
|
|||||||
{t('snippets.form_code')}
|
{t('snippets.form_code')}
|
||||||
<span className={styles.requiredMark}> *</span>
|
<span className={styles.requiredMark}> *</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<CodeEditor
|
||||||
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
|
|
||||||
rows={14}
|
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setCode(e.target.value);
|
setCode(v);
|
||||||
setCodeError('');
|
setCodeError('');
|
||||||
}}
|
}}
|
||||||
placeholder={t('snippets.form_code_placeholder')}
|
rows={14}
|
||||||
spellCheck={false}
|
error={!!codeError}
|
||||||
/>
|
/>
|
||||||
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
import { Upload, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { CodeBlock } from '../../ui';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import type { Snippet } from '../../api';
|
import type { Snippet } from '../../api';
|
||||||
@@ -55,7 +56,7 @@ export function SnippetDetailPage() {
|
|||||||
{snippet && (
|
{snippet && (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||||
<Download size={14} />
|
<Upload size={14} />
|
||||||
{t('snippets.action_export')}
|
{t('snippets.action_export')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -114,7 +115,7 @@ export function SnippetDetailPage() {
|
|||||||
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
|
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<pre className={styles.codeBlock}>{snippet.code}</pre>
|
<CodeBlock code={snippet.code} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Code, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
import { Code, Pencil, Trash2, Plus, Download } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import type { Snippet } from '../../api';
|
import type { Snippet } from '../../api';
|
||||||
@@ -134,7 +134,7 @@ export function SnippetsPage() {
|
|||||||
onChange={handleImport}
|
onChange={handleImport}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Upload size={14} />
|
<Download size={14} />
|
||||||
{t('snippets.action_import')}
|
{t('snippets.action_import')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
.pre {
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: var(--font-mono, ui-monospace, 'Cascadia Code', 'Fira Code', monospace);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
display: block;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── highlight.js token overrides (theme-aware) ─────────────────────── */
|
||||||
|
|
||||||
|
.code :global(.hljs-comment),
|
||||||
|
.code :global(.hljs-quote) {
|
||||||
|
color: var(--hl-comment, #6A8072);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-keyword),
|
||||||
|
.code :global(.hljs-selector-tag),
|
||||||
|
.code :global(.hljs-built_in),
|
||||||
|
.code :global(.hljs-name),
|
||||||
|
.code :global(.hljs-tag) {
|
||||||
|
color: var(--hl-keyword, #A67DCC);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-string),
|
||||||
|
.code :global(.hljs-title),
|
||||||
|
.code :global(.hljs-section),
|
||||||
|
.code :global(.hljs-attribute),
|
||||||
|
.code :global(.hljs-literal),
|
||||||
|
.code :global(.hljs-template-tag),
|
||||||
|
.code :global(.hljs-template-variable),
|
||||||
|
.code :global(.hljs-type),
|
||||||
|
.code :global(.hljs-addition) {
|
||||||
|
color: var(--hl-string, #9BB870);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-number),
|
||||||
|
.code :global(.hljs-regexp),
|
||||||
|
.code :global(.hljs-link) {
|
||||||
|
color: var(--hl-number, #E8A85F);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-function),
|
||||||
|
.code :global(.hljs-title.hljs-function_) {
|
||||||
|
color: var(--hl-function, #5BB8D4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-variable),
|
||||||
|
.code :global(.hljs-params) {
|
||||||
|
color: var(--hl-variable, #D4A0A0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-property) {
|
||||||
|
color: var(--hl-property, #79C0C8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-deletion),
|
||||||
|
.code :global(.hljs-meta) {
|
||||||
|
color: var(--hl-meta, #A9A3C9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-emphasis) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code :global(.hljs-strong) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import hljs, { escapeHtml } from '../../lib/hljs';
|
||||||
|
import styles from './CodeBlock.module.css';
|
||||||
|
|
||||||
|
export interface CodeBlockProps {
|
||||||
|
code: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ code, language = 'javascript' }: CodeBlockProps) {
|
||||||
|
const highlighted = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(code, { language }).value;
|
||||||
|
} catch {
|
||||||
|
return code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
}, [code, language]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className={styles.pre}>
|
||||||
|
<code
|
||||||
|
className={`${styles.code} hljs language-${language}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focused {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasError {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Editor, { OnMount } from '@monaco-editor/react';
|
||||||
|
import styles from './CodeEditor.module.css';
|
||||||
|
|
||||||
|
export interface CodeEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
language?: string;
|
||||||
|
rows?: number;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMonacoTheme() {
|
||||||
|
const [theme, setTheme] = useState(() =>
|
||||||
|
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTheme(
|
||||||
|
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme'],
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
language = 'javascript',
|
||||||
|
rows = 6,
|
||||||
|
error = false,
|
||||||
|
}: CodeEditorProps) {
|
||||||
|
const theme = useMonacoTheme();
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const height = rows * 20 + 16;
|
||||||
|
|
||||||
|
const handleMount: OnMount = (editor) => {
|
||||||
|
editor.onDidFocusEditorWidget(() => setFocused(true));
|
||||||
|
editor.onDidBlurEditorWidget(() => setFocused(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.wrapper,
|
||||||
|
focused ? styles.focused : '',
|
||||||
|
error ? styles.hasError : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
theme={theme}
|
||||||
|
language={language}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
onMount={handleMount}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
wordWrap: 'on',
|
||||||
|
padding: { top: 8, bottom: 8 },
|
||||||
|
folding: false,
|
||||||
|
renderLineHighlight: 'line',
|
||||||
|
scrollbar: { vertical: 'auto', horizontal: 'hidden' },
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,12 @@ export type { PaginationProps } from './Pagination/Pagination';
|
|||||||
export { Search } from './Search/Search';
|
export { Search } from './Search/Search';
|
||||||
export type { SearchProps } from './Search/Search';
|
export type { SearchProps } from './Search/Search';
|
||||||
|
|
||||||
|
export { CodeBlock } from './CodeBlock/CodeBlock';
|
||||||
|
export type { CodeBlockProps } from './CodeBlock/CodeBlock';
|
||||||
|
|
||||||
|
export { CodeEditor } from './CodeEditor/CodeEditor';
|
||||||
|
export type { CodeEditorProps } from './CodeEditor/CodeEditor';
|
||||||
|
|
||||||
export { ContextMenu } from './ContextMenu/ContextMenu';
|
export { ContextMenu } from './ContextMenu/ContextMenu';
|
||||||
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
||||||
|
|
||||||
|
|||||||
Generated
+82
@@ -15,6 +15,8 @@
|
|||||||
"client": {
|
"client": {
|
||||||
"name": "liqa-client",
|
"name": "liqa-client",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.0.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
@@ -3063,6 +3065,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@monaco-editor/loader": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"state-local": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@monaco-editor/react": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/loader": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"monaco-editor": ">= 0.25.0 < 1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mozilla/readability": {
|
"node_modules/@mozilla/readability": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
|
||||||
@@ -4642,6 +4667,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.15.10",
|
"version": "13.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
@@ -6947,6 +6980,16 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"peer": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -8564,6 +8607,15 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.12",
|
"version": "4.12.12",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||||
@@ -10350,6 +10402,19 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -10532,6 +10597,17 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "3.2.7",
|
||||||
|
"marked": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -12315,6 +12391,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/state-local": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user