diff --git a/composer.json b/composer.json index 4fc1c42..471a1e7 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "php": "^8.5", "lunarphp/lunar": "1.3.0", "laravel/framework": "^12.0", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "symfony/yaml": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/resources/js/stoic_embed.js b/resources/js/stoic_embed.js new file mode 100644 index 0000000..edf344d --- /dev/null +++ b/resources/js/stoic_embed.js @@ -0,0 +1,221 @@ +/** + * Stoic CMS — in-place editor script + * + * Vendor this file into your website's asset pipeline. + * + * Configuration (set before this script runs): + * window.STOIC_HOST = "https://your-cms-domain.com"; // default: same origin + * + * Usage on your HTML elements: + * data-stoic="schema/slug" open editor for this entry + * data-stoic="schema/slug#field" open editor and focus a specific field + * data-stoic="singleton#field" open editor for a singleton schema + * + * SSO: if STOIC_SSO_SECRET is configured on the server, the admin link + * will generate a signed token so the user lands directly in the CMS. + * Set window.STOIC_SSO_TOKEN before loading this script: + * window.STOIC_SSO_TOKEN = ""; + */ +(function () { + "use strict"; + + const HOST = (window.STOIC_HOST || "").replace(/\/$/, ""); + + // ── State ──────────────────────────────────────────────────────────────── + + let editMode = false; + let activePane = null; + let messageListener = null; + + // ── Toolbar ────────────────────────────────────────────────────────────── + + const bar = document.createElement("div"); + bar.className = "stoic-bar"; + + const btn = document.createElement("button"); + btn.className = "stoic-edit-toggle"; + btn.setAttribute("aria-label", "Toggle edit mode"); + btn.innerHTML = + ' Edit'; + + btn.addEventListener("click", function () { + editMode ? exitEditMode() : enterEditMode(); + }); + + const adminLink = document.createElement("a"); + adminLink.className = "stoic-admin-link"; + adminLink.target = "_blank"; + adminLink.rel = "noopener noreferrer"; + adminLink.setAttribute("aria-label", "Open CMS admin"); + adminLink.innerHTML = + ''; + + // If a pre-generated SSO token is provided, use the SSO endpoint directly. + // Otherwise link to the standard login page. + if (window.STOIC_SSO_TOKEN) { + adminLink.href = HOST + "/cms/sso?" + window.STOIC_SSO_TOKEN; + } else { + adminLink.href = HOST; + } + + bar.appendChild(btn); + bar.appendChild(adminLink); + + // ── Edit mode ──────────────────────────────────────────────────────────── + + function enterEditMode() { + editMode = true; + btn.classList.add("active"); + document.querySelectorAll("[data-stoic]").forEach(function (el) { + el.classList.add("stoic-target"); + el.addEventListener("click", onTargetClick, true); + }); + } + + function exitEditMode() { + editMode = false; + btn.classList.remove("active"); + document.querySelectorAll("[data-stoic]").forEach(function (el) { + el.classList.remove("stoic-target", "stoic-target--active"); + el.removeEventListener("click", onTargetClick, true); + }); + closePane(); + } + + function onTargetClick(e) { + e.preventDefault(); + e.stopPropagation(); + + document + .querySelectorAll(".stoic-target--active") + .forEach(function (el) { + el.classList.remove("stoic-target--active"); + }); + this.classList.add("stoic-target--active"); + + const attr = this.getAttribute("data-stoic"); + const hashIdx = attr.indexOf("#"); + const path = hashIdx === -1 ? attr : attr.slice(0, hashIdx); + const field = hashIdx === -1 ? "" : attr.slice(hashIdx + 1); + openPane(path, field); + } + + // ── Side pane ──────────────────────────────────────────────────────────── + + function openPane(path, field) { + closePane(); + + const overlay = document.createElement("div"); + overlay.className = "stoic-overlay"; + overlay.addEventListener("click", closePane); + + const pane = document.createElement("div"); + pane.className = "stoic-pane"; + + const closeBtn = document.createElement("button"); + closeBtn.className = "stoic-pane-close"; + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.innerHTML = + ''; + closeBtn.addEventListener("click", closePane); + + const iframe = document.createElement("iframe"); + iframe.className = "stoic-pane-iframe"; + let src = HOST + "/cms/embed/" + path; + if (field) src += "?focus=" + encodeURIComponent(field); + iframe.src = src; + + pane.appendChild(closeBtn); + pane.appendChild(iframe); + + document.body.appendChild(overlay); + document.body.appendChild(pane); + activePane = { pane: pane, overlay: overlay }; + + messageListener = function (e) { + if (e.data && e.data.type === "stoic:saved") { + onSaved(); + } + }; + window.addEventListener("message", messageListener); + + requestAnimationFrame(function () { + pane.classList.add("open"); + overlay.classList.add("open"); + }); + } + + function closePane() { + if (!activePane) return; + var pane = activePane.pane; + var overlay = activePane.overlay; + + pane.classList.remove("open"); + overlay.classList.remove("open"); + + setTimeout(function () { + pane.remove(); + overlay.remove(); + }, 220); + + if (messageListener) { + window.removeEventListener("message", messageListener); + messageListener = null; + } + + document + .querySelectorAll(".stoic-target--active") + .forEach(function (el) { + el.classList.remove("stoic-target--active"); + }); + + activePane = null; + } + + function onSaved() { + var activeEl = document.querySelector(".stoic-target--active"); + if (activeEl) { + activeEl.dispatchEvent( + new CustomEvent("stoic:saved", { + bubbles: true, + composed: true, + }), + ); + } + closePane(); + } + + // ── Styles ─────────────────────────────────────────────────────────────── + + var style = document.createElement("style"); + style.textContent = [ + ".stoic-bar{position:fixed;bottom:1.25rem;right:1.25rem;z-index:2147483646;display:inline-flex;align-items:center;gap:0.375rem;background:#1a1a2e;border-radius:9999px;padding:0.25rem 0.25rem 0.25rem 0.5rem;box-shadow:0 2px 12px rgba(0,0,0,.35);}", + ".stoic-edit-toggle{display:inline-flex;align-items:center;gap:0.4rem;padding:0.35rem 0.75rem;border:none;border-radius:9999px;background:transparent;color:#fff;font-size:0.8rem;font-weight:600;font-family:system-ui,sans-serif;cursor:pointer;transition:background .15s;}", + ".stoic-edit-toggle:hover{background:rgba(255,255,255,.1);}", + ".stoic-edit-toggle.active{background:#4f46e5;}", + ".stoic-admin-link{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;border-radius:50%;color:rgba(255,255,255,.6);text-decoration:none;transition:color .15s,background .15s;}", + ".stoic-admin-link:hover{color:#fff;background:rgba(255,255,255,.1);}", + ".stoic-target{outline:2px dashed rgba(79,70,229,.5)!important;outline-offset:2px!important;cursor:pointer!important;transition:outline-color .15s;}", + ".stoic-target:hover{outline-color:rgba(79,70,229,.9)!important;}", + ".stoic-target--active{outline:2px solid #4f46e5!important;outline-offset:2px!important;}", + ".stoic-overlay{position:fixed;inset:0;z-index:2147483644;background:rgba(0,0,0,.25);opacity:0;transition:opacity .2s;}", + ".stoic-overlay.open{opacity:1;}", + ".stoic-pane{position:fixed;top:0;right:0;bottom:0;z-index:2147483645;width:min(480px,100vw);background:#fff;box-shadow:-4px 0 24px rgba(0,0,0,.18);transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;}", + ".stoic-pane.open{transform:translateX(0);}", + ".stoic-pane-close{position:absolute;top:.75rem;right:.75rem;z-index:1;width:1.75rem;height:1.75rem;border:none;border-radius:50%;background:rgba(0,0,0,.07);color:#555;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;}", + ".stoic-pane-close:hover{background:rgba(0,0,0,.14);}", + ".stoic-pane-iframe{flex:1;width:100%;border:none;}", + ].join(""); + + // ── Init ───────────────────────────────────────────────────────────────── + + document.head.appendChild(style); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + document.body.appendChild(bar); + }); + } else { + document.body.appendChild(bar); + } +})(); diff --git a/src/CorePlugin.php b/src/CorePlugin.php index ed8f344..5b206f2 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -24,8 +24,8 @@ class CorePlugin implements Plugin { $panel->path('boboko') ->brandName('Boboko') - ->brandLogo(asset('logos/core/boboko-logo.svg')) - ->darkModeBrandLogo(asset('logos/core/boboko-logo-white.svg')) + ->brandLogo(asset('static/logos/core/boboko-logo.svg')) + ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->login(Login::class) ->navigationItems([ NavigationItem::make("Lucent") diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 11db2d9..6898070 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -18,7 +18,8 @@ class CoreServiceProvider extends ServiceProvider $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); $this->publishes([ - __DIR__ . '/../../resources/logos' => public_path('logos/core'), + __DIR__ . '/../../resources/logos' => public_path('static/logos/core'), + __DIR__ . '/../../resources/js/stoic_embed.js' => public_path('js/stoic_embed.js'), ], 'core-assets'); if ($this->app->runningInConsole()) { diff --git a/src/Stoic/Stoic.php b/src/Stoic/Stoic.php new file mode 100644 index 0000000..6646963 --- /dev/null +++ b/src/Stoic/Stoic.php @@ -0,0 +1,147 @@ +url($rel); + } + + $webp = substr($filename, 0, -strlen($ext)) . "webp"; + $rel = static::thumbsRelPath() . "presets/" . $preset . "/" . $webp; + + return Storage::disk("public")->url($rel); + } + + // Generates the full srcset string for all configured presets. + public static function srcset(string $filename): string + { + static::loadConfig(); + + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if ($ext === "svg") { + return ""; + } + + return collect(static::$presets) + ->map( + fn($p) => static::image($filename, $p["code"]) . + " " . + $p["width"] . + "w", + ) + ->implode(", "); + } + + // Returns [width, height] for a given preset from the sidecar JSON. + public static function dimensions(string $filename, string $preset): array + { + static::loadConfig(); + + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if ($ext === "svg") { + return [null, null]; + } + + $rel = static::thumbsRelPath() . "images/" . $filename . ".json"; + $path = Storage::disk("public")->path($rel); + $meta = json_decode(@file_get_contents($path), true); + + $p = $meta["presets"][$preset] ?? null; + if (!$p) { + return [null, null]; + } + + return [(int) $p["w"], (int) $p["h"]]; + } + + // Path of the thumbs directory relative to the public storage disk root. + // Uses a fixed marker so it works regardless of the app's base path (local vs Docker). + private static function thumbsRelPath(): string + { + $marker = "/storage/app/public/"; + $pos = strpos(static::$thumbs_path, $marker); + + if ($pos !== false) { + return substr(static::$thumbs_path, $pos + strlen($marker)); + } + + return ltrim(static::$thumbs_path, "/"); + } + + public static function token(string $email): string + { + $ttl = 60; + $payload = base64_encode( + json_encode([ + "email" => $email, + "exp" => time() + $ttl, + ]), + ); + + $sig = hash_hmac( + "sha256", + $payload, + config("services.stoic.sso_secret"), + ); + + $token = "t=" . urlencode($payload) . "&s=" . $sig; + + return $token; + } + + public static function hasMany( + string $relation, + array $slugs, + string $prefix, + ) { + $slugs = array_map( + fn($i) => str_replace($prefix . "/", "", $i), + $slugs, + ); + + return $relation::whereIn("slug", $slugs)->get(); + } + + public static function hasOne( + string $relation, + string $slug, + string $prefix, + ) { + $slug = str_replace($prefix . "/", "", $slug); + return $relation::firstWhere("slug", $slug); + } +}