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 = "= $ssoToken ?>";
+ */
+(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);
+ }
+}