Feat: Adding Stoic, Updating Core Service Provider to declare that the lunar page is called Boboko
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> 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 =
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
|
||||
|
||||
// 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 =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
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);
|
||||
}
|
||||
})();
|
||||
+2
-2
@@ -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")
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Stoic;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Stoic
|
||||
{
|
||||
public static array $config = [];
|
||||
public static array $presets = [];
|
||||
public static string $thumbs_path;
|
||||
|
||||
private static function loadConfig(): void
|
||||
{
|
||||
if (!empty(static::$config)) {
|
||||
return;
|
||||
}
|
||||
$config = Yaml::parseFile(resource_path("stoic/stoic_config.yml"));
|
||||
static::$config = $config;
|
||||
static::$thumbs_path = $config["thumbs_path"];
|
||||
static::$presets = $config["presets"];
|
||||
}
|
||||
|
||||
public static function presets(): array
|
||||
{
|
||||
static::loadConfig();
|
||||
return static::$presets;
|
||||
}
|
||||
|
||||
// Returns the public URL for a stoic image at a given preset.
|
||||
public static function image(string $filename, string $preset): string
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext === "svg") {
|
||||
$rel = static::thumbsRelPath() . "images/" . $filename;
|
||||
return Storage::disk("public")->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user