components_share_dialog.js

/**
 * Modal dialog for managing project or folder sharing.
 *
 * Supports per-user role assignment (owner/editor/viewer), user search,
 * "anyone with link" access (owners only), ownership transfer, and
 * optional email notifications.
 *
 * @param {string} entityId  - Project ID or folder path.
 * @param {object} server    - Server instance (for token + baseUrl + backendUser).
 * @param {'project'|'folder'} [type='project']
 */
export class ShareDialog {
    /**
     * @param {string} entityId - Project ID or folder path.
     * @param {object} server - Server instance (for token, baseUrl, and backendUser).
     * @param {'project'|'folder'} [type] - Entity type; defaults to 'project'.
     */
    constructor(entityId, server, type = 'project') {
        this.entityId = entityId;
        this.server   = server;
        this.type     = type;

        /** Resolved permissions: {owners, editors, viewers, any_with_link, public} */
        this._perms       = { owners: [], editors: [], viewers: [], any_with_link: false, public: false };
        this._isOwner     = false;
        this._searchTimer = null;

        this._buildDOM();
        document.body.appendChild(this.root);
        this._load();
    }

    // ── API helpers ───────────────────────────────────────────────────────────

    /** @returns {string} API permissions URL for the current entity. */
    _permPath() {
        return this.type === 'folder'
            ? `${this.server.baseUrl}/api/folders/${this.entityId}/permissions`
            : `${this.server.baseUrl}/api/projects/${this.entityId}/permissions`;
    }

    /**
     * @param {object} [extra] - additional headers to merge in
     * @returns {Promise<object>}
     */
    async _authHeaders(extra = {}) {
        const token = await this.server.getToken();
        return { ...(token ? { 'X-Auth-Token': token } : {}), ...extra };
    }

    // ── DOM construction ──────────────────────────────────────────────────────

    /** Builds and appends the dialog scrim and root DOM elements. */
    _buildDOM() {
        this.scrim = document.createElement('div');
        this.scrim.className = 'share-scrim';
        this.scrim.addEventListener('click', () => this.close());
        document.body.appendChild(this.scrim);

        this.root = document.createElement('div');
        this.root.className = 'share-dialog';
        this.root.innerHTML = `
            <div class="share-dialog-header">
                <h2 class="share-dialog-title">Share</h2>
                <button class="share-close-btn" title="Close">&#x2715;</button>
            </div>
            <div class="share-dialog-body" id="shareBody">
                <div class="share-loading">Loading…</div>
            </div>
            <div class="share-status" id="shareStatus" style="display:none"></div>
        `;

        this.root.querySelector('.share-close-btn').addEventListener('click', () => this.close());
        this._body   = this.root.querySelector('#shareBody');
        this._status = this.root.querySelector('#shareStatus');
        document.body.appendChild(this.root);
    }

    // ── Load & render ─────────────────────────────────────────────────────────

    /** Fetches current permissions from the server and triggers a re-render. */
    async _load() {
        try {
            const res = await fetch(this._permPath(), {
                headers: await this._authHeaders(),
                credentials: 'include',
            });
            if (!res.ok) throw new Error('Failed to load permissions');
            this._perms = await res.json();
        } catch {
            this._perms = { owners: [], editors: [], viewers: [], any_with_link: false, public: false };
        }

        const myId = this.server.backendUser?.id;
        this._isOwner = (this._perms.owners || []).some(u => u.id === myId);
        this._render();
    }

    /** Rebuilds the dialog body from current permissions state. */
    _render() {
        this._body.innerHTML = '';

        this._body.appendChild(this._buildAddSection());
        this._body.appendChild(this._buildPeopleSection());

        if (this._isOwner) {
            const linkSection = this._buildLinkSection();
            if (linkSection) this._body.appendChild(linkSection);
        }

        if (this.type === 'project') {
            this._body.appendChild(this._buildActionsSection());
        }
    }

    // ── Add people section ────────────────────────────────────────────────────

    /** @returns {HTMLElement} the "add people" search row section */
    _buildAddSection() {
        const wrap = document.createElement('div');
        wrap.className = 'share-add-section';

        const row = document.createElement('div');
        row.className = 'share-add-row';

        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'share-search-input';
        input.placeholder = 'Add people by name or email…';
        input.autocomplete = 'off';

        const roleSelect = document.createElement('select');
        roleSelect.className = 'share-role-select share-role-select--add';
        roleSelect.innerHTML = `
            <option value="viewer">Viewer</option>
            <option value="editor">Editor</option>
        `;

        const notifyWrap = document.createElement('label');
        notifyWrap.className = 'share-notify-label';
        notifyWrap.innerHTML = `<input type="checkbox" id="shareNotify" checked /> Notify`;

        row.appendChild(input);
        row.appendChild(roleSelect);
        row.appendChild(notifyWrap);

        const dropdown = document.createElement('div');
        dropdown.className = 'share-search-dropdown';
        dropdown.style.display = 'none';

        wrap.appendChild(row);
        wrap.appendChild(dropdown);

        input.addEventListener('input', () => {
            clearTimeout(this._searchTimer);
            const q = input.value.trim();
            if (!q) { dropdown.style.display = 'none'; return; }
            this._searchTimer = setTimeout(() => this._runSearch(q, dropdown, input, roleSelect, notifyWrap), 280);
        });

        input.addEventListener('keydown', e => {
            if (e.key === 'Escape') { dropdown.style.display = 'none'; input.value = ''; }
        });

        document.addEventListener('click', e => {
            if (!wrap.contains(e.target)) dropdown.style.display = 'none';
        }, { capture: true });

        return wrap;
    }

    /**
     * Searches for users matching the query and populates the dropdown.
     * @param {string} query - Search string typed by the user.
     * @param {HTMLElement} dropdown - Container element for search results.
     * @param {HTMLElement} input - Text input to clear after selection.
     * @param {HTMLElement} roleSelect - Role selector whose value is applied on selection.
     * @param {HTMLElement} notifyWrap - Wrapper containing the notify checkbox.
     */
    async _runSearch(query, dropdown, input, roleSelect, notifyWrap) {
        try {
            const res = await fetch(
                `${this.server.baseUrl}/api/users/search?q=${encodeURIComponent(query)}`,
                { headers: await this._authHeaders(), credentials: 'include' }
            );
            if (!res.ok) return;
            const results = await res.json();

            // Filter out people who already have access
            const existingIds = new Set([
                ...(this._perms.owners  || []).map(u => u.id),
                ...(this._perms.editors || []).map(u => u.id),
                ...(this._perms.viewers || []).map(u => u.id),
            ]);
            const filtered = results.filter(u => !existingIds.has(u.id));

            dropdown.innerHTML = '';
            if (!filtered.length) {
                dropdown.innerHTML = '<div class="share-search-empty">No users found</div>';
                dropdown.style.display = '';
                return;
            }
            filtered.forEach(u => {
                const item = document.createElement('div');
                item.className = 'share-search-item';
                item.innerHTML = `
                    <div class="share-search-avatar">${this._initials(u.display_name || u.email)}</div>
                    <div class="share-search-info">
                        <span class="share-search-name">${this._esc(u.display_name || '—')}</span>
                        <span class="share-search-email">${this._esc(u.email || '')}</span>
                    </div>
                `;
                item.style.setProperty('--avatar-bg', this._hashColor(u.id));
                item.addEventListener('click', () => {
                    dropdown.style.display = 'none';
                    input.value = '';
                    const role    = roleSelect.value;
                    const notify  = notifyWrap.querySelector('input').checked;
                    this._addUser(u, role, notify);
                });
                dropdown.appendChild(item);
            });
            dropdown.style.display = '';
        } catch {
            /* ignore */
        }
    }

    /**
     * Grants the user access at the given role and refreshes the UI.
     * @param {object} user - User object from search results.
     * @param {string} role - Role to grant: 'editor' or 'viewer'.
     * @param {boolean} notify - Whether to email the user about the new access.
     */
    async _addUser(user, role, notify) {
        const updated = this._clonePerms();
        if (role === 'editor') {
            updated.editors.push(user.id);
        } else {
            updated.viewers.push(user.id);
        }
        const ok = await this._putPerms(updated, notify);
        if (ok) {
            if (role === 'editor') {
                this._perms.editors = [...(this._perms.editors || []), user];
            } else {
                this._perms.viewers = [...(this._perms.viewers || []), user];
            }
            this._render();
            this._showStatus(`Added ${user.display_name || user.email}`);
        }
    }

    // ── People list section ───────────────────────────────────────────────────

    /** @returns {HTMLElement} the people list section */
    _buildPeopleSection() {
        const section = document.createElement('div');
        section.className = 'share-people-section';

        const allGroups = [
            { role: 'owner',  users: this._perms.owners  || [] },
            { role: 'editor', users: this._perms.editors || [] },
            { role: 'viewer', users: this._perms.viewers || [] },
        ];

        const hasAnyone = allGroups.some(g => g.users.length > 0);
        if (!hasAnyone) {
            section.innerHTML = '<div class="share-people-empty">Not shared with anyone yet.</div>';
            return section;
        }

        allGroups.forEach(({ role, users }) => {
            users.forEach(u => {
                section.appendChild(this._buildUserRow(u, role));
            });
        });

        return section;
    }

    /**
     * Builds a single user row with avatar, name, and role controls.
     * @param {object} user - User object with id, display_name, and email.
     * @param {string} role - Current role: 'owner', 'editor', or 'viewer'.
     * @returns {HTMLElement}
     */
    _buildUserRow(user, role) {
        const row = document.createElement('div');
        row.className = 'share-user-row';

        const avatar = document.createElement('div');
        avatar.className = 'share-user-avatar';
        avatar.textContent = this._initials(user.display_name || user.email);
        avatar.style.setProperty('--avatar-bg', this._hashColor(user.id));

        const info = document.createElement('div');
        info.className = 'share-user-info';
        info.innerHTML = `
            <span class="share-user-name">${this._esc(user.display_name || '—')}</span>
            <span class="share-user-email">${this._esc(user.email || '')}</span>
        `;

        const controls = document.createElement('div');
        controls.className = 'share-user-controls';

        const myId = this.server.backendUser?.id;
        const isSelf = user.id === myId;

        if (role === 'owner') {
            const badge = document.createElement('span');
            badge.className = 'share-role-badge';
            badge.textContent = 'Owner';
            controls.appendChild(badge);

            // Transfer ownership button (owners only, not for self)
            if (this._isOwner && !isSelf) {
                const transferBtn = document.createElement('button');
                transferBtn.className = 'share-transfer-btn';
                transferBtn.textContent = 'Transfer';
                transferBtn.addEventListener('click', () => this._confirmTransfer(user));
                controls.appendChild(transferBtn);
            }
        } else if (this._isOwner) {
            const select = document.createElement('select');
            select.className = 'share-role-select';
            select.innerHTML = `
                <option value="editor" ${role === 'editor' ? 'selected' : ''}>Editor</option>
                <option value="viewer" ${role === 'viewer' ? 'selected' : ''}>Viewer</option>
            `;
            select.addEventListener('change', () => this._changeRole(user, role, select.value));

            const removeBtn = document.createElement('button');
            removeBtn.className = 'share-remove-btn';
            removeBtn.title = 'Remove';
            removeBtn.innerHTML = '&#x2715;';
            removeBtn.addEventListener('click', () => this._removeUser(user, role));

            controls.appendChild(select);
            controls.appendChild(removeBtn);
        } else {
            const badge = document.createElement('span');
            badge.className = 'share-role-badge share-role-badge--muted';
            badge.textContent = role.charAt(0).toUpperCase() + role.slice(1);
            controls.appendChild(badge);
        }

        row.appendChild(avatar);
        row.appendChild(info);
        row.appendChild(controls);
        return row;
    }

    /**
     * Changes a user's role and persists the update.
     * @param {object} user - User object being updated.
     * @param {string} oldRole - Current role before the change.
     * @param {string} newRole - New role to assign.
     */
    async _changeRole(user, oldRole, newRole) {
        if (oldRole === newRole) return;
        const updated = this._clonePerms();
        updated[oldRole + 's'] = updated[oldRole + 's'].filter(id => id !== user.id);
        updated[newRole + 's'].push(user.id);
        const ok = await this._putPerms(updated, false);
        if (ok) {
            this._perms[oldRole + 's'] = (this._perms[oldRole + 's'] || []).filter(u => u.id !== user.id);
            this._perms[newRole + 's'] = [...(this._perms[newRole + 's'] || []), user];
            this._render();
            this._showStatus(`${user.display_name || user.email} is now ${newRole}`);
        }
    }

    /**
     * Removes a user's access and persists the update.
     * @param {object} user - User object to remove.
     * @param {string} role - Current role of the user being removed.
     */
    async _removeUser(user, role) {
        const updated = this._clonePerms();
        updated[role + 's'] = updated[role + 's'].filter(id => id !== user.id);
        const ok = await this._putPerms(updated, false);
        if (ok) {
            this._perms[role + 's'] = (this._perms[role + 's'] || []).filter(u => u.id !== user.id);
            this._render();
            this._showStatus(`Removed ${user.display_name || user.email}`);
        }
    }

    // ── Transfer ownership ────────────────────────────────────────────────────

    /**
     * Shows a confirmation modal before transferring ownership.
     * @param {object} newOwner - the user to transfer ownership to
     */
    _confirmTransfer(newOwner) {
        const myId   = this.server.backendUser?.id;
        const myInfo = (this._perms.owners || []).find(u => u.id === myId);
        const myName = myInfo?.display_name || myInfo?.email || 'you';

        const modal = document.createElement('div');
        modal.className = 'share-transfer-modal';
        modal.innerHTML = `
            <div class="share-transfer-card">
                <h3>Transfer ownership?</h3>
                <p>
                    Transfer ownership of this ${this.type} to
                    <strong>${this._esc(newOwner.display_name || newOwner.email)}</strong>?<br>
                    <strong>${this._esc(myName)}</strong> will be demoted to editor.
                    This cannot be undone without their consent.
                </p>
                <div class="share-transfer-actions">
                    <button class="share-transfer-cancel">Cancel</button>
                    <button class="share-transfer-confirm">Transfer</button>
                </div>
            </div>
        `;
        modal.querySelector('.share-transfer-cancel').addEventListener('click', () => modal.remove());
        modal.querySelector('.share-transfer-confirm').addEventListener('click', async () => {
            modal.remove();
            await this._doTransfer(newOwner);
        });
        this.root.appendChild(modal);
    }

    /**
     * Executes the ownership transfer and reloads permissions.
     * @param {object} newOwner - the user to become the new owner
     */
    async _doTransfer(newOwner) {
        const myId  = this.server.backendUser?.id;
        const updated = this._clonePerms();

        // New owner replaces current owner list entry; old owner demoted to editor server-side
        updated.owners = [newOwner.id];
        // Retain other existing owners too (multi-owner scenario)
        const otherOwners = (this._perms.owners || []).filter(u => u.id !== myId && u.id !== newOwner.id);
        updated.owners.push(...otherOwners.map(u => u.id));
        // Remove newOwner from editors/viewers if present
        updated.editors = updated.editors.filter(id => id !== newOwner.id);
        updated.viewers = updated.viewers.filter(id => id !== newOwner.id);

        const ok = await this._putPerms(updated, false);
        if (ok) {
            await this._load(); // Reload to reflect demotion server applied
            this._showStatus('Ownership transferred');
        }
    }

    // ── Anyone with link section ──────────────────────────────────────────────

    /** @returns {HTMLElement} the "anyone with link" toggle section */
    _buildLinkSection() {
        const presUrl = this.type === 'project'
            ? `${window.location.origin}/presentation/${this.entityId}`
            : `${window.location.origin}/presentation/folder/${this.entityId}`;

        const section = document.createElement('div');
        section.className = 'share-section';

        const row = document.createElement('div');
        row.className = 'share-row';
        row.innerHTML = `
            <div class="share-row-text">
                <span class="share-label">Anyone with link</span>
                <span class="share-desc">Anyone with this link can view without signing in.</span>
            </div>
            <label class="share-toggle">
                <input type="checkbox" id="shareAnyWithLink" ${this._perms.any_with_link ? 'checked' : ''} />
                <span class="share-toggle-track"></span>
            </label>
        `;

        const linkSection = document.createElement('div');
        linkSection.className = 'share-link-section';
        linkSection.style.display = this._perms.any_with_link ? '' : 'none';
        linkSection.innerHTML = `
            <div class="share-link-label">Shareable link</div>
            <div class="share-link-row">
                <input class="share-link-input" type="text" readonly value="${this._esc(presUrl)}" />
                <button class="share-link-copy">Copy</button>
            </div>
        `;

        const toggle = row.querySelector('#shareAnyWithLink');
        toggle.addEventListener('change', async () => {
            toggle.disabled = true;
            const newVal = toggle.checked;
            const updated = this._clonePerms();
            updated.any_with_link = newVal;
            const ok = await this._putPerms(updated, false);
            if (ok) {
                this._perms.any_with_link = newVal;
                linkSection.style.display = newVal ? '' : 'none';
            } else {
                toggle.checked = !newVal;
            }
            toggle.disabled = false;
        });

        const copyBtn = linkSection.querySelector('.share-link-copy');
        copyBtn.addEventListener('click', () => {
            navigator.clipboard.writeText(presUrl).then(() => {
                copyBtn.textContent = 'Copied!';
                setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
            });
        });

        section.appendChild(row);
        section.appendChild(linkSection);
        return section;
    }

    // ── Open presentation button (projects only) ──────────────────────────────

    /** @returns {HTMLElement} the actions footer section with the open-presentation button */
    _buildActionsSection() {
        const wrap = document.createElement('div');
        wrap.className = 'share-actions';
        const btn = document.createElement('button');
        btn.className = 'share-open-btn';
        btn.textContent = 'Open Presentation';
        btn.addEventListener('click', () => {
            window.open(`/presentation/${this.entityId}`, '_blank');
        });
        wrap.appendChild(btn);
        return wrap;
    }

    // ── PUT helper ────────────────────────────────────────────────────────────

    /**
     * Convert internal {owners: [userObj]} shape to {owners: [id]} for the API.
     * @returns {object} permissions payload with ID arrays
     */
    _clonePerms() {
        return {
            owners:        (this._perms.owners  || []).map(u => u.id),
            editors:       (this._perms.editors || []).map(u => u.id),
            viewers:       (this._perms.viewers || []).map(u => u.id),
            any_with_link: this._perms.any_with_link,
            public:        this._perms.public,
        };
    }

    /**
     * Sends updated permissions to the server.
     * @param {object} permissions - Permissions payload with owner/editor/viewer ID arrays and link flags.
     * @param {boolean} [notify] - Whether to notify affected users by email.
     * @returns {Promise<boolean>}
     */
    async _putPerms(permissions, notify = true) {
        try {
            const res = await fetch(this._permPath(), {
                method: 'PUT',
                headers: await this._authHeaders({ 'Content-Type': 'application/json' }),
                credentials: 'include',
                body: JSON.stringify({ permissions, notify }),
            });
            if (!res.ok) {
                let err;
                try {
                    err = await res.json();
                } catch {
                    err = {};
                }
                this._showStatus(err.error || 'Could not update sharing settings.', true);
                return false;
            }
            return true;
        } catch {
            this._showStatus('Could not update sharing settings.', true);
            return false;
        }
    }

    // ── Utilities ─────────────────────────────────────────────────────────────

    /**
     * Displays a transient status message at the bottom of the dialog.
     * @param {string} msg - Message text to display.
     * @param {boolean} [isError] - If true, renders in error color.
     */
    _showStatus(msg, isError = false) {
        this._status.textContent = msg;
        this._status.style.display = '';
        this._status.style.color = isError ? 'var(--danger)' : 'var(--success)';
        clearTimeout(this._statusTimer);
        this._statusTimer = setTimeout(() => { this._status.style.display = 'none'; }, 3000);
    }

    /**
     * @param {string} name - Display name or email to derive initials from.
     * @returns {string} 1-2 letter initials
     */
    _initials(name) {
        if (!name) return '?';
        const parts = name.trim().split(/\s+/);
        if (parts.length === 1) return parts[0][0].toUpperCase();
        return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
    }

    /**
     * @param {string} id - User ID to hash into a color.
     * @returns {string} deterministic HSL color
     */
    _hashColor(id) {
        let hash = 0;
        for (let i = 0; i < (id || '').length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
        const h = Math.abs(hash) % 360;
        return `hsl(${h}, 55%, 45%)`;
    }

    /**
     * @param {string} str - Raw string to escape.
     * @returns {string} HTML-escaped string
     */
    _esc(str) {
        return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }

    /** Removes the dialog and scrim from the DOM. */
    close() {
        this.root.remove();
        this.scrim.remove();
    }

    // ── Public: get all users with access (for avatar stacks) ────────────────

    /** @returns {Array} all users with any level of access */
    getSharedUsers() {
        return [
            ...(this._perms.owners  || []),
            ...(this._perms.editors || []),
            ...(this._perms.viewers || []),
        ];
    }
}