import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; // Shared on-disk folders (blueprint §6). Admin-only surface: create a folder, // describe what it holds (the description is fed to the assistant's system // context), and grant members read-only or read-write access. There is no owner — // a folder is just a name + a membership list (contrast: Projects, which will have // an owner). Renaming is intentionally not offered (it would remount + move the // on-disk directory). Reuses the `um-*` (users/roles) and `connector-card` styles. async function jf(url, opts) { const res = await fetch(url, opts); if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : null; } export class SharedFoldersPage extends LightElement { static get properties() { return { _open: { state: true }, _folders: { state: true }, // [{ id, folder_name, description, members:[{user_id,can_write}] }] _users: { state: true }, // /api/users — for the member picker + labels _error: { state: true }, _modal: { state: true }, // null | { mode:'create'|'edit', folder?, form:{folder_name,description} } _add: { state: true }, // { [folderId]: { user_id, can_write } } — in-progress add-row }; } constructor() { super(); this._open = false; this._folders = null; this._users = null; this._error = null; this._modal = null; this._add = {}; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'shared-folders'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _load() { this._error = null; try { const [folders, users] = await Promise.all([ jf('/api/shared-folders'), jf('/api/users'), ]); this._folders = folders; this._users = users; } catch (e) { this._error = e.message; this._folders = this._folders ?? []; } } _userLabel(id) { const u = (this._users ?? []).find(x => x.id === id); return u ? (u.display_name || u.username) : id; } // Users not yet members of this folder (and active) — the add-picker's options. _candidates(folder) { const members = new Set(folder.members.map(m => m.user_id)); return (this._users ?? []).filter(u => u.active && !members.has(u.id)); } // ── create / edit-description modal ────────────────────────────────────────── _openCreate() { this._modal = { mode: 'create', form: { folder_name: '', description: '' } }; this._error = null; } _openEditDesc(folder) { this._modal = { mode: 'edit', folder, form: { folder_name: folder.folder_name, description: folder.description } }; this._error = null; } _closeModal() { this._modal = null; this._error = null; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } async _save() { const { mode, form, folder } = this._modal; this._error = null; try { if (mode === 'create') { if (!form.folder_name.trim()) { this._error = t('sf.error.name'); return; } await jf('/api/shared-folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_name: form.folder_name.trim(), description: form.description.trim() }), }); } else { await jf(`/api/shared-folders/${folder.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: form.description.trim() }), }); } this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } async _delete(folder) { if (!confirm(t('sf.confirm.delete', { name: folder.folder_name }))) return; this._error = null; try { await jf(`/api/shared-folders/${folder.id}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } // ── membership ─────────────────────────────────────────────────────────────── _draft(folderId) { return this._add[folderId] ?? { user_id: '', can_write: false }; } _setAdd(folderId, patch) { this._add = { ...this._add, [folderId]: { ...this._draft(folderId), ...patch } }; } async _addMember(folder) { const draft = this._draft(folder.id); if (!draft.user_id) return; this._error = null; try { await jf(`/api/shared-folders/${folder.id}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: draft.user_id, can_write: !!draft.can_write }), }); this._add = { ...this._add, [folder.id]: { user_id: '', can_write: false } }; await this._load(); } catch (e) { this._error = e.message; } } // Re-grant with a new capability — the POST upserts on (folder, user). async _setAccess(folder, userId, canWrite) { this._error = null; try { await jf(`/api/shared-folders/${folder.id}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId, can_write: canWrite }), }); await this._load(); } catch (e) { this._error = e.message; } } async _removeMember(folder, userId) { if (!confirm(t('sf.confirm.remove_member', { name: this._userLabel(userId), folder: folder.folder_name }))) return; this._error = null; try { await jf(`/api/shared-folders/${folder.id}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } // ── render ─────────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; const folders = this._folders ?? []; const loading = this._folders === null; return html`
${this._error && !this._modal ? html`
${this._error}
` : nothing}
${t('sf.note.propagation')}
${loading ? html`
${t('sf.loading')}
` : folders.length === 0 ? html`

${t('sf.empty')}

${t('sf.empty_hint')}

` : html`
${folders.map(f => this._renderFolder(f))}
`}
${this._renderModal()}
`; } _renderFolder(f) { const draft = this._draft(f.id); const candidates = this._candidates(f); return html`
${f.folder_name}
shared/${f.folder_name}
${f.description ? html`${f.description}` : html`${t('sf.no_desc')}`}
${t('sf.members')}
${f.members.length === 0 ? html`
${t('sf.no_members')}
` : html`
${f.members.map(m => this._renderMember(f, m))}
`} ${candidates.length > 0 ? html`
` : html`
${t('sf.all_added')}
`}
`; } _renderMember(f, m) { return html`
${this._userLabel(m.user_id)}
`; } _renderModal() { if (!this._modal) return nothing; const { mode, form, folder } = this._modal; const title = mode === 'create' ? t('sf.form.new') : t('sf.form.edit', { name: folder.folder_name }); return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${title}
${this._error ? html`
${this._error}
` : nothing} ${mode === 'create' ? html`
this._patch('folder_name', e.target.value)} />
${t('sf.form.name_desc')}
` : html`
shared/${folder.folder_name}
`}
${t('sf.form.desc_desc')}
`; } }