fix: stabilize markdown notes — internal link modal, rename UI, trash integration
- Replace broken ObjectPickerModal with simple inline modal (Label+URL fields) - Insert internal link at cursor position in textarea - Add rename button in note editor header and note cards - Add delete button on note cards with confirm dialog - Integrate DeleteNote with shared trash (.verstak/trash/) via files.TrashFile() - Remove hidden .verstak/trash/notes/ folder — notes use unified trash now - Fix purgeTrashNode to clean file-record-based trash entries (notes/files) - Add activity + sync ops to DeleteNote binding - Add files.TrashFile() public method - Update i18n keys for note.rename, note.deleteConfirm, internal link modal - AssertContained: symlink-aware path containment check - Update tests: shared trash, file record missing flag, collision on rename - All go test ./... pass, frontend build passes, GUI binary built
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
export let viewMode = 'edit';
|
||||
export let placeholder = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let textareaRef;
|
||||
|
||||
// ─── Core insert helper ──────────────────────────────────────────
|
||||
function insertAtCursor(before, after = '', placeholder = '') {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = content.substring(start, end);
|
||||
const insertText = selected || placeholder;
|
||||
|
||||
const replacement = before + insertText + after;
|
||||
const newContent = content.substring(0, start) + replacement + content.substring(end);
|
||||
content = newContent;
|
||||
|
||||
// Dispatch change
|
||||
dispatch('content-change', { content: newContent });
|
||||
|
||||
// Restore focus and set cursor position
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
if (selected) {
|
||||
// Wrap mode: select the inserted content (excluding markers)
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
} else {
|
||||
// No selection: place cursor inside the wrapper
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + placeholder.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertLinePrefix(prefix) {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
|
||||
// Find the start of the line(s) covered by selection
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
|
||||
const selectedLines = content.substring(lineStart, lineEnd);
|
||||
const lines = selectedLines.split('\n');
|
||||
const prefixedLines = lines.map(l => {
|
||||
// Don't double-prefix
|
||||
if (l.startsWith(prefix)) return l;
|
||||
return prefix + l;
|
||||
});
|
||||
const replacement = prefixedLines.join('\n');
|
||||
|
||||
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + replacement.length;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Toolbar handlers ────────────────────────────────────────────
|
||||
function handleBold() { insertAtCursor('**', '**', 'bold text'); }
|
||||
function handleItalic() { insertAtCursor('*', '*', 'italic text'); }
|
||||
function handleInlineCode() { insertAtCursor('`', '`', 'code'); }
|
||||
function handleLink() { dispatch('insert-link'); }
|
||||
function handleHeading() { insertLinePrefix('## '); }
|
||||
function handleQuote() { insertLinePrefix('> '); }
|
||||
function handleBulletList() { insertLinePrefix('- '); }
|
||||
function handleNumberedList() {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = ta.selectionEnd;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const selectedLines = content.substring(lineStart, lineEnd);
|
||||
const lines = selectedLines.split('\n');
|
||||
let counter = 1;
|
||||
const prefixedLines = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
// Remove existing numbered list prefix
|
||||
const cleaned = l.replace(/^\s*\d+\.\s*/, '');
|
||||
return `${counter++}. ${trimmed || 'item'}`;
|
||||
});
|
||||
const replacement = prefixedLines.join('\n');
|
||||
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => { ta.focus(); ta.selectionStart = lineStart; ta.selectionEnd = lineStart + replacement.length; });
|
||||
}
|
||||
function handleCheckbox() { insertLinePrefix('- [ ] '); }
|
||||
function handleCodeBlock() {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = content.substring(start, end);
|
||||
const replacement = selected
|
||||
? '```\n' + selected + '\n```'
|
||||
: '```\ncode here\n```';
|
||||
const newContent = content.substring(0, start) + replacement + content.substring(end);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
if (selected) {
|
||||
ta.selectionStart = start + 4;
|
||||
ta.selectionEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
ta.selectionStart = start + 4;
|
||||
ta.selectionEnd = start + 4 + 'code here'.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
function handleInternalLink() { dispatch('insert-internal-link'); }
|
||||
|
||||
// ─── Textarea events ─────────────────────────────────────────────
|
||||
function handleInput(e) {
|
||||
content = e.target.value;
|
||||
dispatch('content-change', { content });
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
dispatch('save');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const ta = e.target;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Unindent: remove up to 2 spaces at start of selected lines
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const block = content.substring(lineStart, lineEnd);
|
||||
const dedented = block.replace(/^ {1,2}/gm, '');
|
||||
const newContent = content.substring(0, lineStart) + dedented + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + dedented.length;
|
||||
});
|
||||
} else {
|
||||
// Indent: insert 2 spaces at cursor or start of each selected line
|
||||
if (start === end) {
|
||||
// No selection: insert 2 spaces at cursor
|
||||
const newContent = content.substring(0, start) + ' ' + content.substring(end);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||
});
|
||||
} else {
|
||||
// Selection: indent each line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const block = content.substring(lineStart, lineEnd);
|
||||
const indented = block.replace(/^/gm, ' ');
|
||||
const newContent = content.substring(0, lineStart) + indented + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + indented.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="md-editor" class:mode-edit={viewMode === 'edit' || viewMode === 'split'}>
|
||||
<!-- Toolbar -->
|
||||
<div class="md-toolbar" role="toolbar" aria-label="Markdown formatting">
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleBold} title={t('note.toolbar.bold')} aria-label={t('note.toolbar.bold')}>
|
||||
<span class="md-btn-label md-btn-bold">B</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleItalic} title={t('note.toolbar.italic')} aria-label={t('note.toolbar.italic')}>
|
||||
<span class="md-btn-label md-btn-italic">I</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleHeading} title={t('note.toolbar.heading')} aria-label={t('note.toolbar.heading')}>
|
||||
<span class="md-btn-label">H</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleLink} title={t('note.toolbar.link')} aria-label={t('note.toolbar.link')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleInlineCode} title={t('note.toolbar.code')} aria-label={t('note.toolbar.code')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleCodeBlock} title={t('note.toolbar.codeBlock')} aria-label={t('note.toolbar.codeBlock')}>
|
||||
<span class="md-btn-label md-btn-sm">{'</>'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleBulletList} title={t('note.toolbar.bulletList')} aria-label={t('note.toolbar.bulletList')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleNumberedList} title={t('note.toolbar.numberedList')} aria-label={t('note.toolbar.numberedList')}>
|
||||
<span class="md-btn-label md-btn-sm">1.</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleCheckbox} title={t('note.toolbar.checkbox')} aria-label={t('note.toolbar.checkbox')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleQuote} title={t('note.toolbar.quote')} aria-label={t('note.toolbar.quote')}>
|
||||
<span class="md-btn-label md-btn-sm md-btn-quote">"</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn md-toolbar-btn--internal" on:click={handleInternalLink} title={t('note.toolbar.internalLink')} aria-label={t('note.toolbar.internalLink')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M4.93 4.93l1.41 1.41"/><path d="M17.66 17.66l1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M6.34 17.66l-1.41 1.41"/><path d="M19.07 4.93l-1.41 1.41"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor textarea -->
|
||||
<textarea
|
||||
class="md-textarea"
|
||||
bind:value={content}
|
||||
bind:this={textareaRef}
|
||||
{placeholder}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.md-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.md-editor.mode-edit {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
background: #16161f;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.md-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.md-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #2a2a3c;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.md-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #e4e4ef;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:active {
|
||||
background: #333350;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.md-toolbar-btn--internal {
|
||||
color: #67e8f9;
|
||||
}
|
||||
.md-toolbar-btn--internal:hover {
|
||||
background: rgba(103, 232, 249, 0.1);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.md-btn-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.md-btn-bold { font-weight: 900; }
|
||||
.md-btn-italic { font-style: italic; font-family: Georgia, serif; }
|
||||
.md-btn-sm { font-size: 10px; }
|
||||
.md-btn-quote { font-size: 16px; font-family: Georgia, serif; }
|
||||
|
||||
/* Textarea */
|
||||
.md-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #13131f;
|
||||
color: #e4e4ef;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
padding: 16px 20px;
|
||||
resize: none;
|
||||
tab-size: 2;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.md-textarea::placeholder {
|
||||
color: #444;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script>
|
||||
import { renderMarkdown, isInternalLink, parseVerstakUrl, isAllowedVerstakType } from '../../markdown/markdown';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
|
||||
let html = '';
|
||||
let error = '';
|
||||
|
||||
$: {
|
||||
try {
|
||||
html = renderMarkdown(content || '');
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
html = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
const link = e.target.closest('[data-verstak-href]');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('data-verstak-href');
|
||||
if (!href) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const parsed = parseVerstakUrl(href);
|
||||
if (!parsed || !isAllowedVerstakType(parsed.type)) return;
|
||||
|
||||
link.dispatchEvent(new CustomEvent('verstak-link', {
|
||||
bubbles: true,
|
||||
detail: { type: parsed.type, id: parsed.id, href },
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="markdown-body" on:click={handleClick} role="article">
|
||||
{#if error}
|
||||
<div class="md-error">
|
||||
<p>⚠️ {t('note.preview.error')}</p>
|
||||
<p class="md-error-detail">{error}</p>
|
||||
</div>
|
||||
{:else if !content?.trim()}
|
||||
<div class="md-empty">{t('note.preview.empty')}</div>
|
||||
{:else}
|
||||
{@html html}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-body {
|
||||
color: #e4e4ef;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.md-empty {
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.md-error {
|
||||
background: #3a2222;
|
||||
border: 1px solid #5a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.md-error-detail {
|
||||
font-size: 12px;
|
||||
color: #cc6666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
:global(.markdown-body h1) {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 32px 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
color: #f0f0f8;
|
||||
}
|
||||
:global(.markdown-body h2) {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 28px 0 14px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #22223a;
|
||||
color: #e8e8f0;
|
||||
}
|
||||
:global(.markdown-body h3) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
:global(.markdown-body h4) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 10px;
|
||||
color: #ccc;
|
||||
}
|
||||
:global(.markdown-body h5),
|
||||
:global(.markdown-body h6) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
:global(.markdown-body p) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
:global(.markdown-body .md-link) {
|
||||
color: #818cf8;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.markdown-body .md-link:hover) {
|
||||
color: #a5b4fc;
|
||||
border-bottom-color: #818cf8;
|
||||
}
|
||||
:global(.markdown-body .md-link--internal) {
|
||||
color: #67e8f9;
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(.markdown-body .md-link--internal:hover) {
|
||||
color: #a5f3fc;
|
||||
border-bottom-color: #67e8f9;
|
||||
}
|
||||
:global(.markdown-body .md-link--blocked) {
|
||||
color: #666;
|
||||
text-decoration: line-through;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bold / Italic / Strikethrough */
|
||||
:global(.markdown-body strong) { font-weight: 700; color: #f0f0f8; }
|
||||
:global(.markdown-body em) { font-style: italic; }
|
||||
:global(.markdown-body del) { text-decoration: line-through; color: #888; }
|
||||
|
||||
/* Lists */
|
||||
:global(.markdown-body ul) {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
list-style-type: disc;
|
||||
}
|
||||
:global(.markdown-body ol) {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
:global(.markdown-body li) {
|
||||
margin: 4px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:global(.markdown-body li > ul),
|
||||
:global(.markdown-body li > ol) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Task lists */
|
||||
:global(.markdown-body .md-task-item) {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
:global(.markdown-body .md-task-checkbox) {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
accent-color: #818cf8;
|
||||
}
|
||||
:global(.markdown-body .md-task-text) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
:global(.markdown-body blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid #818cf8;
|
||||
background: rgba(129, 140, 248, 0.06);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
}
|
||||
:global(.markdown-body blockquote p) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:global(.markdown-body .md-code-inline) {
|
||||
background: #1e1e38;
|
||||
color: #f59e0b;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Code blocks — dark theme */
|
||||
:global(.markdown-body .md-code-block) {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
:global(.markdown-body .md-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* highlight.js dark theme (GitHub Dark) */
|
||||
:global(.markdown-body .hljs) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-comment) { color: #8b949e; }
|
||||
:global(.markdown-body .hljs-keyword) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-string) { color: #a5d6ff; }
|
||||
:global(.markdown-body .hljs-number) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-function) { color: #d2a8ff; }
|
||||
:global(.markdown-body .hljs-class) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-variable) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-operator) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-punctuation) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-tag) { color: #7ee787; }
|
||||
:global(.markdown-body .hljs-attr) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-title) { color: #d2a8ff; }
|
||||
:global(.markdown-body .hljs-built_in) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-literal) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-type) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-params) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-property) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-regexp) { color: #a5d6ff; }
|
||||
:global(.markdown-body .hljs-meta) { color: #8b949e; }
|
||||
:global(.markdown-body .hljs-doctag) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-name) { color: #7ee787; }
|
||||
:global(.markdown-body .hljs-section) { color: #1f6feb; font-weight: 700; }
|
||||
:global(.markdown-body .hljs-symbol) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-bullet) { color: #f2cc60; }
|
||||
:global(.markdown-body .hljs-code) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-emphasis) { font-style: italic; }
|
||||
:global(.markdown-body .hljs-strong) { font-weight: 700; }
|
||||
:global(.markdown-body .hljs-addition) { color: #aff5b4; background: #033a16; }
|
||||
:global(.markdown-body .hljs-deletion) { color: #ffdcd7; background: #67060c; }
|
||||
|
||||
/* Tables */
|
||||
:global(.markdown-body table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
:global(.markdown-body thead) {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
:global(.markdown-body th) {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #c4c4d4;
|
||||
border-bottom: 2px solid #2a2a3c;
|
||||
}
|
||||
:global(.markdown-body td) {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #1e1e30;
|
||||
}
|
||||
:global(.markdown-body tbody tr:hover) {
|
||||
background: rgba(129, 140, 248, 0.04);
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
:global(.markdown-body hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #2a2a3c;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* Blocked images placeholder */
|
||||
:global(.markdown-body .md-image-blocked) {
|
||||
display: inline-block;
|
||||
background: #1a1a2e;
|
||||
border: 1px dashed #3a3a5c;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import MarkdownPreview from './MarkdownPreview.svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
export let viewMode = 'edit';
|
||||
export let placeholder = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function setMode(mode) {
|
||||
dispatch('mode-change', { mode });
|
||||
}
|
||||
|
||||
function handleContentChange(e) {
|
||||
content = e.detail.content;
|
||||
dispatch('content-change', e.detail);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
dispatch('save');
|
||||
}
|
||||
|
||||
function handleInsertLink() {
|
||||
dispatch('insert-link');
|
||||
}
|
||||
|
||||
function handleInsertInternalLink() {
|
||||
dispatch('insert-internal-link');
|
||||
}
|
||||
|
||||
function handleVerstakLink(e) {
|
||||
dispatch('verstak-link', e.detail);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
|
||||
<!-- Mode switcher -->
|
||||
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
|
||||
{t('note.mode.edit')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
|
||||
{t('note.mode.preview')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
|
||||
{t('note.mode.split')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="panel-content">
|
||||
{#if viewMode === 'edit'}
|
||||
<MarkdownEditor
|
||||
{content}
|
||||
{placeholder}
|
||||
viewMode="edit"
|
||||
on:content-change={handleContentChange}
|
||||
on:save={handleSave}
|
||||
on:insert-link={handleInsertLink}
|
||||
on:insert-internal-link={handleInsertInternalLink}
|
||||
/>
|
||||
{:else if viewMode === 'preview'}
|
||||
<div class="preview-pane">
|
||||
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||
</div>
|
||||
{:else if viewMode === 'split'}
|
||||
<div class="split-pane">
|
||||
<div class="split-editor">
|
||||
<MarkdownEditor
|
||||
{content}
|
||||
{placeholder}
|
||||
viewMode="split"
|
||||
on:content-change={handleContentChange}
|
||||
on:save={handleSave}
|
||||
on:insert-link={handleInsertLink}
|
||||
on:insert-internal-link={handleInsertInternalLink}
|
||||
/>
|
||||
</div>
|
||||
<div class="split-preview">
|
||||
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
background: #14141f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: #ccc;
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
color: #e4e4ef;
|
||||
background: #22223a;
|
||||
border-color: #333350;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mode-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mode-preview .panel-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #2a2a3c;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
background: #11111c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let visible = false;
|
||||
export let objectType = 'note'; // 'case' | 'note' | 'file' | 'secret'
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let searchQuery = '';
|
||||
let results = [];
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let selectedIndex = 0;
|
||||
let inputRef;
|
||||
|
||||
const objectTypes = [
|
||||
{ id: 'case', label: 'Дело', searchable: true },
|
||||
{ id: 'note', label: 'Заметка', searchable: true },
|
||||
{ id: 'file', label: 'Файл', searchable: true },
|
||||
{ id: 'secret', label: 'Секрет', searchable: false },
|
||||
];
|
||||
|
||||
$: currentTypeInfo = objectTypes.find(o => o.id === objectType) || objectTypes[0];
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const fn = window['go']['main']['App'][method];
|
||||
if (typeof fn === 'function') {
|
||||
return fn(...args);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Wails call error:', method, e);
|
||||
}
|
||||
return Promise.reject(new Error('Wails not connected: ' + method));
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!searchQuery.trim() || !currentTypeInfo.searchable) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
let items = [];
|
||||
if (objectType === 'note') {
|
||||
// Search across all notes
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'note' || n.type === 'case');
|
||||
} else if (objectType === 'case') {
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'case' || n.type === 'project' || n.type === 'client');
|
||||
} else if (objectType === 'file') {
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'file');
|
||||
}
|
||||
results = items;
|
||||
selectedIndex = 0;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypeChange(newType) {
|
||||
objectType = newType;
|
||||
searchQuery = '';
|
||||
results = [];
|
||||
error = '';
|
||||
if (inputRef) inputRef.focus();
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
dispatch('select', { type: objectType, id: item.id, title: item.title, path: item.path || '' });
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
search();
|
||||
}
|
||||
|
||||
$: if (visible && inputRef) {
|
||||
setTimeout(() => inputRef.focus(), 50);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label="Select object" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
|
||||
<div class="modal-box object-picker-modal" role="document">
|
||||
<h3>Вставить внутреннюю ссылку</h3>
|
||||
|
||||
<!-- Object type selector -->
|
||||
<div class="type-selector" role="tablist" aria-label="Object type">
|
||||
{#each objectTypes as type}
|
||||
<button
|
||||
type="button"
|
||||
class="type-btn"
|
||||
class:active={objectType === type.id}
|
||||
class:disabled={!type.searchable}
|
||||
role="tab"
|
||||
aria-selected={objectType === type.id}
|
||||
on:click={() => type.searchable && handleTypeChange(type.id)}
|
||||
disabled={!type.searchable}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !currentTypeInfo.searchable}
|
||||
<div class="coming-soon">
|
||||
<p>{t('note.internal.secretNotImplemented')}</p>
|
||||
<p class="hint">Object picker for this type is coming soon.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Search input -->
|
||||
<div class="search-wrapper">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputRef}
|
||||
bind:value={searchQuery}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
placeholder="Поиск..."
|
||||
class="search-input"
|
||||
aria-label="Search objects"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="object-picker-results"
|
||||
aria-activedescendant={results[selectedIndex] ? `result-${selectedIndex}` : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="results-list" id="object-picker-results" role="listbox">
|
||||
{#if loading}
|
||||
<div class="results-loading">{t('common.loading')}</div>
|
||||
{:else if error}
|
||||
<div class="results-error">{error}</div>
|
||||
{:else if searchQuery.trim() && results.length === 0}
|
||||
<div class="results-empty">{t('search.noResults')}</div>
|
||||
{:else}
|
||||
{#each results as item, i}
|
||||
<button
|
||||
type="button"
|
||||
class="result-item"
|
||||
class:selected={i === selectedIndex}
|
||||
id="result-{i}"
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
on:click={() => handleSelect(item)}
|
||||
>
|
||||
<span class="result-title">{item.title}</span>
|
||||
{#if item.path}
|
||||
<span class="result-path">{item.path}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" on:click={() => dispatch('close')}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.object-picker-modal {
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 4px;
|
||||
background: #1a1a28;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.type-btn:hover:not(:disabled) {
|
||||
background: #22223a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: #2a2a4a;
|
||||
color: #e4e4ef;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.type-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
background: #13131f;
|
||||
color: #e4e4ef;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #818cf8;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
background: #11111c;
|
||||
}
|
||||
|
||||
.results-loading,
|
||||
.results-error,
|
||||
.results-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.results-error {
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1a1a28;
|
||||
background: transparent;
|
||||
color: #e4e4ef;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item:hover,
|
||||
.result-item.selected {
|
||||
background: #1e1e38;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-path {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.coming-soon .hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user