Changeset 0.19.0.1 (#82)
This commit is contained in:
288
src/js/projects-ui.js
Normal file
288
src/js/projects-ui.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Projects UI (v0.19.0)
|
||||
// ==========================================
|
||||
// Manages project CRUD, sidebar grouping, and
|
||||
// chat-to-project association via context menu
|
||||
// and drag-and-drop.
|
||||
// ==========================================
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const resp = await API.listProjects();
|
||||
App.projects = (resp.data || []).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: p.channel_count || 0,
|
||||
kbCount: p.kb_count || 0,
|
||||
noteCount: p.note_count || 0,
|
||||
isArchived: p.is_archived || false,
|
||||
}));
|
||||
// Restore collapse state from localStorage
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}');
|
||||
App.collapsedProjects = saved;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load projects:', e.message);
|
||||
App.projects = [];
|
||||
}
|
||||
}
|
||||
|
||||
function _saveCollapseState() {
|
||||
try { localStorage.setItem('cs-collapsed-projects', JSON.stringify(App.collapsedProjects)); } catch (_) {}
|
||||
}
|
||||
|
||||
function toggleProjectCollapse(projectId) {
|
||||
App.collapsedProjects[projectId] = !App.collapsedProjects[projectId];
|
||||
_saveCollapseState();
|
||||
UI.renderChatList();
|
||||
}
|
||||
|
||||
// ── Project CRUD ────────────────────────────
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
App.projects.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0,
|
||||
isArchived: false,
|
||||
});
|
||||
UI.renderChatList();
|
||||
UI.toast('Project created', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameProject(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
const name = prompt('Rename project:', proj.name);
|
||||
if (!name || !name.trim() || name.trim() === proj.name) return;
|
||||
try {
|
||||
await API.updateProject(projectId, { name: name.trim() });
|
||||
proj.name = name.trim();
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to rename project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
|
||||
try {
|
||||
await API.deleteProject(projectId);
|
||||
App.projects = App.projects.filter(p => p.id !== projectId);
|
||||
// Unassign chats client-side
|
||||
App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; });
|
||||
UI.renderChatList();
|
||||
UI.toast('Project deleted', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setProjectColor(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
// Simple color picker via an invisible input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.value = proj.color || '#3B82F6';
|
||||
input.style.position = 'fixed';
|
||||
input.style.opacity = '0';
|
||||
document.body.appendChild(input);
|
||||
input.addEventListener('change', async () => {
|
||||
try {
|
||||
await API.updateProject(projectId, { color: input.value });
|
||||
proj.color = input.value;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to set color', 'error');
|
||||
}
|
||||
input.remove();
|
||||
});
|
||||
input.addEventListener('blur', () => setTimeout(() => input.remove(), 200));
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ── Move Chat to Project ────────────────────
|
||||
|
||||
async function moveChatToProject(chatId, projectId) {
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
const oldProjectId = chat.projectId;
|
||||
|
||||
if (projectId === null) {
|
||||
// Remove from project
|
||||
if (!oldProjectId) return;
|
||||
try {
|
||||
await API.removeChannelFromProject(oldProjectId, chatId);
|
||||
chat.projectId = null;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to remove from project', 'error');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.addChannelToProject(projectId, chatId, 0);
|
||||
chat.projectId = projectId;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to move to project', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context Menu ────────────────────────────
|
||||
|
||||
let _ctxMenu = null;
|
||||
|
||||
function showChatContextMenu(e, chatId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
|
||||
// Move to project submenu
|
||||
let items = '';
|
||||
if (chat.projectId) {
|
||||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
||||
<span class="ctx-icon">↩</span> Remove from project
|
||||
</button>`;
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
}
|
||||
|
||||
App.projects.forEach(p => {
|
||||
if (p.id === chat.projectId) return; // skip current
|
||||
const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : '';
|
||||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}','${p.id}');dismissChatContextMenu()">
|
||||
${dot}<span>${esc(p.name)}</span>
|
||||
</button>`;
|
||||
});
|
||||
|
||||
if (App.projects.length === 0 && !chat.projectId) {
|
||||
items += '<div class="ctx-hint">No projects yet</div>';
|
||||
}
|
||||
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
items += `<button class="ctx-item" onclick="createProjectAndMove('${chatId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">+</span> New project…
|
||||
</button>`;
|
||||
|
||||
menu.innerHTML = items;
|
||||
|
||||
// Position near cursor
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
|
||||
// Dismiss on click outside
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
|
||||
function dismissChatContextMenu() {
|
||||
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
||||
}
|
||||
|
||||
async function createProjectAndMove(chatId) {
|
||||
const name = prompt('New project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
App.projects.push({
|
||||
id: p.id, name: p.name, description: '', color: null, icon: null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0, isArchived: false,
|
||||
});
|
||||
await moveChatToProject(chatId, p.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and Drop ───────────────────────────
|
||||
|
||||
function onChatDragStart(e, chatId) {
|
||||
e.dataTransfer.setData('text/plain', chatId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.target.classList.add('dragging');
|
||||
}
|
||||
|
||||
function onChatDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function onProjectDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function onProjectDragLeave(e) {
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
async function onProjectDrop(e, projectId) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
await moveChatToProject(chatId, projectId);
|
||||
}
|
||||
|
||||
async function onRecentDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
await moveChatToProject(chatId, null);
|
||||
}
|
||||
|
||||
// ── Project Header Menu ─────────────────────
|
||||
|
||||
function showProjectMenu(e, projectId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">✏</span> Rename
|
||||
</button>
|
||||
<button class="ctx-item" onclick="setProjectColor('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🎨</span> Color
|
||||
</button>
|
||||
<div class="ctx-divider"></div>
|
||||
<button class="ctx-item ctx-danger" onclick="deleteProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🗑</span> Delete project
|
||||
</button>`;
|
||||
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user