// Shared helpers — Icon, Modal, form primitives. // // Icon renders real, React-owned elements built from lucide's icon // data (window.lucide.icons) instead of the classic "render // then call lucide.createIcons() to mutate it into an " pattern. That // mutation happens outside React's virtual DOM, so whenever a re-render // touched the same node afterward, React's reconciler could be asked to // remove a child that lucide had already swapped out from under it — // surfacing as "NotFoundError: Failed to execute 'removeChild'" crashes, // intermittently, anywhere an icon's surrounding list/state changed. function tfIconPascalName(name) { return name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(''); } function Icon({ name, size = 18, style, strokeWidth = 1.75 }) { const nodes = window.lucide && window.lucide.icons && window.lucide.icons[tfIconPascalName(name)]; if (!nodes) { return React.createElement('span', { style: { width: size, height: size, display: 'inline-flex', flexShrink: 0, ...style } }); } return React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'inline-flex', flexShrink: 0, ...style }, }, nodes.map(([tag, attrs], i) => React.createElement(tag, { key: i, ...attrs }))); } // Kept as a harmless no-op for any lingering callers — Icon no longer // depends on lucide.createIcons() to render. function useLucide() {} // ── Generic overlay modal ───────────────────────────────────── function Modal({ open, onClose, title, children, onSubmit, loading, submitLabel }) { if (!open) return null; return (
e.target === e.currentTarget && onClose()}>

{title}

{children}
); } // ── Form primitives ─────────────────────────────────────────── function FormRow({ label, required, children }) { return (
{children}
); } // Reusable field style objects const FF = { input: { width: '100%', height: 36, padding: '0 10px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--text-strong)', background: 'var(--slate-0)', boxSizing: 'border-box', outline: 'none', }, select: { width: '100%', height: 36, padding: '0 10px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--text-strong)', background: 'var(--slate-0)', cursor: 'pointer', boxSizing: 'border-box', outline: 'none', }, row2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }, }; // ── Google Drive picker (OAuth via Google Identity Services + Picker API) ── // Shared by any screen that wants to attach real Drive files (Docs & Files, // the legacy Files screen, etc). Needs a Client ID + API key configured on // the Integrations screen first — nothing loads or runs until a screen // actually calls pickFilesFromGoogleDrive(). function readIntegrationsCfg() { try { return JSON.parse(localStorage.getItem('tf_integrations') || '{}'); } catch { return {}; } } function loadScriptOnce(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) return resolve(); const s = document.createElement('script'); s.src = src; s.async = true; s.defer = true; s.onload = () => resolve(); s.onerror = () => reject(new Error('Failed to load ' + src)); document.head.appendChild(s); }); } // Cached in memory only (never persisted to disk) — survives client-side // navigation within the same tab so the OAuth popup only shows once per // session instead of on every single "Google Drive" click, but disappears // on a full page reload / new tab. let _driveTokenCache = null; // { accessToken, expiresAt, clientId } function openDrivePicker(accessToken, apiKey) { return new Promise((resolve, reject) => { try { const view = new window.google.picker.DocsView().setIncludeFolders(true).setSelectFolderEnabled(false); const picker = new window.google.picker.PickerBuilder() .addView(view) .setOAuthToken(accessToken) .setDeveloperKey(apiKey) .setCallback(data => { if (data.action === window.google.picker.Action.PICKED) resolve(data.docs || []); else if (data.action === window.google.picker.Action.CANCEL) resolve([]); }) .build(); picker.setVisible(true); } catch (err) { reject(err); } }); } async function pickFilesFromGoogleDrive({ clientId, apiKey }) { await Promise.all([ loadScriptOnce('https://accounts.google.com/gsi/client'), loadScriptOnce('https://apis.google.com/js/api.js'), ]); await new Promise((resolve, reject) => window.gapi.load('picker', { callback: resolve, onerror: reject })); const cached = _driveTokenCache; if (cached && cached.clientId === clientId && cached.expiresAt > Date.now() + 60000) { return openDrivePicker(cached.accessToken, apiKey); } return new Promise((resolve, reject) => { const tokenClient = window.google.accounts.oauth2.initTokenClient({ client_id: clientId, // drive.file: only files the user opens/creates through this app — // no request for blanket access to their whole Drive. scope: 'https://www.googleapis.com/auth/drive.file', callback: async (resp) => { if (resp.error) { reject(new Error(resp.error_description || resp.error)); return; } _driveTokenCache = { accessToken: resp.access_token, expiresAt: Date.now() + Number(resp.expires_in || 3600) * 1000, clientId }; try { resolve(await openDrivePicker(resp.access_token, apiKey)); } catch (err) { reject(err); } }, }); tokenClient.requestAccessToken(); }); } // A file counts as "Drive-origin" if it's a native Google format (Doc/Sheet/ // Slide) or its link points at drive.google.com — covers regular files // (photos, PDFs, etc) picked from Drive too, since the Picker always hands // back a drive.google.com viewer link for those rather than raw file bytes. // Those links only work as a normal browser navigation, not as the src of // an /