더 친근한 카테고리 생성

누군가는 카테고리와 태그를 만드는 과정이 번거롭다고 지적하며, 이러한 마찰이 신규 관리자들로 하여금 Discourse 도입을 꺼리게 만들 수 있다고 언급했습니다.

저는 Gemini에게 카테고리 플래너를 생성해 달라고 요청했습니다. 그 결과는 꽤 멋져 보입니다. 많은 (좋은) 이유들로 인해 그대로 Discourse에 추가되거나 추가될 수 있다고 생각하지는 않지만, 살펴볼 만한 흥미로운 결과물입니다. 이는 Discourse의 미래를 위한 아이디어를 자극할 수도 있습니다.

관련 링크: How to add multiple tags up front

카테고리를 상위 카테고리 밖으로 이동하는 과정은 다소 어색합니다. 상위 카테고리에 드래그한 다음 왼쪽으로 이동해야 합니다. 직관적이지는 않지만, 다시 한번 말하지만 이는 실제 해결책이라기보다는 인터페이스 개념에 가깝습니다.

chrome_Zl3VkfeCTI

Discourse 관리자 UI는 처음에는 다소 딱딱하게 느껴질 수 있습니다. 이 소프트웨어는 복잡하며, 설정이 많고, 수년에 걸쳐 더욱 복잡해졌습니다. 특히 커뮤니티의 기본 옵션을 설정하려고 할 때 사람들이 바로 마주하는 영역들을 더 시각적이고, 인터랙티브하며, 놀이처럼 재미있게 만드는 방식으로 관리 기능의 일부에 대한 전면 개편이 가치가 있을 수 있습니다.

현재 인터페이스에 압도되어 Discourse를 포기하는 사람이 얼마나 될지 궁금합니다. :thinking:


Gemini의 생성물에 대한 이 반응 이후에 이 주제를 만들기로 결정했습니다:

14개의 좋아요

현재 카테고리 생성 기능을 개선하는 작업을 계획하고 있습니다. 카테고리에서 사용할 수 있는 다양한 기능(투표, 해결책 등)을 모두 포함합니다. 여러분의 피드백은 모두 매우 정확합니다!

14개의 좋아요

와, 이건 정말 놀랍네요. 지금 바로 사용 중인데, 제가 생각했던 바로 그 기능이네요. 이 시점에서 한 가지 질문이 있는데, 권한 설정에서 “group”이 모든 구성원을 의미하나요? 하위 그룹(예: “vets”)에 권한을 부여하고 싶다면, 그냥 “vets”라고 입력하면 되는 건가요?

아, 그리고: .csv 파일을 현재 시점에서 Discourse에 가져올 수 있나요? (아니면 안 되도 큰 문제는 아니에요. 제 구조를 자유롭게 다룰 수 있는 인터페이스만 있어도 이미 훌륭하니까요)

4개의 좋아요

기본적으로 그룹을 지정하지 않으면 모든 사람이 대상이 됩니다. Add A Group 버튼을 클릭하면 기존에 있는 그룹이 감지되어야 합니다.

CSV로 일괄 추가하는 방법이 있는 것 같았는데, 지금은 안 보이네요. 하지만 CSV에서 직접 복사해서 해당 필드에 붙여넣을 수 있는 항목이 의외로 많다는 걸 알게 되면 놀랄 겁니다.

1개의 좋아요

어떤 혼동이 있는 것 같은데요. 저는 @Canapin 이 방금 만든 UI에 대해 질문하는 겁니다… 당장은 Discourse에서 그룹이나 기타 설정을 해놓은 게 전혀 없어요. 그냥 구조를 설계해 보는 중일 뿐입니다!

1개의 좋아요

이 도구는 제미니(Gemini)에게 "디스커스에서 카테고리 관리를 위한 더 친근한 인터페이스라면 어떻게 상상할 수 있을까?"라고 물은 것에서 출발했습니다.

실제로는 그냥 놀아볼 수 있는 인터랙티브 목업일 뿐입니다 :slightly_smiling_face:.

가져오기/내보내기 버튼은 테스트되지 않았으며, 내보낸 데이터는 확실히 디스커스에 가져올 수 없습니다.

그룹에 대해 말씀드리자면, 실제 디스커스에는 내장 그룹이나 사용자 정의 그룹에 할당할 수 있는 세 가지 권한(보기, 답변, 생성)이 있습니다. 이 도구에서는 권한 필드가 나머지 부분과 마찬가지로 임의의 플레이스홀더일 뿐입니다.

이것을 디스커스 자체와 연결된 것이라기보다는 카테고리를 계획하기 위한 상세한 화이트보드라고 생각해 주세요 :slightly_smiling_face:

3개의 좋아요

정말 인상적입니다 – 이미 꽤 쓸만해 보이네요! 제가 만든 것들의 여러 버전을 내보냈지만, 아직 가져오기를 시도해 보진 못했습니다 (탭을 새로고침할 용기가 없었거든요, 헤헤).

"이미 도움이 되는 인터랙티브 목업"의 권한 필드에 대한 설명을 해 주셔서 감사합니다 :slight_smile:

지금 상태 그대로도 저에게는 충분합니다!

다만, 질문이 하나 있습니다. CSV에서 태그를 가져오듯, 카테고리 목록을 Discourse에 가져올 수 있는 방법이 있을까요?

1개의 좋아요

포럼 전체를 이전하는 스크립트는 많이 있지만, 빈 카테고리 설정만을 위한 도구에 대해서는 들어본 적이 없습니다.

인터페이스를 통해 카테고리를 만드는 것이 너무 번거롭게 느껴진다면, 카테고리가 너무 많을 수도 있겠네요… :sweat_smile:

몇 개만 만들어 보면 금방 익숙해집니다. 저는 꽤 오래 전부터 하지 않았는데, 기본 색상, 위치, 권한 설정과 업데이트된 환영 게시글을 포함하여 새 카테고리를 약 90초 만에 추가했습니다: (video)

(거기서 보이는 메뉴의 여러 카테고리가 특정 그룹으로 제한되어 있음을 참고해 주세요. 관리자로서 저는 모든 것을 볼 수 있지만, 대부분의 회원은 목록에서 6~7개 정도의 카테고리만 보입니다.)

1개의 좋아요

@Canapin 잠시 이것을 사용해 보고 나서 실수로 브라우저 창을 새로고침했는데, 덕분에 “가져오기” 버튼을 테스트해 볼 기회가 생겼습니다. 내보내기는 정상적으로 작동합니다(첨부된 파일 참조) 하지만 가져오기는 아무런 알림 없이 실패합니다 :joy:

discourse_categories-7.csv (3.4 KB)

나도 아마 그런 것 같은데 :sweat_smile:, 만약 카테고리를 줄여야 한다면 인터페이스가 그걸 도와줄 곳은 아닐 거예요 :wink:

카테고리를 만드는 데 걸리는 실제 시간이 문제라기보다는, 전체 구조 중 일부(하나의 카테고리… 그리고 그에 대한 설정의 일부)만 보이는 화면 사이를 오가야 하는 인지 부하가 문제인 것 같습니다. 시각적 목업(visual mockup)은 카테고리와 설정을 조정하는 동안 모든 것을 눈앞에 두게 해주어, 어떤 것이 작동하고 어떤 것이 작동하지 않는지 반복적으로 확인하고 볼 수 있게 해줍니다.

2개의 좋아요

아 네, 구조를 먼저 계획하고 목업을 만드는 것에 대해서는 전적으로 동의합니다. 저는 디스코스로 카테고리 목록을 가져오는 아이디어에 대해 언급한 건데, 프로크터앤갬블 같은 대기업이라면 그럴듯할 수 있지만, 우리 대부분에게는 많은 노력에 비해 소수의 결과가 나올 뿐입니다. :wink:

1개의 좋아요

이 도구를 수정하여 적어도 가져오기 기능은 작동하도록 하려면 어떻게 해야 하나요? 만든 도구의 "복본"을 만들어 작업할 수 있는 방법이 있을까요? 프롬프트가 정말 그 몇 단어에 불과했나요?

여기서 후속 질문을 드립니다. 현재 Discourse에서 카테고리를 정리하고 있습니다. 정말 번거로운 과정입니다.

  1. “모든 카테고리” 페이지로 이동
  2. 수정하려는 카테고리를 클릭
  3. 렌치 아이콘을 클릭하고 수정 사항 입력
  4. 하단의 “저장”을 클릭하지만, 실제로 저장되었는지 알려주는 시각적 피드백이 전혀 없습니다
  5. 아, 이제 끝났나 보네. 이제 어디로 가야 하지? 아, “카테고리로 돌아가기”
  6. 음, 이제 더 이상 관심이 없는 해당 카테고리 페이지로 돌아와 버렸네요. 메인 카테고리 페이지로 돌아가려면 어떻게 해야 하지? 아, 사이드바 > 모든 카테고리… 처음부터 그걸 클릭했어야 했는데

이 과정에서 제가 기대했던 것들:

  • “저장”을 클릭하면 페이지 어딘가에 실제로 저장되었다는 확인 메시지가 표시되었으면 합니다.
  • 제 사용 사례처럼 모든 카테고리를 순회하며 구조를 재편하거나, 권한, 색상, 설명 등을 변경할 때, (최소한) 메인 카테고리 페이지로 돌아가고 싶습니다. 물론 사이드바에 해당 링크는 있지만, 메인 콘텐츠 영역에서 “필요한 곳으로 돌아가게 해주는” 버튼을 계속 찾아봤습니다.
  • 이 스레드에서 제시된 시각적 목업처럼 아름다운 것까지는 아니더라도, 모든 카테고리와 주요 설정을 큰 표 형태로 보여주고 그 안에서 수정할 수 있다면 훨씬 쉬울 것입니다. 이렇게 하면 커뮤니티 구조가 진화함에 따라 좋은 개요 없이도 수십 개의 화면을 다시 반복해서 클릭하지 않고 초기 카테고리 설정/정리를 할 수 있을 것입니다.
2개의 좋아요

그 카테고리를 편집하려는 경우, 드롭다운 메뉴를 사용하여 다음 카테고리로 이동하는 것이 도움이 될 수 있습니다.

저도 저장 버튼을 클릭했을 때 무엇을 기대해야 하는지 확실하지 않습니다. 제 포럼에서는 버튼이 "저장 중"으로 바뀌고, 작업이 완료되면 다시 "저장"으로 돌아갑니다.

아, 맞아요. 누군가(당신이었나요?) 이미 언급했지만 제가 잊고 있었네요.

만약 그렇게 작동한다면(그럴 수도 있겠죠!) 너무 빨리 일어나서 아무것도 보이지 않아요. 또한, 제 뇌가 손가락에게 클릭하라는 명령을 내린 순간 제 눈은 이미 다른 곳에 가 있거든요. WordPress처럼 페이지 상단에 “저장됨”이라는 작은 메시지가 뜨면 좋겠다는 생각이 듭니다. :wink:

저는 Gemini에게 카테고리의 주요 속성(이름, 색상, 태그 등)을 제공하고 이렇게 요청했습니다: “이러한 속성을 가진 카테고리를 생성하고 정리할 수 있는 js/html5 도구를 출력해 줄 수 있나요? 또한 빠르게 편집하고 순서를 바꿀 수도 있어야 합니다.”

생성된 도구는 이미 훌륭했지만, 속성 및 기능을 추가/제거하도록 요청하고 디자인을 조정하는 방식으로 세밀하게 다듬었습니다.

가능합니다. 코드를 Gemini에게 보내고(Canvas 모드를 사용) 원하는 대로 조정하도록 요청할 수 있습니다:

<!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개의 좋아요

이곳에서 새로운 공식 인터페이스와 관련된 내용도 논의해도 되는 곳인가요?

문서용 새 카테고리를 만들려고 합니다. 네 가지 선택지 중에는 이에 대한 내용이 제대로 다루어지지 않는 것 같습니다:

지원 카테고리는 아닙니다. 그냥 기본 형식인 '토론’으로 진행하는 것 같습니다.