Changeset 0.37.16 (#228)
This commit is contained in:
429
src/js/sw/surfaces/projects/detail.js
Normal file
429
src/js/sw/surfaces/projects/detail.js
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* ProjectDetail — two-column detail view with inline chat + metadata sidebar.
|
||||
*
|
||||
* Props:
|
||||
* project — project object
|
||||
* onBack — () => void (return to list)
|
||||
* onUpdate — (deleted?: boolean) => void
|
||||
*
|
||||
* Left panel toggles: conversation list ↔ embedded ChatPane.
|
||||
* Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files).
|
||||
*/
|
||||
const html = window.html;
|
||||
const h = window.preact.h;
|
||||
const { useState, useEffect, useCallback, useRef } = window.hooks;
|
||||
|
||||
import { ChatPane } from '../../components/chat-pane/index.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6'];
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
function relTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']];
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVS) {
|
||||
if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; }
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const chatRef = useRef(null);
|
||||
|
||||
// ── State ──
|
||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||
const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] });
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState(project.name || '');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false });
|
||||
const [desc, setDesc] = useState(project.description || '');
|
||||
const [instructions, setInstructions] = useState(project.settings?.instructions || '');
|
||||
const [descDirty, setDescDirty] = useState(false);
|
||||
const [instrDirty, setInstrDirty] = useState(false);
|
||||
const [starred, setStarred] = useState(!!project.settings?.starred);
|
||||
|
||||
// Picker state
|
||||
const [pickerOpen, setPickerOpen] = useState('');
|
||||
const [pickerItems, setPickerItems] = useState([]);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
// Drag state for files
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
// ── Load associations ──
|
||||
const loadAssoc = useCallback(async () => {
|
||||
const id = project.id;
|
||||
const [ch, kb, nt, fi] = await Promise.all([
|
||||
sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []),
|
||||
sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []),
|
||||
]);
|
||||
setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi });
|
||||
}, [project.id]);
|
||||
|
||||
useEffect(() => { loadAssoc(); }, [loadAssoc]);
|
||||
|
||||
// ── Toggle section ──
|
||||
function toggleSection(key) {
|
||||
setSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
// ── Name editing ──
|
||||
async function saveName() {
|
||||
const name = nameVal.trim();
|
||||
if (!name || name === project.name) { setEditingName(false); return; }
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { name });
|
||||
sw.toast('Renamed', 'success');
|
||||
setEditingName(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Description / Instructions save ──
|
||||
async function saveDesc() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { description: desc });
|
||||
sw.toast('Description saved', 'success');
|
||||
setDescDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveInstructions() {
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), instructions };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
sw.toast('Instructions saved', 'success');
|
||||
setInstrDirty(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Star toggle ──
|
||||
async function toggleStar() {
|
||||
const next = !starred;
|
||||
setStarred(next);
|
||||
try {
|
||||
const settings = { ...(project.settings || {}), starred: next };
|
||||
await sw.api.projects.update(project.id, { settings });
|
||||
} catch (e) { sw.toast(e.message, 'error'); setStarred(!next); }
|
||||
}
|
||||
|
||||
// ── Context menu actions ──
|
||||
async function handleColor(c) {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { color: c });
|
||||
sw.toast('Color updated', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
try {
|
||||
await sw.api.projects.update(project.id, { is_archived: !project.is_archived });
|
||||
sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success');
|
||||
setMenuOpen(false);
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
setMenuOpen(false);
|
||||
if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return;
|
||||
try {
|
||||
await sw.api.projects.del(project.id);
|
||||
sw.toast('Project deleted', 'success');
|
||||
onUpdate(true);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Picker helpers ──
|
||||
async function openPicker(type) {
|
||||
if (pickerOpen === type) { setPickerOpen(''); return; }
|
||||
setPickerOpen(type);
|
||||
setSections(prev => ({ ...prev, [type]: true }));
|
||||
setPickerLoading(true);
|
||||
setPickerItems([]);
|
||||
try {
|
||||
let items = [];
|
||||
if (type === 'kbs') {
|
||||
const r = await sw.api.knowledge.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id));
|
||||
items = all.filter(k => !attached.has(k.id));
|
||||
} else if (type === 'notes') {
|
||||
const r = await sw.api.notes.list();
|
||||
const all = r?.data || r || [];
|
||||
const attached = new Set(assoc.notes.map(n => n.id || n.note_id));
|
||||
items = all.filter(n => !attached.has(n.id));
|
||||
}
|
||||
setPickerItems(items);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
setPickerLoading(false);
|
||||
}
|
||||
|
||||
async function pickItem(type, item) {
|
||||
try {
|
||||
if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false);
|
||||
else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id);
|
||||
sw.toast('Added', 'success');
|
||||
setPickerOpen('');
|
||||
loadAssoc();
|
||||
onUpdate();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Remove handlers ──
|
||||
async function removeKb(kbId) {
|
||||
try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeNote(noteId) {
|
||||
try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
async function removeChannel(chId) {
|
||||
try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File upload ──
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await sw.api.projects.uploadFile(project.id, file);
|
||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
loadAssoc();
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = () => { if (input.files.length) uploadFiles(input.files); };
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
// ── New conversation ──
|
||||
async function newConversation() {
|
||||
try {
|
||||
const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' });
|
||||
const chId = ch?.id || ch?.data?.id;
|
||||
if (chId) {
|
||||
await sw.api.projects.addChannel(project.id, chId);
|
||||
await loadAssoc();
|
||||
setActiveChannelId(chId);
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Render: Picker dropdown ──
|
||||
function renderPicker(type, emptyHint) {
|
||||
if (pickerOpen !== type) return null;
|
||||
return html`<div class="sw-projects__picker">
|
||||
${pickerLoading && html`<div class="sw-projects__picker-hint">Loading\u2026</div>`}
|
||||
${!pickerLoading && pickerItems.length === 0 && html`<div class="sw-projects__picker-hint">${emptyHint}</div>`}
|
||||
${!pickerLoading && pickerItems.map(item => html`
|
||||
<div key=${item.id} class="sw-projects__picker-item" onClick=${() => pickItem(type, item)}>
|
||||
${item.title || item.name || item.id}
|
||||
</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Collapsible section ──
|
||||
function renderSection(key, title, count, content, addAction) {
|
||||
const open = sections[key];
|
||||
return html`<div class="sw-projects__section">
|
||||
<div class="sw-projects__section-header" onClick=${e => { if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}>
|
||||
<div>
|
||||
<span class="sw-projects__section-title">${title}</span>
|
||||
${count != null && html`<span class="sw-projects__section-count">${count}</span>`}
|
||||
</div>
|
||||
<div class="sw-projects__section-actions">
|
||||
${addAction && html`<button class="sw-projects__section-add"
|
||||
onClick=${e => { e.stopPropagation(); addAction(); }}>+</button>`}
|
||||
<span class=${'sw-projects__section-chevron' + (open ? ' sw-projects__section-chevron--open' : '')}>\u25B6</span>
|
||||
</div>
|
||||
</div>
|
||||
${open && html`<div class="sw-projects__section-body">${content}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Left panel ──
|
||||
function renderLeftPanel() {
|
||||
if (activeChannelId) {
|
||||
return html`<div class="sw-projects__chat-wrap">
|
||||
<div class="sw-projects__chat-back" onClick=${() => setActiveChannelId(null)}>
|
||||
\u2190 Back to conversations
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeChannelId}
|
||||
standalone=${false}
|
||||
handleRef=${chatRef}
|
||||
className="sw-projects__chat-pane" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="sw-projects__conversations">
|
||||
<div class="sw-projects__conv-header">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Conversations</span>
|
||||
<button class="sw-projects__new-btn" style="font-size:12px;padding:5px 12px;" onClick=${newConversation}>+ New</button>
|
||||
</div>
|
||||
<div class="sw-projects__conv-list">
|
||||
${assoc.channels.length === 0 && html`
|
||||
<div class="sw-projects__conv-empty">
|
||||
No conversations yet.<br/>
|
||||
<button class="sw-projects__new-btn" style="margin-top:12px;font-size:12px;padding:5px 12px;" onClick=${newConversation}>Start a conversation</button>
|
||||
</div>
|
||||
`}
|
||||
${assoc.channels.map(ch => {
|
||||
const chId = ch.id || ch.channel_id;
|
||||
return html`<div key=${chId} class="sw-projects__conv-item" onClick=${() => setActiveChannelId(chId)}>
|
||||
<div class="sw-projects__conv-title">${ch.title || ch.name || chId}</div>
|
||||
<div class="sw-projects__conv-meta">Last message ${relTime(ch.updated_at)}</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: Right sidebar ──
|
||||
function renderSidebar() {
|
||||
return html`<div class="sw-projects__sidebar">
|
||||
${renderSection('desc', 'Description', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="3"
|
||||
value=${desc}
|
||||
onInput=${e => { setDesc(e.target.value); setDescDirty(true); }}
|
||||
placeholder="Add a project description\u2026" />
|
||||
${descDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveDesc}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('instructions', 'Instructions', null, html`
|
||||
<textarea class="sw-projects__section-textarea" rows="4"
|
||||
value=${instructions}
|
||||
onInput=${e => { setInstructions(e.target.value); setInstrDirty(true); }}
|
||||
placeholder="Add instructions to tailor responses\u2026" />
|
||||
${instrDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveInstructions}>Save</button>`}
|
||||
`)}
|
||||
|
||||
${renderSection('kbs', 'Knowledge Bases', assoc.kbs.length, html`
|
||||
${assoc.kbs.length === 0 && html`<div class="sw-projects__section-hint">No knowledge bases linked.</div>`}
|
||||
${assoc.kbs.map(kb => html`
|
||||
<div key=${kb.id || kb.kb_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${kb.name || kb.kb_id}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeKb(kb.id || kb.kb_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('kbs', 'No knowledge bases available.')}
|
||||
`, () => openPicker('kbs'))}
|
||||
|
||||
${renderSection('notes', 'Notes', assoc.notes.length, html`
|
||||
${assoc.notes.length === 0 && html`<div class="sw-projects__section-hint">No notes linked.</div>`}
|
||||
${assoc.notes.map(n => html`
|
||||
<div key=${n.id || n.note_id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${n.title || n.note_id}</span>
|
||||
<span class="sw-projects__section-item-meta">${fmtDate(n.created_at || n.added_at)}</span>
|
||||
<button class="sw-projects__section-item-remove" onClick=${() => removeNote(n.id || n.note_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${renderPicker('notes', 'No notes available.')}
|
||||
`, () => openPicker('notes'))}
|
||||
|
||||
${renderSection('files', 'Files', assoc.files.length, html`
|
||||
${assoc.files.map(f => html`
|
||||
<div key=${f.id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
|
||||
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
|
||||
</div>
|
||||
`)}
|
||||
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
||||
onClick=${handleUploadClick}
|
||||
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
||||
onDragLeave=${() => setDragActive(false)}
|
||||
onDrop=${handleDrop}>
|
||||
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
|
||||
</div>
|
||||
`, handleUploadClick)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
return html`<div class="sw-projects__detail">
|
||||
<div class="sw-projects__detail-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<a class="sw-projects__back" onClick=${onBack}>\u2190 All projects</a>
|
||||
${editingName
|
||||
? html`<input class="sw-projects__detail-name-input"
|
||||
value=${nameVal}
|
||||
onInput=${e => setNameVal(e.target.value)}
|
||||
onBlur=${saveName}
|
||||
onKeyDown=${e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
autoFocus />`
|
||||
: html`<h2 class="sw-projects__detail-name"
|
||||
onClick=${() => { setNameVal(project.name || ''); setEditingName(true); }}>
|
||||
${(project.icon ? project.icon + ' ' : '') + project.name}
|
||||
</h2>`
|
||||
}
|
||||
<div class="sw-projects__header-actions">
|
||||
<button class=${'sw-projects__icon-btn' + (starred ? ' sw-projects__icon-btn--active' : '')}
|
||||
onClick=${toggleStar} title="Star project">
|
||||
${starred ? '\u2605' : '\u2606'}
|
||||
</button>
|
||||
<div style="position:relative">
|
||||
<button class="sw-projects__icon-btn"
|
||||
onClick=${() => setMenuOpen(!menuOpen)} title="More options">\u22EF</button>
|
||||
${menuOpen && html`
|
||||
<div class="sw-projects__ctx-menu">
|
||||
<button class="sw-projects__ctx-item"
|
||||
onClick=${() => { setMenuOpen(false); setNameVal(project.name || ''); setEditingName(true); }}>Rename</button>
|
||||
<div class="sw-projects__color-row">
|
||||
${COLORS.map(c => html`
|
||||
<span key=${c}
|
||||
class=${'sw-projects__color-swatch' + (project.color === c ? ' sw-projects__color-swatch--active' : '')}
|
||||
style=${'background:' + c}
|
||||
onClick=${() => handleColor(c)} />
|
||||
`)}
|
||||
</div>
|
||||
<div class="sw-projects__ctx-divider" />
|
||||
<button class="sw-projects__ctx-item" onClick=${toggleArchive}>
|
||||
${project.is_archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button class="sw-projects__ctx-item sw-projects__ctx-item--danger"
|
||||
onClick=${deleteProject}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-projects__detail-body">
|
||||
${renderLeftPanel()}
|
||||
${renderSidebar()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
93
src/js/sw/surfaces/projects/index.js
Normal file
93
src/js/sw/surfaces/projects/index.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* ProjectsSurface — root view-switching component.
|
||||
*
|
||||
* No selectedId → card grid list (ProjectsList)
|
||||
* With selectedId → detail view (ProjectDetail)
|
||||
*
|
||||
* Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useCallback } = window.hooks;
|
||||
const { render } = window.preact;
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { ProjectsList } from './list.js';
|
||||
import { ProjectDetail } from './detail.js';
|
||||
|
||||
function ProjectsSurface() {
|
||||
const BASE = window.__BASE__ || '';
|
||||
const deepLinkId = window.__PROJECT_ID__ || '';
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(deepLinkId || '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.projects.list();
|
||||
const list = resp?.data || resp || [];
|
||||
setProjects(list);
|
||||
if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
|
||||
setSelectedId('');
|
||||
}
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [deepLinkId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const selected = projects.find(p => p.id === selectedId) || null;
|
||||
|
||||
function selectProject(id) {
|
||||
setSelectedId(id);
|
||||
history.replaceState(null, '', BASE + '/projects/' + id);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setSelectedId('');
|
||||
history.replaceState(null, '', BASE + '/projects');
|
||||
}
|
||||
|
||||
async function createProject(name) {
|
||||
try {
|
||||
const proj = await sw.api.projects.create({ name });
|
||||
sw.toast('Project created', 'success');
|
||||
await load();
|
||||
if (proj?.id) selectProject(proj.id);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const handleUpdate = useCallback((deleted) => {
|
||||
if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
|
||||
load();
|
||||
}, [load, BASE]);
|
||||
|
||||
return html`
|
||||
<div class="sw-projects">
|
||||
${selected
|
||||
? html`<${ProjectDetail}
|
||||
key=${selectedId}
|
||||
project=${selected}
|
||||
onBack=${goBack}
|
||||
onUpdate=${handleUpdate} />`
|
||||
: html`<${ProjectsList}
|
||||
projects=${projects}
|
||||
loading=${loading}
|
||||
onSelect=${selectProject}
|
||||
onCreate=${createProject} />`
|
||||
}
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────
|
||||
const mount = document.getElementById('projects-mount');
|
||||
if (mount) {
|
||||
render(html`<${ProjectsSurface} />`, mount);
|
||||
console.log('[projects] Surface mounted');
|
||||
}
|
||||
|
||||
export { ProjectsSurface };
|
||||
124
src/js/sw/surfaces/projects/list.js
Normal file
124
src/js/sw/surfaces/projects/list.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ProjectsList — card grid view for all projects.
|
||||
*
|
||||
* Props:
|
||||
* projects — array of project objects
|
||||
* loading — boolean
|
||||
* onSelect — (id: string) => void
|
||||
* onCreate — (name: string) => void
|
||||
*/
|
||||
const html = window.html;
|
||||
const { useState, useMemo } = window.hooks;
|
||||
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
// ── Relative time helper ────────────────────
|
||||
const DIVISIONS = [
|
||||
[60, 'second'], [60, 'minute'], [24, 'hour'],
|
||||
[30, 'day'], [12, 'month'], [Infinity, 'year'],
|
||||
];
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (val < 10) return 'just now';
|
||||
for (const [div, unit] of DIVISIONS) {
|
||||
if (val < div) {
|
||||
const n = Math.floor(val);
|
||||
return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
val /= div;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function ProjectsList({ projects, loading, onSelect, onCreate }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('activity');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
let list = projects;
|
||||
if (q) {
|
||||
list = list.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
(p.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (sort === 'activity') {
|
||||
list = [...list].sort((a, b) =>
|
||||
new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
|
||||
);
|
||||
} else {
|
||||
list = [...list].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '')
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [projects, search, sort]);
|
||||
|
||||
async function handleCreate() {
|
||||
const name = await sw.prompt('Project name:');
|
||||
if (name && name.trim()) onCreate(name.trim());
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="sw-projects__list">
|
||||
<div class="sw-projects__empty">Loading projects\u2026</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-projects__list">
|
||||
<div class="sw-projects__list-header">
|
||||
<${UserMenu} placement="down-right" />
|
||||
<h1 class="sw-projects__list-title">Projects</h1>
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ New project</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__search-wrap">
|
||||
<span class="sw-projects__search-icon">\u{1F50D}</span>
|
||||
<input class="sw-projects__search"
|
||||
type="text"
|
||||
placeholder="Search projects\u2026"
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="sw-projects__sort">
|
||||
<span>Sort by</span>
|
||||
<select value=${sort} onChange=${e => setSort(e.target.value)}>
|
||||
<option value="activity">Activity</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${filtered.length === 0 && !loading && html`
|
||||
<div class="sw-projects__empty">
|
||||
${search ? 'No projects match your search.' : 'No projects yet.'}
|
||||
${!search && html`<div style="margin-top:12px;">
|
||||
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ Create your first project</button>
|
||||
</div>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="sw-projects__grid">
|
||||
${filtered.map(p => html`
|
||||
<div key=${p.id}
|
||||
class="sw-projects__card"
|
||||
style=${p.color ? `border-left: 3px solid ${p.color}` : ''}
|
||||
onClick=${() => onSelect(p.id)}>
|
||||
<div class="sw-projects__card-name">
|
||||
${p.icon ? html`<span>${p.icon}</span>` : ''}
|
||||
<span>${p.name}</span>
|
||||
${p.is_archived && html`<span class="sw-projects__card-badge">archived</span>`}
|
||||
</div>
|
||||
${p.description && html`
|
||||
<div class="sw-projects__card-desc">${p.description}</div>
|
||||
`}
|
||||
<div class="sw-projects__card-meta">${relativeTime(p.updated_at)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user