Quick Wins: - Feature 1: Ticket linking in comments (#123456789 auto-links) - Feature 6: Checkbox click area fix (click anywhere in cell) - Feature 7: User groups display in settings modal UI Enhancements: - Feature 4: Collapsible sidebar with localStorage persistence - Feature 5: Inline ticket preview popup on hover (300ms delay) - Feature 2: Mobile responsive improvements (44px touch targets, iOS zoom fix) Major Features: - Feature 3: Kanban card view with status columns (toggle with localStorage) - Feature 9: API key generation admin panel (/admin/api-keys) - Feature 8: Ticket visibility levels (public/internal/confidential) New files: - views/admin/ApiKeysView.php - api/generate_api_key.php - api/revoke_api_key.php - migrations/008_ticket_visibility.sql Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
276 lines
9.0 KiB
JavaScript
276 lines
9.0 KiB
JavaScript
/**
|
|
* Simple Markdown Parser for Tinker Tickets
|
|
* Supports basic markdown formatting without external dependencies
|
|
*/
|
|
|
|
function parseMarkdown(markdown) {
|
|
if (!markdown) return '';
|
|
|
|
let html = markdown;
|
|
|
|
// Escape HTML first to prevent XSS
|
|
html = html.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// Ticket references (#123456789) - convert to clickable links
|
|
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
|
|
|
// Code blocks (```code```)
|
|
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
|
|
|
// Inline code (`code`)
|
|
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
|
|
|
// Bold (**text** or __text__)
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
|
|
// Italic (*text* or _text_)
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
|
|
// Links [text](url) - only allow safe protocols
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
|
// Only allow http, https, mailto protocols
|
|
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
|
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
|
}
|
|
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
|
return text;
|
|
});
|
|
|
|
// Headers (# H1, ## H2, etc.)
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// Lists
|
|
// Unordered lists (- item or * item)
|
|
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
|
|
|
// Ordered lists (1. item)
|
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
|
|
|
// Blockquotes (> text)
|
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
|
|
|
// Horizontal rules (--- or ***)
|
|
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
|
|
|
// Line breaks (two spaces at end of line or double newline)
|
|
html = html.replace(/ \n/g, '<br>');
|
|
html = html.replace(/\n\n/g, '</p><p>');
|
|
|
|
// Wrap in paragraph if not already wrapped
|
|
if (!html.startsWith('<')) {
|
|
html = '<p>' + html + '</p>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// Apply markdown rendering to all elements with data-markdown attribute
|
|
function renderMarkdownElements() {
|
|
document.querySelectorAll('[data-markdown]').forEach(element => {
|
|
const markdownText = element.getAttribute('data-markdown') || element.textContent;
|
|
element.innerHTML = parseMarkdown(markdownText);
|
|
});
|
|
}
|
|
|
|
// Apply markdown to description and comments on page load
|
|
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
|
|
|
// Expose for manual use
|
|
window.parseMarkdown = parseMarkdown;
|
|
window.renderMarkdownElements = renderMarkdownElements;
|
|
|
|
// ========================================
|
|
// Rich Text Editor Toolbar Functions
|
|
// ========================================
|
|
|
|
/**
|
|
* Insert markdown formatting around selection
|
|
*/
|
|
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const text = textarea.value;
|
|
const selectedText = text.substring(start, end);
|
|
|
|
// Insert formatting
|
|
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
|
textarea.value = newText;
|
|
|
|
// Set cursor position
|
|
if (selectedText) {
|
|
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
|
} else {
|
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
|
}
|
|
|
|
textarea.focus();
|
|
|
|
// Trigger input event to update preview if enabled
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Insert markdown at cursor position
|
|
*/
|
|
function insertMarkdownText(textareaId, text) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const value = textarea.value;
|
|
|
|
textarea.value = value.substring(0, start) + text + value.substring(start);
|
|
textarea.setSelectionRange(start + text.length, start + text.length);
|
|
textarea.focus();
|
|
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Toolbar button handlers
|
|
*/
|
|
function toolbarBold(textareaId) {
|
|
insertMarkdownFormat(textareaId, '**', '**');
|
|
}
|
|
|
|
function toolbarItalic(textareaId) {
|
|
insertMarkdownFormat(textareaId, '_', '_');
|
|
}
|
|
|
|
function toolbarCode(textareaId) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
|
|
|
// Use code block for multi-line, inline code for single line
|
|
if (selectedText.includes('\n')) {
|
|
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
|
} else {
|
|
insertMarkdownFormat(textareaId, '`', '`');
|
|
}
|
|
}
|
|
|
|
function toolbarLink(textareaId) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
|
|
|
if (selectedText) {
|
|
// Wrap selected text as link text
|
|
insertMarkdownFormat(textareaId, '[', '](url)');
|
|
} else {
|
|
insertMarkdownText(textareaId, '[link text](url)');
|
|
}
|
|
}
|
|
|
|
function toolbarList(textareaId) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const text = textarea.value;
|
|
|
|
// Find start of current line
|
|
let lineStart = start;
|
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
|
lineStart--;
|
|
}
|
|
|
|
// Insert list marker at beginning of line
|
|
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
|
textarea.setSelectionRange(start + 2, start + 2);
|
|
textarea.focus();
|
|
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function toolbarHeading(textareaId) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const text = textarea.value;
|
|
|
|
// Find start of current line
|
|
let lineStart = start;
|
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
|
lineStart--;
|
|
}
|
|
|
|
// Insert heading marker at beginning of line
|
|
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
|
textarea.setSelectionRange(start + 3, start + 3);
|
|
textarea.focus();
|
|
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function toolbarQuote(textareaId) {
|
|
const textarea = document.getElementById(textareaId);
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const text = textarea.value;
|
|
|
|
// Find start of current line
|
|
let lineStart = start;
|
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
|
lineStart--;
|
|
}
|
|
|
|
// Insert quote marker at beginning of line
|
|
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
|
textarea.setSelectionRange(start + 2, start + 2);
|
|
textarea.focus();
|
|
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Create and insert toolbar HTML for a textarea
|
|
*/
|
|
function createEditorToolbar(textareaId, containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'editor-toolbar';
|
|
toolbar.innerHTML = `
|
|
<button type="button" onclick="toolbarBold('${textareaId}')" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
<button type="button" onclick="toolbarItalic('${textareaId}')" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
<button type="button" onclick="toolbarCode('${textareaId}')" title="Code"></></button>
|
|
<span class="toolbar-separator"></span>
|
|
<button type="button" onclick="toolbarHeading('${textareaId}')" title="Heading">H</button>
|
|
<button type="button" onclick="toolbarList('${textareaId}')" title="List">≡</button>
|
|
<button type="button" onclick="toolbarQuote('${textareaId}')" title="Quote">"</button>
|
|
<span class="toolbar-separator"></span>
|
|
<button type="button" onclick="toolbarLink('${textareaId}')" title="Link">🔗</button>
|
|
`;
|
|
|
|
container.insertBefore(toolbar, container.firstChild);
|
|
}
|
|
|
|
// Expose toolbar functions globally
|
|
window.toolbarBold = toolbarBold;
|
|
window.toolbarItalic = toolbarItalic;
|
|
window.toolbarCode = toolbarCode;
|
|
window.toolbarLink = toolbarLink;
|
|
window.toolbarList = toolbarList;
|
|
window.toolbarHeading = toolbarHeading;
|
|
window.toolbarQuote = toolbarQuote;
|
|
window.createEditorToolbar = createEditorToolbar;
|
|
window.insertMarkdownFormat = insertMarkdownFormat;
|
|
window.insertMarkdownText = insertMarkdownText;
|