私は Gemini にカテゴリの主要な属性(名前、色、タグなど)を与え、「これらの属性を持つカテゴリを作成・整理するための js/html5 ツールを出力できますか?また、素早く編集・並べ替えもできるようにしてください」と尋ねました。
生成されたツールはすでに素晴らしかったですが、属性や機能の追加・削除を依頼し、デザインを微調整することでさらに洗練させました。
おそらく可能です。コードを Gemini に送信し(Canvas モードを使用)、希望通りに微調整するよう依頼してください:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discourse カテゴリプランナー</title>
<style>
/* ベースのスタイル */
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; }
/* レイアウト */
.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-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; }
/* ボタン */
.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; }
/* --- 色入力フィールドのスタイル --- */
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;
}
/* --- 色入力フィールドのスタイル終了 --- */
/* 管理リストのスタイル(ドラッグ可能) */
#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; }
/* 管理リスト(中央カラム)でのサブカテゴリのインデント */
.category-item.is-sub {
margin-left: 30px;
border-left: 3px solid #ced4da;
background-color: #f7f7f7;
}
/* --- ドラッグヒントのスタイル --- */
.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;
}
/* --- ドラッグヒントのスタイル終了 --- */
.category-actions { flex-shrink: 0; display: flex; gap: 5px; }
/* --- カテゴリ表示コンテナ(管理リストとプレビューの両方に適用) --- */
.category-info, .preview-category {
flex-grow: 1;
display: flex;
align-items: flex-start;
padding: 0;
}
/* 色付き要素(垂直バー + 四角 + テキスト)のラッパー */
.category-display-block {
display: flex;
flex-direction: column;
padding-left: 8px;
border-left: 4px solid transparent;
flex-grow: 1;
}
/* 四角と名前/テキストのラッパー */
.square-and-text-wrapper {
display: flex;
align-items: flex-start;
}
/* 色付き四角 - 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-name {
font-size: 1em;
font-weight: 700;
color: #343a40;
}
.category-desc { font-size: 0.8em; color: #6c757d; }
/* --- ピル型バッジのスタイル --- */
.pill-container, .preview-pill-wrapper {
margin-top: 5px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
/* 権限 - より控えめなスタイル */
.management-permission-pill, .preview-permission-pill {
font-size: 0.7em;
font-weight: 600;
background: #e9ecef; /* より明るい背景 */
color: #495057; /* より濃いテキスト */
padding: 3px 6px;
border-radius: 4px;
text-transform: uppercase;
border: 1px solid #ced4da; /* 定義を明確にするための控えめな境界線 */
}
.attr-pill, .preview-tag-pill { /* タグ */
font-size: 0.7em;
background: #f8f9fa; /* 強調を減らすための非常に明るい背景 */
color: #495057;
padding: 3px 6px;
border-radius: 4px;
display: inline-block;
border: 1px solid #e9ecef; /* 控えめな境界線 */
}
/* 編集/削除ボタンのスタイル */
.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 カテゴリ&階層プランナー</h1>
<div class="main-container">
<div class="form-section">
<h2>カテゴリ属性の追加/編集</h2>
<form id="category-form">
<input type="hidden" id="category-id">
<div class="form-row">
<div class="form-group">
<label for="name">1. 名前</label>
<input type="text" id="name" required oninput="updateLiveFormPreview()">
</div>
<div class="form-group">
<label for="color">2. 色</label>
<!-- カスタムスタイルの色入力フィールド -->
<input type="color" id="color" value="#007bff" oninput="updateLiveFormPreview()">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="description">3. 説明</label>
<textarea id="description" rows="2" oninput="updateLiveFormPreview()"></textarea>
</div>
<div class="form-group">
<label for="permissions">4. 権限(例: 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. タグ(カンマ区切り、例: "bug, feature")</label>
<input type="text" id="tags" placeholder="このカテゴリの投稿用のオプションタグ" 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">新しいカテゴリを追加</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;">プレビュー</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">[カテゴリ名]</div>
</div>
<div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[ここに説明が入ります]</div>
<div class="preview-pill-wrapper" id="preview-pills-field">
<!-- ピル型バッジはJSによってここに挿入されます -->
</div>
</div>
</div>
</div>
<div class="utility-buttons">
<button id="export-btn" class="action-button">エクスポート (CSV)</button>
<button id="import-btn" class="action-button">インポート (CSV)</button>
<input type="file" id="csv-file-input" accept=".csv" style="display: none;">
</div>
</div>
<div class="list-section">
<h2>カテゴリ管理(ドラッグして並べ替え)</h2>
<p style="font-size: 0.85em; color: #6c757d;">
要素をドラッグして並べ替えます。**右**にドラッグするとサブカテゴリが作成されます(緑色のヒント)、**左**にドラッグすると上位階層に昇格します(赤色のヒント)。
</p>
<ul id="category-list">
<!-- カテゴリは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エクスポート/インポートハンドラー ---
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] || '';
// CSV形式用のカンマと引用符のエスケープ
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('このブラウザではエクスポートがサポートされていません。');
}
});
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(`${importedData.length} カテゴリを正常にインポートしました。`);
} else {
alert('CSVインポートエラー: データが空か、フォーマットが正しくありません。');
}
};
reader.readAsText(file);
});
// --- コアデータ管理とレンダリング ---
const findParent = (parentId) => categories.find(c => c.id === parentId);
// カテゴリ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 updateLiveFormPreview() {
const name = document.getElementById('name').value || '[カテゴリ名]';
const description = document.getElementById('description').value || '[ここに説明が入ります]';
const color = document.getElementById('color').value;
const permissions = document.getElementById('permissions').value;
const tagsInput = document.getElementById('tags').value;
// 色を四角と左の境界線に適用
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;
// ピル型バッジ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 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. 管理リストアイテム(ドラッグ可能)をレンダリング ---
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);
// タグ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 ? '— サブカテゴリ' : ''}</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}')">編集</button>
<button class="delete-btn" onclick="deleteCategory('${cat.id}')">削除</button>
</div>
`;
list.appendChild(managementItem);
});
addDragDropListeners();
}
// フォーム送信(追加または更新)を処理する関数
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 = '新しいカテゴリを追加';
updateLiveFormPreview();
renderAll();
});
// 編集用にフォームを埋める関数
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 = 'カテゴリを更新';
updateLiveFormPreview();
window.scrollTo(0, 0);
}
// カテゴリを削除する関数
function deleteCategory(id) {
// サンドボックス環境での実行を許可するためにconfirm()呼び出しを削除
categories = categories.filter(c => c.id !== id && c.parentId !== id);
renderAll();
}
// --- 並べ替えとネストのためのドラッグ&ドロップロジック ---
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; // 開始X位置をキャプチャ
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;
// --- ヒント適用のロジック ---
// 1. インデント(右にドラッグ)のチェック
if (horizontalShift > INDENT_THRESHOLD_PX) {
// ターゲットが子を受け入れられるかチェック(つまり、すでに子ではないこと)
if (!dropTargetCat.parentId) {
dropTargetElement.classList.add('indent-hint');
dropTargetElement.classList.remove('drag-over');
}
}
// 2. アウトデント(左にドラッグ)のチェック
else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
dropTargetElement.classList.add('outdent-hint');
dropTargetElement.classList.remove('drag-over');
}
// 3. 中間領域: ヒントがない場合、ドロップ時は垂直方向の並べ替えのみ行われます。
});
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. カテゴリ配列の再順序付け(線形順序)
if (draggedIndex !== -1 && dropIndex !== -1) {
const [temp] = categories.splice(draggedIndex, 1);
categories.splice(dropIndex, 0, temp);
}
// 再順序付け後のインデックスを再取得
const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
const draggedCat = categories[newDraggedIndex];
// 2. アクティブなヒントに基づいて新しい親IDを決定
let newParentId = draggedCat.parentId; // デフォルト: 現在の階層ステータスを維持
let updateDescendants = false;
if (dropTargetElement.classList.contains('indent-hint')) {
// 子への昇格: ドラッグされたカテゴリの新しい親はドロップターゲットになります。
newParentId = dropTargetId;
updateDescendants = true;
} else if (dropTargetElement.classList.contains('outdent-hint')) {
// 親への昇格: ドラッグされたカテゴリの新しい親はnull/空になります。
newParentId = '';
updateDescendants = true;
}
draggedCat.parentId = newParentId;
// 3. ドラッグされた親の階層が変更された場合、子孫を更新
if (updateDescendants) {
const descendantIds = getDescendantIds(draggedId);
descendantIds.forEach(descendantId => {
const descendantCat = categories.find(c => c.id === descendantId);
if (descendantCat) {
// 子孫はドラッグされた親の新しいステータスに従います
descendantCat.parentId = newParentId;
}
});
}
removeAllDragHints();
renderAll();
}
});
});
}
// --- 初期読み込み(更新されたカテゴリ) ---
categories.push({
id: 'cat_001', name: 'General', description: '一般的なコミュニティの議論とヘルプ。', color: '#007bff', permissions: 'Public', parentId: '', tags: 'tag 1, tag 2'
});
categories.push({
id: 'cat_004', name: 'サブカテゴリの例', description: '', color: '#007bff', permissions: 'Public', parentId: 'cat_001', tags: 'tag 3'
});
categories.push({
id: 'cat_002', name: 'Staff', description: 'モデレーションと管理用のプライベートエリア。', color: '#ffc107', permissions: 'Staff Only', parentId: '', tags: ''
});
categories.push({
id: 'cat_003', name: 'Site Feedback', description: 'フォーラム自体について話し合い、改善を提案します。', color: '#28a745', permissions: 'Public', parentId: '', tags: 'site, feedback'
});
renderAll();
updateLiveFormPreview();
</script>
</body>
</html>