Более дружественное создание категорий

Кто-то отметил, что создание категорий и тегов кажется громоздким, и что это трение может отпугнуть новых администраторов от внедрения Discourse.

Я попросил Gemini сгенерировать планировщик категорий. Результат выглядит довольно неплохо. Я не думаю, что его следует или можно добавлять в Discourse в таком виде по многим (хорошим) причинам, но его всё же интересно посмотреть. Это может вдохновить на идеи для будущего Discourse.

Контекст: How to add multiple tags up front

Перемещение категории вне родительской немного неудобно. Нужно перетащить её на родителя, а затем влево. Не интуитивно, но, опять же, это скорее концепция интерфейса, чем реальное решение.

chrome_Zl3VkfeCTI

Административный интерфейс Discourse может показаться суровым на первый взгляд. Программное обеспечение сложное, полно настроек и с годами стало ещё сложнее. Пересмотр некоторых частей администрирования, сделав их более визуальными, интерактивными и игровыми, особенно тех областей, с которыми люди сталкиваются сразу при попытке настроить базовые параметры своего сообщества, мог бы быть полезным.

Интересно, сколько людей отказываются от Discourse после того, как их отпугнул текущий интерфейс. :thinking:


Я решил создать эту тему после такой реакции на создание Gemini:

14 лайков

Мы в настоящее время планируем улучшить создание категорий, включая все различные функции, которые могут включать категории (голосование, решения и т. д.) — все ваши отзывы абсолютно верны!

14 лайков

Вау, это невероятно! Я уже использую это, и это именно то, о чём я думал. Пока только один вопрос по поводу разрешений: означает ли «group» всех участников? Если я хочу предоставить права подгруппе (например, «vets»), достаточно ли просто написать «vets»?

И ещё: можно ли на данном этапе импортировать .csv-файл в Discourse? (Это не критично, если нельзя — у меня уже есть замечательный интерфейс, в котором я могу экспериментировать со своей структурой).

4 лайка

По умолчанию это все участники, если вы не укажете группы. Когда вы нажмёте Добавить группу, система должна определить ваши существующие группы.

Казалось, что есть способ массового добавления через CSV, но я его не вижу. Хотя вас может удивить, сколько записей можно просто скопировать и вставить прямо в поле из CSV-файла.

1 лайк

Кажется, возникло недопонимание. Я спрашиваю о UI, который только что создал @Canapin… На данный момент у меня в Discourse нет никаких групп или настроек, я просто пытаюсь спроектировать структуру!

1 лайк

Этот инструмент появился после того, как я спросил у Gemini: «Как бы вы представили более дружелюбный интерфейс для управления категориями в Discourse?»

Это по сути интерактивный макет для экспериментов :slightly_smiling_face:.

Кнопки импорта/экспорта не тестировались, и экспортированные данные точно нельзя импортировать обратно в Discourse.

О группах: в реальном Discourse есть три разрешения (просмотр, ответ и создание), которые можно назначать встроенным или пользовательским группам. В этом инструменте поле разрешений, как и остальные, — просто произвольный заполнитель.

Воспринимайте это как расширенную доску для планирования категорий, а не как что-то связанное с самим Discourse :slightly_smiling_face:

3 лайка

Действительно впечатляет — уже вполне удобно в использовании! Я экспортировал множество версий того, что сделал, но ещё не пробовал импортировать (не посмел обновить вкладку, хехе).

Спасибо за уточнение по поводу поля разрешений в «интерактивном макете, который уже полезен» :slight_smile:

Для меня уже всё отлично как есть!

Вопрос только: есть ли способ импортировать список категорий в Discourse, так же как можно импортировать теги из CSV?

1 лайк

Существует множество скриптов для миграции всего форума, но я никогда не слышал о инструменте, предназначенном исключительно для создания пустых категорий.

Если процесс создания категорий через интерфейс кажется вам слишком трудоемким, возможно, у вас слишком много категорий… :sweat_smile:

После создания нескольких категорий вы быстро освоитесь. Я давно этого не делал, но только что добавил новую категорию с базовыми настройками цвета, позиции и разрешений, а также обновил тему приветствия примерно за 90 секунд: (видео)

(Отмечу, что несколько категорий в меню, показанном там, ограничены для одной или другой группы. Как администратор, я вижу всё, но большинство участников видят в своём списке только 6 или 7 категорий.)

1 лайк

@Canapin Итак, после небольшого ознакомления я по ошибке обновил окно браузера, что дало мне возможность попробовать кнопку «Импорт». Могу сообщить, что экспорт работает (см. прикреплённый файл), но импорт завершается без ошибок и безрезультатно :joy:

discourse_categories-7.csv (3.4 КБ)

Возможно, я и переборщил :sweat_smile:, но если мне нужно их сократить, интерфейс не тот инструмент, который поможет мне в этом :wink:

Знаете, я думаю, что проблема не столько во времени, необходимом для создания категории, сколько в когнитивной нагрузке из-за необходимости постоянно переключаться между экранами, где видна лишь небольшая часть общей структуры (одна категория и подмножество её настроек). Визуальный макет позволяет держать всё перед глазами, пока я работаю с категориями и настройками, что даёт возможность быстро итерировать и сразу видеть, что работает, а что нет.

2 лайка

Да, я полностью согласен насчёт того, что сначала нужно спланировать и набросать структуру. Я комментировал идею импорта списка категорий в Discourse, что может иметь смысл, если вы Proctor & Gamble, но для большинства из нас это слишком много усилий ради малого результата. :wink:

1 лайк

Если я захочу попробовать изменить инструмент, чтобы хотя бы импорт заработал, как мне это сделать? Есть ли способ создать «копию» вашего инструмента, чтобы работать с ним? Был ли ваш запрос действительно всего лишь несколькими словами?

Продолжаю тему. Сейчас я в Discourse пытаюсь привести в порядок свои категории. Это очень утомительный процесс.

  1. перейти на страницу «Все категории»
  2. кликнуть на категорию, которую нужно изменить
  3. кликнуть на ключик, внести изменения
  4. кликнуть «Сохранить» внизу, но никакого видимого подтверждения, что что-то действительно произошло
  5. ладно, полагаю, я закончил, куда теперь идти? ах, «назад к категории»
  6. хм, сейчас я снова на странице категории, которая мне больше не интересна, как вернуться на главную страницу категорий? ах, боковая панель > все категории…看来我 стоило нажать на это раньше

Некоторые мои ожидания в процессе:

  • при нажатии «Сохранить» я хочу видеть на странице подтверждение, что сохранение действительно выполнено
  • для моего случая, когда я перебираю все категории, чтобы перестроить их, изменить права доступа, цвета, описания или что-то ещё, мне нужно (хотя бы) возвращаться на главную страницу категорий. Да, такая ссылка есть в боковой панели, но я постоянно искал что-то в основной области контента, что «вернуло бы меня туда, куда нужно»
  • даже если мы не получим что-то столь же красивое, как визуальный макет, представленный в этой ветке, было бы гораздо удобнее иметь большую таблицу со всеми категориями и важными настройками, которые можно изменять. Это позволило бы выполнить первоначальную настройку и уборку категорий, не кликая (буквально) десятки раз по экранам, когда стратегия структуры сообщества меняется, без хорошего общего обзора.
2 лайка

Если вы хотите редактировать следующую категорию, может быть полезно использовать выпадающий список для перехода к ней.

Также я не уверена, что вы ожидаете от нажатия кнопки «Сохранить». На моём форуме кнопка меняется на «Сохранение», а после завершения снова становится «Сохранить».

О, ты прав, кто-то (ты?) уже упоминал об этом, но я забыл.

Если это происходит (возможно, так и есть!), то это происходит так быстро, что я ничего не замечаю. Кроме того, должен сказать, что как только мой мозг отдаёт приказ пальцу нажать, мои глаза уже смотрят в другое место. Я бы ожидал какое-то небольшое сообщение в верхней части страницы «Сохранено» — как, например, в WordPress, думаю :wink:

I gave Gemini the main attributes of a category (name, color, tags, etc) and asked: “Can you output a js/html5 tool to create and organize categories with these attributes? We should also be able to quickly edit and rearrange”.

The produced tool was already great, but I fine-tuned it by asking to remove/add attributes and features as well as tweaking the design.

Probably. You can send the code to Gemini (use the Canvas mode) and ask it to tweak it like you want:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Discourse Category Planner</title>
    <style>
        /* Base Styling */
        body { font-family: 'Inter', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background-color: #f8f9fa; }
        h1 { border-bottom: 2px solid #e9ecef; padding-bottom: 10px; margin-bottom: 20px; color: #343a40; }
        
        /* Layout */
        .main-container { display: flex; gap: 30px; }
        @media (max-width: 900px) {
            .main-container { flex-direction: column; }
        }
        .form-section { flex: 1.2; padding: 15px; border-radius: 8px; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
        .list-section { flex: 1.8; padding: 15px; border-radius: 8px; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }

        /* Form Controls */
        .form-row { display: flex; gap: 15px; margin-bottom: 10px; }
        .form-group { flex: 1; display: flex; flex-direction: column; }
        label { font-weight: 600; margin-bottom: 3px; font-size: 0.9em; color: #495057; }
        input[type="text"], select, textarea { padding: 8px; border: 1px solid #ced4da; border-radius: 4px; }
        
        /* Buttons */
        .action-button { padding: 10px 15px; border: none; border-radius: 44px; cursor: pointer; background: #007bff; color: white; margin-top: 10px; transition: background 0.2s; }
        .action-button:hover { background: #0056b3; }
        .utility-buttons { display: flex; gap: 10px; margin-top: 20px; }
        .utility-buttons .action-button { background: #6c757d; }
        .utility-buttons .action-button:hover { background: #5a6268; }


        /* --- Color Input Styling --- */
        input[type="color"] {
            -webkit-appearance: none;
            -moz-appearance: none;
            appearance: none;
            border: 1px solid #ced4da;
            padding: 0;
            height: 38px;
            cursor: pointer;
            border-radius: 4px;
            overflow: hidden;
        }
        input[type="color"]::-webkit-color-swatch-wrapper {
            padding: 0;
        }
        input[type="color"]::-webkit-color-swatch {
            border: none;
            border-radius: 4px;
            height: 100%; 
            display: block;
        }
        /* --- End Color Input Styling --- */
        
        /* Management List Styles (Draggable) */
        #category-list { list-style: none; padding: 0; margin-top: 15px; }
        .category-item { 
            display: flex; 
            align-items: center; 
            padding: 12px; 
            border: 1px solid #dee2e6; 
            border-radius: 6px; 
            margin-bottom: 8px; 
            cursor: move; 
            background: #ffffff;
            transition: box-shadow 0.2s;
        }
        .category-item:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
        .category-item.dragging { opacity: 0.6; border: 2px dashed #007bff; background: #eaf3ff; }
        
        /* Subcategory Indentation in Management List (Middle Column) */
        .category-item.is-sub {
            margin-left: 30px;
            border-left: 3px solid #ced4da; 
            background-color: #f7f7f7;
        }

        /* --- DRAG HINT STYLES --- */
        .category-item.drag-over { border-bottom: 2px solid #007bff; }
        .category-item.indent-hint {
            border-bottom: 2px solid #28a745;
            box-shadow: 5px 0 0 0 #28a745 inset;
            margin-left: 30px !important;
            background-color: #eaf3ff;
        }
        .category-item.outdent-hint {
            border-bottom: 2px solid #dc3545;
            box-shadow: -5px 0 0 0 #dc3545 inset;
            margin-left: 0 !important;
            background-color: #ffeaea;
        }
        /* --- END DRAG HINT STYLES --- */


        .category-actions { flex-shrink: 0; display: flex; gap: 5px; }

        /* --- Category Display Container (Applies to both Management List and Preview) --- */
        .category-info, .preview-category { 
            flex-grow: 1; 
            display: flex;
            align-items: flex-start; 
            padding: 0; 
        }
        
        /* Wrapper for the colored elements (Vertical Bar + Square + Text) */
        .category-display-block {
            display: flex;
            flex-direction: column;
            padding-left: 8px; 
            border-left: 4px solid transparent; 
            flex-grow: 1;
        }

        /* Wrapper for the Square and Name/Text */
        .square-and-text-wrapper {
            display: flex;
            align-items: flex-start;
        }

        /* Colored Square - Removed border-radius */
        .color-square {
            width: 12px;
            height: 12px;
            border-radius: 0; 
            margin-right: 8px;
            flex-shrink: 0;
            border: 1px solid rgba(0,0,0,0.1);
            margin-top: 3px;
        }

        /* Category titles use default color (dark gray/black) and are bold */
        .category-name { 
            font-size: 1em; 
            font-weight: 700;
            color: #343a40; 
        } 
        .category-desc { font-size: 0.8em; color: #6c757d; }

        /* --- Pill Styling --- */
        .pill-container, .preview-pill-wrapper { 
            margin-top: 5px; 
            display: flex; 
            flex-wrap: wrap; 
            gap: 6px; 
            align-items: center; 
        }

        /* Permissions - Subtler styling */
        .management-permission-pill, .preview-permission-pill { 
            font-size: 0.7em; 
            font-weight: 600; 
            background: #e9ecef; /* Lighter background */
            color: #495057; /* Darker text */
            padding: 3px 6px; 
            border-radius: 4px;
            text-transform: uppercase;
            border: 1px solid #ced4da; /* Add a subtle border for definition */
        }

        .attr-pill, .preview-tag-pill { /* Tags */
            font-size: 0.7em; 
            background: #f8f9fa; /* Very light background for less emphasis */
            color: #495057;
            padding: 3px 6px; 
            border-radius: 4px;
            display: inline-block;
            border: 1px solid #e9ecef; /* Subtle border */
        }
        
        /* Edit/Delete Button Styles */
        .edit-btn, .delete-btn { 
            background: #6c757d; 
            color: white; 
            padding: 5px 8px; 
            font-size: 0.75em; 
            border-radius: 4px; 
            border: none; 
        }
        .delete-btn { 
            background: #dc3545; 
        }
        
    </style>
</head>
<body>
    <h1>📋 Discourse Category & Hierarchy Planner</h1>
    
    <div class="main-container">
        
        <div class="form-section">
            <h2>Add/Edit Category Attributes</h2>
            <form id="category-form">
                <input type="hidden" id="category-id">
                <div class="form-row">
                    <div class="form-group">
                        <label for="name">1. Name</label> 
                        <input type="text" id="name" required oninput="updateLiveFormPreview()">
                    </div>
                    <div class="form-group">
                        <label for="color">2. Color</label>
                        <!-- Custom styled color input -->
                        <input type="color" id="color" value="#007bff" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <div class="form-row">
                    <div class="form-group">
                        <label for="description">3. Description</label>
                        <textarea id="description" rows="2" oninput="updateLiveFormPreview()"></textarea>
                    </div>
                    <div class="form-group">
                        <label for="permissions">4. Permissions (e.g., public, staff, group)</label> 
                        <input type="text" id="permissions" value="Public" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <div class="form-row">
                    <div class="form-group" style="flex: 2;">
                        <label for="tags">5. Tags (Comma separated, e.g., "bug, feature")</label>
                        <input type="text" id="tags" placeholder="Optional tags for posts in this category" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <input type="hidden" id="parentId" value=""> 

                <div class="form-row">
                    <div class="form-group" style="flex: 2;">
                        <button type="submit" id="submit-btn" class="action-button">Add New Category</button>
                    </div>
                </div>
            </form>

            <div id="live-form-preview" style="padding: 10px; border: 1px dashed #ced4da; border-radius: 6px; background: #fefefe;">
                <h3 style="margin-top: 0; font-size: 1em;">Preview</h3>
                <div class="preview-category" style="padding: 10px; border: none; align-items: flex-start;">
                    <div class="category-display-block" id="preview-display-block" style="border-left-color: #007bff; padding-left: 8px;">
                        <div class="square-and-text-wrapper">
                            <div class="color-square" id="preview-color-bar-field" style="background-color: #007bff;"></div>
                            <div class="category-name" id="preview-name-field">[Category Name]</div>
                        </div>
                        <div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[Description goes here]</div>
                        <div class="preview-pill-wrapper" id="preview-pills-field">
                            <!-- Pills will be inserted here by JS -->
                        </div>
                    </div>
                </div>
            </div>
            
            <div class="utility-buttons">
                <button id="export-btn" class="action-button">Export (CSV)</button>
                <button id="import-btn" class="action-button">Import (CSV)</button>
                <input type="file" id="csv-file-input" accept=".csv" style="display: none;">
            </div>
        </div>

        <div class="list-section">
            <h2>Category Management (Drag to Reorder)</h2>
            <p style="font-size: 0.85em; color: #6c757d;">
                Drag an element to reorder. Drag to the **right** to create a subcategory (Green hint), drag to the **left** to promote (Red hint).
            </p>
            <ul id="category-list">
                <!-- Categories will be rendered here by JavaScript -->
            </ul>

            <ul id="live-preview-list" style="display: none;"></ul>
        </div>
        
    </div>

    <script>
        let categories = []; 
        let dragStartX = 0; 
        
        const form = document.getElementById('category-form');
        const list = document.getElementById('category-list');
        const previewList = document.getElementById('live-preview-list'); 
        const submitBtn = document.getElementById('submit-btn');
        const exportBtn = document.getElementById('export-btn');
        const importBtn = document.getElementById('import-btn');
        const csvFileInput = document.getElementById('csv-file-input');
        
        // --- CSV Export/Import Handlers ---

        function arrayToCsv(data) {
            const headers = ['id', 'name', 'description', 'color', 'permissions', 'tags', 'parentId'];
            let csv = headers.join(',') + '\n';
            data.forEach(row => {
                const values = headers.map(header => {
                    let value = row[header] || '';
                    // Escape commas and quotes for CSV format
                    if (typeof value === 'string') {
                        value = value.replace(/"/g, '""');
                        if (value.includes(',') || value.includes('\n')) {
                            value = `"${value}"`;
                        }
                    }
                    return value;
                });
                csv += values.join(',') + '\n';
            });
            return csv;
        }

        function csvToArray(csv) {
            const lines = csv.split('\n').filter(line => line.trim() !== '');
            if (lines.length < 2) return [];

            const headers = lines[0].split(',');
            const data = [];

            for (let i = 1; i < lines.length; i++) {
                const values = lines[i].match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || lines[i].split(',');
                if (values.length === headers.length) {
                    let row = {};
                    for (let j = 0; j < headers.length; j++) {
                        let value = values[j].trim();
                        if (value.startsWith('"') && value.endsWith('"')) {
                            value = value.substring(1, value.length - 1).replace(/""/g, '"');
                        }
                        row[headers[j]] = value;
                    }
                    data.push(row);
                }
            }
            return data;
        }


        exportBtn.addEventListener('click', () => {
            const csvData = arrayToCsv(categories);
            const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
            const link = document.createElement('a');
            
            if (link.download !== undefined) {
                const url = URL.createObjectURL(blob);
                link.setAttribute('href', url);
                link.setAttribute('download', 'discourse_categories.csv');
                link.style.visibility = 'hidden';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
            } else {
                alert('Export not supported in this browser.');
            }
        });

        importBtn.addEventListener('click', () => {
            csvFileInput.click();
        });

        csvFileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = (e) => {
                const csvContent = e.target.result;
                const importedData = csvToArray(csvContent);
                
                if (importedData.length > 0) {
                    categories = importedData;
                    renderAll();
                    alert(`Successfully imported ${importedData.length} categories.`);
                } else {
                    alert('Error importing CSV: Data is empty or incorrectly formatted.');
                }
            };
            reader.readAsText(file);
        });

        // --- Core Data Management & Rendering ---

        const findParent = (parentId) => categories.find(c => c.id === parentId);

        // Recursive function to find all descendants of a category ID
        function getDescendantIds(parentId) {
            let descendants = [];
            const children = categories.filter(c => c.parentId === parentId);
            
            children.forEach(child => {
                descendants.push(child.id);
                descendants = descendants.concat(getDescendantIds(child.id)); 
            });
            return descendants;
        }

        // Function to update the simple preview box next to the form
        function updateLiveFormPreview() {
            const name = document.getElementById('name').value || '[Category Name]';
            const description = document.getElementById('description').value || '[Description goes here]';
            const color = document.getElementById('color').value;
            const permissions = document.getElementById('permissions').value;
            const tagsInput = document.getElementById('tags').value;

            // Apply color to the square and left border
            document.getElementById('preview-color-bar-field').style.backgroundColor = color;
            document.getElementById('preview-display-block').style.borderLeftColor = color;
            
            document.getElementById('preview-name-field').textContent = name;
            document.getElementById('preview-desc-field').textContent = description;
            
            // Generate Pills HTML
            let pillsHtml = `<span class="preview-permission-pill">${permissions}</span>`;

            if (tagsInput) {
                const tagsArray = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
                tagsArray.forEach(tag => {
                    pillsHtml += `<span class="preview-tag-pill">${tag}</span>`;
                });
            }

            document.getElementById('preview-pills-field').innerHTML = pillsHtml;
        }


        // Function to render the management list
        function renderAll() {
            list.innerHTML = ''; 
            previewList.innerHTML = '';
            
            let finalOrder = [];
            const parentCategories = categories.filter(c => !c.parentId);
            const subCategories = categories.filter(c => c.parentId);

            parentCategories.sort((a, b) => categories.indexOf(a) - categories.indexOf(b)).forEach(parent => {
                finalOrder.push(parent);
                const children = subCategories.filter(c => c.parentId === parent.id);
                children.sort((a, b) => categories.indexOf(a) - categories.indexOf(b)).forEach(child => {
                    finalOrder.push(child);
                });
            });

            subCategories.filter(c => !findParent(c.parentId)).forEach(orphan => {
                finalOrder.push(orphan);
            });


            finalOrder.forEach(cat => {
                const isSub = !!cat.parentId;
                
                // --- 1. Render Management List Item (Draggable) ---
                const managementItem = document.createElement('li');
                managementItem.className = 'category-item';
                if (isSub) {
                    managementItem.classList.add('is-sub');
                }
                managementItem.setAttribute('data-id', cat.id);
                managementItem.setAttribute('draggable', true);

                // Build tags HTML
                const tagsHtml = cat.tags ? cat.tags.split(',').map(tag => `<span class="attr-pill">${tag.trim()}</span>`).join('') : '';

                managementItem.innerHTML = `
                    <div class="category-info">
                        <div class="category-display-block" style="border-left-color: ${cat.color};">
                            <div class="square-and-text-wrapper">
                                <span class="color-square" style="background-color: ${cat.color};"></span>
                                <div class="category-name">${cat.name} ${isSub ? '— Subcategory' : ''}</div>
                            </div>
                            <div class="category-desc">${cat.description || ''}</div>
                            <div class="pill-container">
                                <span class="management-permission-pill">${cat.permissions}</span>
                                ${tagsHtml}
                            </div>
                        </div>
                    </div>
                    <div class="category-actions">
                        <button class="edit-btn" onclick="editCategory('${cat.id}')">Edit</button>
                        <button class="delete-btn" onclick="deleteCategory('${cat.id}')">Delete</button>
                    </div>
                `;
                list.appendChild(managementItem);
            });
            
            addDragDropListeners();
        }

        // Function to handle form submission (Add or Update)
        form.addEventListener('submit', (e) => {
            e.preventDefault();
            
            const idField = document.getElementById('category-id');
            const id = idField.value;
            
            const newCategory = {
                id: id || 'cat_' + Date.now(), 
                name: document.getElementById('name').value,
                description: document.getElementById('description').value,
                color: document.getElementById('color').value,
                permissions: document.getElementById('permissions').value,
                tags: document.getElementById('tags').value.trim(),
                parentId: '',
            };
            
            if (id) {
                const index = categories.findIndex(c => c.id === id);
                if (index !== -1) {
                    newCategory.parentId = categories[index].parentId; 
                    categories[index] = newCategory;
                }
            } else {
                categories.push(newCategory);
            }

            form.reset();
            idField.value = '';
            submitBtn.textContent = 'Add New Category';
            updateLiveFormPreview();
            renderAll();
        });

        // Function to populate the form for editing
        function editCategory(id) {
            const cat = categories.find(c => c.id === id);
            if (!cat) return;

            document.getElementById('category-id').value = cat.id;
            document.getElementById('name').value = cat.name;
            document.getElementById('description').value = cat.description;
            document.getElementById('color').value = cat.color;
            document.getElementById('permissions').value = cat.permissions;
            document.getElementById('tags').value = cat.tags || '';
            
            submitBtn.textContent = 'Update Category';
            updateLiveFormPreview();
            window.scrollTo(0, 0); 
        }

        // Function to delete a category
        function deleteCategory(id) {
            // Removed confirm() call to allow execution in sandboxed environment
            categories = categories.filter(c => c.id !== id && c.parentId !== id); 
            renderAll();
        }


        // --- Drag and Drop Logic for Rearrangement and Nesting ---

        let draggedItem = null;

        function removeAllDragHints() {
            document.querySelectorAll('.category-item').forEach(li => {
                li.classList.remove('indent-hint', 'outdent-hint', 'drag-over');
                const cat = categories.find(c => c.id === li.dataset.id);
                if (cat) {
                    li.style.marginLeft = cat.parentId ? '30px' : '0';
                }
            });
        }

        function addDragDropListeners() {
            document.querySelectorAll('.category-item').forEach(item => {
                
                item.addEventListener('dragstart', (e) => {
                    draggedItem = item;
                    e.dataTransfer.effectAllowed = 'move';
                    e.dataTransfer.setData('text/plain', item.dataset.id);
                    dragStartX = e.clientX; // Capture starting X position
                    setTimeout(() => item.classList.add('dragging'), 0);
                });

                item.addEventListener('dragend', () => {
                    draggedItem.classList.remove('dragging');
                    draggedItem = null;
                    removeAllDragHints(); 
                });

                item.addEventListener('dragleave', (e) => {
                    e.currentTarget.classList.remove('indent-hint', 'outdent-hint', 'drag-over');
                });

                item.addEventListener('dragover', (e) => {
                    e.preventDefault(); 
                    e.dataTransfer.dropEffect = 'move';
                    
                    const dropTargetElement = e.currentTarget;
                    const dropTargetId = dropTargetElement.dataset.id;
                    const dropTargetCat = categories.find(c => c.id === dropTargetId);
                    const draggedCat = categories.find(c => c.id === draggedItem.dataset.id);
                    
                    if (draggedItem === dropTargetElement) return;

                    removeAllDragHints();
                    dropTargetElement.classList.add('drag-over');

                    const INDENT_THRESHOLD_PX = 40; 
                    const OUTDENT_SHIFT_PX = 50; 
                    const currentX = e.clientX;
                    const horizontalShift = currentX - dragStartX; 
                    
                    // --- LOGIC FOR HINT APPLICATION ---

                    // 1. Check for INDENT (Drag Right)
                    if (horizontalShift > INDENT_THRESHOLD_PX) {
                        // Check if the target can accept children (i.e., is not already a child)
                        if (!dropTargetCat.parentId) {
                            dropTargetElement.classList.add('indent-hint');
                            dropTargetElement.classList.remove('drag-over');
                        }
                    } 
                    
                    // 2. Check for OUTDENT (Drag Left)
                    else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
                        dropTargetElement.classList.add('outdent-hint');
                        dropTargetElement.classList.remove('drag-over');
                    }
                    
                    // 3. Middle Ground: No hint means only vertical reordering occurs on drop.
                });

                item.addEventListener('drop', (e) => {
                    e.preventDefault();
                    if (draggedItem !== e.currentTarget) {
                        
                        const dropTargetElement = e.currentTarget;
                        const dropTargetId = dropTargetElement.dataset.id;
                        const draggedId = draggedItem.dataset.id;
                        
                        let draggedIndex = categories.findIndex(c => c.id === draggedId);
                        let dropIndex = categories.findIndex(c => c.id === dropTargetId);

                        // 1. Re-sequence the categories array (Linear Order)
                        if (draggedIndex !== -1 && dropIndex !== -1) {
                            const [temp] = categories.splice(draggedIndex, 1);
                            categories.splice(dropIndex, 0, temp);
                        }

                        // Re-find indices after resequencing
                        const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
                        const draggedCat = categories[newDraggedIndex];
                        
                        // 2. Determine New Parent ID based on the active hint
                        
                        let newParentId = draggedCat.parentId; // Default: Keep current hierarchy status
                        let updateDescendants = false;

                        if (dropTargetElement.classList.contains('indent-hint')) {
                            // Promoting to child: The dragged category's new parent is the drop target.
                            newParentId = dropTargetId;
                            updateDescendants = true; 
                        } else if (dropTargetElement.classList.contains('outdent-hint')) {
                            // Promoting to parent: The dragged category's new parent is null/empty.
                            newParentId = '';
                            updateDescendants = true; 
                        }
                        
                        draggedCat.parentId = newParentId;

                        // 3. Update Descendants if hierarchy changed for the dragged parent
                        if (updateDescendants) {
                            const descendantIds = getDescendantIds(draggedId);
                            descendantIds.forEach(descendantId => {
                                const descendantCat = categories.find(c => c.id === descendantId);
                                if (descendantCat) {
                                    // Descendants follow the dragged parent's new status
                                    descendantCat.parentId = newParentId;
                                }
                            });
                        }

                        removeAllDragHints(); 
                        renderAll(); 
                    }
                });
            });
        }

        // --- Initial Load (Updated Categories) ---

        categories.push({
            id: 'cat_001', name: 'General', description: 'General community discussion and help.', color: '#007bff', permissions: 'Public', parentId: '', tags: 'tag 1, tag 2'
        });
        categories.push({
            id: 'cat_004', name: 'Subcategory Example', description: '', color: '#007bff', permissions: 'Public', parentId: 'cat_001', tags: 'tag 3'
        });
        categories.push({
            id: 'cat_002', name: 'Staff', description: 'Private area for moderation and administration.', color: '#ffc107', permissions: 'Staff Only', parentId: '', tags: ''
        });
        categories.push({
            id: 'cat_003', name: 'Site Feedback', description: 'Discuss the forum itself and suggest improvements.', color: '#28a745', permissions: 'Public', parentId: '', tags: 'site, feedback'
        });

        renderAll();
        updateLiveFormPreview();
    </script>
</body>
</html>
2 лайка

Мы добавляем уведомления о сохранении в различных местах, и это кажется разумным вариантом. При следующем обновлении Discourse вы увидите что-то вроде этого:

3 лайка

Теперь также появилась возможность устанавливать плейсхолдеры заголовков для каждой категории:

4 лайка