/** * Git Sync — auto-loaded admin-next widget. * * Registered with `autoLoad: true, showFab: false`, so admin-next pulls * this script on every admin page load but never instantiates the custom * element (there's no FAB, no panel). The script's job is to: * * 1. Listen at the window level for the `grav:plugin-page-action` event * that the plugin page header dispatches when the Wizard action is * clicked. (Patched into admin-next's `executeAction` for blueprint * pages with no endpoint / no navigate.) * 2. Render the wizard as a centered modal portal'd into document.body * so it's not constrained by the floating-widget panel chrome. * * The wizard mirrors the four-step flow from the admin-classic Twig * partial (`templates/partials/modal-wizard.html.twig`) but talks to the * plugin's API endpoints (`/git-sync/wizard/state`, `/git-sync/data`, * `/git-sync/test-connection`) instead of admin-classic tasks. */ const TAG = window.__GRAV_WIDGET_TAG; // ─── API helpers ───────────────────────────────────────────────────────── function apiUrl(path) { return (window.__GRAV_API_SERVER_URL || '') + (window.__GRAV_API_PREFIX || '/api/v1') + path; } function apiHeaders(json = false) { const h = {}; const token = window.__GRAV_API_TOKEN; if (token) h['X-API-Token'] = token; if (json) h['Content-Type'] = 'application/json'; return h; } async function apiCall(method, path, body) { const opts = { method, headers: apiHeaders(!!body) }; if (body) opts.body = JSON.stringify(body); const resp = await fetch(apiUrl(path), opts); const text = await resp.text(); let json = {}; try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; } if (!resp.ok) { const msg = (json.errors && json.errors[0] && json.errors[0].detail) || json.detail || json.message || `HTTP ${resp.status}`; throw new Error(msg); } return json.data ?? json; } // ─── Service catalogue (mirrors admin-classic wizard) ─────────────────── const SERVICES = { github: { host: 'github.com', branch: 'main', create: 'https://github.com/join?source=header-home' }, bitbucket: { host: 'bitbucket.org', branch: 'master', create: 'https://bitbucket.org/account/signup/' }, gitlab: { host: 'gitlab.com', branch: 'master', create: 'https://gitlab.com/users/sign_up' }, allothers: { host: 'allothers.repo', branch: 'master', create: null }, }; const GIT_REGEX = /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|#[-\d\w._]+?)$/; function detectServiceFromUrl(url) { if (!url) return null; if (url.includes('github.com')) return 'github'; if (url.includes('bitbucket.org')) return 'bitbucket'; if (url.includes('gitlab.com')) return 'gitlab'; return 'allothers'; } // ─── Wizard modal ─────────────────────────────────────────────────────── class WizardModal { constructor() { this.host = null; this.shadow = null; this.step = 0; this.maxStep = 4; this.state = null; this.frontendUrl = ''; this.draft = { service: '', no_user: false, user: '', password: '', repository: '', branch: 'main', webhook: '', webhook_enabled: false, webhook_secret: '', folders: ['pages'], }; this.testing = false; this.testResult = null; this.saving = false; this.saveError = ''; } async open() { if (this.host) return; this.host = document.createElement('div'); this.host.setAttribute('data-grav-gitsync-wizard', ''); this.shadow = this.host.attachShadow({ mode: 'open' }); document.body.appendChild(this.host); this._injectStyles(); // Disable page scroll this._prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; // Esc to close this._onKeydown = (e) => { if (e.key === 'Escape') this.close(); }; window.addEventListener('keydown', this._onKeydown); // Render skeleton, then load state this._render(); try { const state = await apiCall('GET', '/git-sync/wizard/state'); this.state = state; // Server-derived public site URL (Uri::base + Uri::rootUrl) so // that Grav installs in a sub-folder render the right webhook URL. this.frontendUrl = state.frontend_url || window.location.origin; // Pre-fill from saved settings const s = state.settings || {}; if (s.repository) { this.draft.service = detectServiceFromUrl(s.repository) || 'allothers'; this.draft.repository = s.repository; } if (typeof s.no_user === 'boolean') this.draft.no_user = s.no_user; if (s.user) this.draft.user = s.user; if (s.branch) this.draft.branch = s.branch; if (s.webhook) this.draft.webhook = s.webhook; if (typeof s.webhook_enabled === 'boolean') this.draft.webhook_enabled = s.webhook_enabled; if (s.webhook_secret) this.draft.webhook_secret = s.webhook_secret; if (Array.isArray(s.folders) && s.folders.length) this.draft.folders = s.folders.slice(); } catch (err) { console.warn('[git-sync] wizard state load failed:', err); this.state = { git_installed: true, settings: {} }; } this._render(); } close() { if (!this.host) return; document.body.style.overflow = this._prevOverflow || ''; window.removeEventListener('keydown', this._onKeydown); this.host.remove(); this.host = null; this.shadow = null; this.step = 0; this.testResult = null; this.saveError = ''; } _injectStyles() { const style = document.createElement('style'); style.textContent = ` :host { all: initial; font-family: inherit; } * { box-sizing: border-box; } .backdrop { position: fixed; inset: 0; background: rgb(23 23 23 / 0.75); backdrop-filter: blur(4px); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 1rem; animation: fadeIn 150ms ease-out; } .modal { width: 100%; max-width: 720px; max-height: calc(100vh - 2rem); background: var(--card, #fff); color: var(--card-foreground, var(--foreground, #0f172a)); border: 1px solid var(--border, #e2e8f0); border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); display: flex; flex-direction: column; overflow: hidden; animation: pop 180ms cubic-bezier(0.16, 1, 0.3, 1); } .header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border, #e2e8f0); } .title { font-size: 1.05rem; font-weight: 600; } .step-pill { font-size: 0.7rem; color: var(--muted-foreground, #64748b); margin-left: 0.5rem; } .close-btn { background: transparent; border: 0; color: var(--muted-foreground, #64748b); cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; line-height: 0; } .close-btn:hover { background: var(--accent, #f1f5f9); color: var(--foreground); } .body { padding: 1.25rem; overflow-y: auto; flex: 1; font-size: 0.875rem; line-height: 1.5; } .body p { margin: 0 0 0.75rem 0; } .body p:last-child { margin-bottom: 0; } .body code { background: var(--muted, #f1f5f9); border-radius: 0.25rem; padding: 0.05rem 0.3rem; font-size: 0.8125rem; font-family: ui-monospace, SFMono-Regular, monospace; } .body ul, .body ol { padding-left: 1.25rem; margin: 0 0 0.75rem 0; } .body li { margin-bottom: 0.25rem; } .body h4 { margin: 1rem 0 0.5rem 0; font-size: 0.95rem; } .footer { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.875rem 1.25rem; background: var(--muted, #f8fafc); border-top: 1px solid var(--border, #e2e8f0); } .footer-right { display: flex; gap: 0.5rem; } .btn { font: inherit; font-size: 0.8125rem; font-weight: 500; height: 2rem; padding: 0 0.75rem; background: var(--background, #fff); color: var(--foreground, #0f172a); border: 1px solid var(--border, #e2e8f0); border-radius: 0.375rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.375rem; transition: background 120ms ease, border-color 120ms ease; } .btn:hover { background: var(--accent, #f1f5f9); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn-primary { background: var(--primary, #0ea5e9); color: var(--primary-foreground, #fff); border-color: var(--primary, #0ea5e9); } .btn-primary:hover { background: color-mix(in srgb, var(--primary, #0ea5e9) 85%, black); } .btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; } .btn-danger:hover { background: #dc2626; border-color: #dc2626; } .hosting-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin-top: 0.75rem; } .host-card { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 1rem; border: 1px solid var(--border, #e2e8f0); border-radius: 0.5rem; cursor: pointer; background: var(--background, #fff); transition: border-color 120ms, box-shadow 120ms; } .host-card:hover { border-color: var(--primary, #0ea5e9); } .host-card.selected { border-color: var(--primary, #0ea5e9); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary, #0ea5e9) 20%, transparent); } .host-card .name { font-weight: 600; font-size: 0.875rem; } .host-card .small { font-size: 0.75rem; color: var(--muted-foreground, #64748b); } label.field { display: block; margin-bottom: 0.875rem; } label.field > .lbl { display: flex; justify-content: space-between; align-items: center; font-size: 0.8125rem; font-weight: 600; margin-bottom: 0.375rem; } label.field input[type="text"], label.field input[type="password"] { width: 100%; height: 2.25rem; padding: 0 0.625rem; font: inherit; font-size: 0.875rem; background: var(--background, #fff); color: var(--foreground, #0f172a); border: 1px solid var(--border, #e2e8f0); border-radius: 0.375rem; } label.field input:focus { outline: none; border-color: var(--primary, #0ea5e9); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0ea5e9) 20%, transparent); } label.field input.invalid { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.2); } .inline-checkbox { font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground, #64748b); display: inline-flex; align-items: center; gap: 0.25rem; } .creds-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .verify-row { display: flex; justify-content: center; margin: 0.75rem 0; } .test-result { margin-top: 0.5rem; padding: 0.625rem 0.75rem; border-radius: 0.375rem; font-size: 0.8125rem; } .test-result.success { background: rgba(34, 197, 94, 0.1); color: #15803d; border: 1px solid rgba(34, 197, 94, 0.3); } .test-result.error { background: rgba(239, 68, 68, 0.1); color: #b91c1c; border: 1px solid rgba(239, 68, 68, 0.3); } .folder-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; } .folder-card { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 0.75rem; border: 1px solid var(--border, #e2e8f0); border-radius: 0.375rem; cursor: pointer; background: var(--background, #fff); } .folder-card.selected { border-color: var(--primary, #0ea5e9); background: color-mix(in srgb, var(--primary, #0ea5e9) 8%, var(--background, #fff)); } .folder-card .warn { font-size: 0.7rem; color: #b45309; } .save-error { margin-top: 0.75rem; padding: 0.625rem 0.75rem; background: rgba(239, 68, 68, 0.1); color: #b91c1c; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 0.375rem; font-size: 0.8125rem; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes pop { from { opacity: 0; transform: scale(0.96) translateY(6px); } to { opacity: 1; transform: scale(1) translateY(0); } } .spin { display: inline-block; width: 0.875rem; height: 0.875rem; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } `; this.shadow.appendChild(style); } _render() { const container = this.shadow.querySelector('.backdrop') || (() => { const el = document.createElement('div'); el.className = 'backdrop'; // Backdrop dismissal guard: only close when both mousedown AND // mouseup land on the backdrop itself. Without this, dragging // a text selection out of an input inside the modal — or any // mousedown-inside-mouseup-outside motion — fires a click on // the backdrop and snaps the wizard shut. The wizard form is // long enough that accidental drags happen often. let mouseDownOnBackdrop = false; el.addEventListener('mousedown', (e) => { mouseDownOnBackdrop = (e.target === el); }); el.addEventListener('mouseup', (e) => { if (mouseDownOnBackdrop && e.target === el && !this.saving) { this.close(); } mouseDownOnBackdrop = false; }); this.shadow.appendChild(el); return el; })(); const stepLabel = ['Welcome', 'Hosting Service', 'Repository', 'Webhook', 'Folders'][this.step]; const isReady = this.state !== null; const gitInstalled = !this.state || this.state.git_installed !== false; container.innerHTML = `
Loading wizard…
`; } _renderNoGit() { return `The Git Sync plugin requires the git binary to be installed and reachable on the server's PATH.
If git is missing, ask your hosting provider to install it, or set a custom Git Binary Path on the settings form below the wizard.
This wizard walks you through setting up Git Sync in four steps. When done, your site will keep itself in sync with a remote git repository.
user/ folders to keep in sync.Press Next to begin.
`; } _step1() { const sel = this.draft.service; const services = [ { id: 'github', label: 'GitHub' }, { id: 'bitbucket', label: 'Bitbucket' }, { id: 'gitlab', label: 'GitLab' }, { id: 'allothers', label: 'Other Git' }, ]; return `Choose the git host you'll be using and enter your username and password (or an access token / app password).
Paste the full HTTPS clone URL of your repository. Most hosts list it on the project page next to "Clone".
If you're starting from scratch, create the repo on the host first and check "initialize with a README" — Git Sync needs an initial commit to clone from.
A webhook lets the remote repository tell your site about pushes so changes show up immediately. Set the URL below in the repo's webhook settings on your git host.
Full URL: ${frontendUrl}${this.draft.webhook || '/_git-sync'}
Pick which user/ folders to keep under git control. You can change this later from the settings form.