diff --git a/.gitignore b/.gitignore index 59d459b..95ca378 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ front_vue/node_modules/ front_vue/dist/ # Публичные файлы backend -backend/public/* \ No newline at end of file +backend/public/* + +# Личные файлы +deploy.js +deploy.php \ No newline at end of file diff --git a/front_vue/src/components/Card.vue b/front_vue/src/components/Card.vue index 7639b42..3a30549 100644 --- a/front_vue/src/components/Card.vue +++ b/front_vue/src/components/Card.vue @@ -44,7 +44,7 @@ Создано: {{ formatDateWithYear(card.dateCreate) }} - + {{ daysLeftText }} diff --git a/front_vue/src/components/TaskPanel.vue b/front_vue/src/components/TaskPanel.vue index f315d88..ecef6ba 100644 --- a/front_vue/src/components/TaskPanel.vue +++ b/front_vue/src/components/TaskPanel.vue @@ -41,12 +41,32 @@
- - +
+ +
+ + + +
+
+ +
@@ -340,6 +360,7 @@ const dropdownRef = ref(null) const searchInput = ref(null) const panelRef = ref(null) const fileInput = ref(null) +const detailsInput = ref(null) const dropdownOpen = ref(false) const userSearch = ref('') @@ -591,6 +612,9 @@ watch(() => props.show, async (newVal) => { await nextTick() saveInitialForm() + // Устанавливаем содержимое редактора описания + setEditorContent(form.details) + await nextTick() titleInput.value?.focus() refreshIcons() @@ -831,6 +855,129 @@ const refreshIcons = () => { } } +// Применить форматирование (toggle) +const applyFormat = (command) => { + detailsInput.value?.focus() + document.execCommand(command, false, null) + syncDetailsFromEditor() +} + +// Синхронизация содержимого редактора с form.details +const syncDetailsFromEditor = () => { + if (!detailsInput.value) return + // Нормализуем переносы строк в
+ let html = detailsInput.value.innerHTML + html = html.replace(/<\/div>
/gi, '
') + html = html.replace(/
/gi, '') + html = html.replace(/<\/div>/gi, '') + form.details = html +} + +// Обработка ввода +const onDetailsInput = () => { + syncDetailsFromEditor() +} + +// Обработка клика (открытие ссылок) +const onDetailsClick = (e) => { + // Если кликнули по ссылке — открываем в новой вкладке + if (e.target.tagName === 'A') { + e.preventDefault() + window.open(e.target.href, '_blank') + } +} + +// Обработка вставки (очистка форматирования из внешних источников) +const onDetailsPaste = (e) => { + e.preventDefault() + const text = e.clipboardData.getData('text/plain') + document.execCommand('insertText', false, text) + // Подсвечиваем ссылки после вставки + setTimeout(linkifyContent, 0) +} + +// Горячие клавиши +const onDetailsKeydown = (e) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === 'b') { + e.preventDefault() + applyFormat('bold') + } else if (e.key === 'i') { + e.preventDefault() + applyFormat('italic') + } else if (e.key === 'u') { + e.preventDefault() + applyFormat('underline') + } + } + + // После пробела или Enter — подсвечиваем ссылки + if (e.key === ' ' || e.key === 'Enter') { + setTimeout(linkifyContent, 0) + } +} + +// Автоматическое преобразование URL в ссылки (вызывается после пробела/Enter) +const linkifyContent = () => { + if (!detailsInput.value) return + + const html = detailsInput.value.innerHTML + + // Ищем URL которые ещё не в тегах + // Разбиваем HTML на части: теги ... и остальной текст + const parts = html.split(/(]*>.*?<\/a>)/gi) + + let changed = false + const newParts = parts.map(part => { + // Если это уже ссылка — не трогаем + if (part.startsWith('"]+)/g, + '$1' + ) + if (newPart !== part) changed = true + return newPart + }) + + if (changed) { + // Сохраняем позицию курсора (в конце) + detailsInput.value.innerHTML = newParts.join('') + + // Ставим курсор в конец + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(detailsInput.value) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) + + syncDetailsFromEditor() + } +} + +// Установка содержимого редактора +const setEditorContent = (html) => { + if (detailsInput.value) { + detailsInput.value.innerHTML = linkifyHtml(html || '') + } +} + +// Простое преобразование URL в ссылки (без сложных regex) +const linkifyHtml = (html) => { + if (!html) return '' + + // Если уже есть теги — не трогаем + if (html.includes('"]+)/g, + '$1' + ) +} + onMounted(() => { refreshIcons() document.addEventListener('click', handleClickOutside) @@ -967,6 +1114,48 @@ onUpdated(refreshIcons) letter-spacing: 0.5px; } +.field-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.format-buttons { + display: flex; + gap: 3px; +} + +.format-btn { + width: 24px; + height: 24px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.format-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +.format-btn:active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} + +.format-btn i { + width: 10px; + height: 10px; +} + .field input { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); @@ -1009,6 +1198,73 @@ onUpdated(refreshIcons) transition: border-color 0.15s, background 0.15s; } +/* Редактор описания с форматированием */ +.details-editor { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 14px 16px; + min-height: 120px; + max-height: 400px; + overflow-y: auto; + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + transition: border-color 0.15s, background 0.15s; + word-break: break-word; + outline: none; + resize: vertical; +} + +.details-editor:focus { + border-color: var(--accent); + background: rgba(255, 255, 255, 0.06); +} + +.details-editor:empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + pointer-events: none; +} + +.details-editor::-webkit-scrollbar { + width: 6px; +} + +.details-editor::-webkit-scrollbar-track { + background: transparent; +} + +.details-editor::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.details-editor :deep(a) { + color: var(--accent); + text-decoration: none; + cursor: pointer; +} + +.details-editor :deep(a:hover) { + text-decoration: underline; + cursor: pointer; +} + +.details-editor :deep(b), +.details-editor :deep(strong) { + font-weight: 600; +} + +.details-editor :deep(i), +.details-editor :deep(em) { + font-style: italic; +} + +.details-editor :deep(u) { + text-decoration: underline; +} + .field textarea::-webkit-scrollbar { width: 6px; }