feat: add custom CSS save system with dual-editor interface
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s

Implement complete custom CSS management system:
- Separate base CSS (readonly) and custom CSS (editable)
- Save custom CSS to Kirby backend per narrative
- Visual save button with state indicators (dirty/saving/success/error)
- CSRF-protected API endpoint for CSS operations
- Dual-editor StylesheetViewer (base + custom with edit mode toggle)
- Auto-format custom CSS with Prettier on edit mode exit

Backend changes:
- Add web2print Kirby plugin with POST/GET routes
- Add customCss field to narrative blueprint
- Add CSRF token meta tag in header
- Include customCss and modified timestamps in JSON template
- Install code-editor plugin for Kirby panel

Frontend changes:
- Refactor stylesheet store with baseCss/customCss refs
- Make content a computed property (baseCss + customCss)
- Add helper methods: replaceBlock, replaceInCustomCss, setCustomCss
- Update all components to use new store API
- Create SaveButton component with FAB design
- Redesign StylesheetViewer with collapsable sections
- Initialize store from narrative data on app mount

File changes:
- Rename stylesheet.css → stylesheet.print.css
- Update all references to new filename

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-09 13:39:25 +01:00
parent 4d1183d1af
commit 0f46618066
32 changed files with 1207 additions and 89 deletions

View file

@ -22,7 +22,8 @@
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
"getkirby/cms": "^5.0"
"getkirby/cms": "^5.0",
"sylvainjule/code-editor": "^1.1"
},
"config": {
"allow-plugins": {

40
public/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0b7fb803e22a45eb87e24172337208aa",
"content-hash": "82adb49b472cb54cd88e72b31f49ada3",
"packages": [
{
"name": "christian-riesen/base32",
@ -725,6 +725,44 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "sylvainjule/code-editor",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/sylvainjule/kirby-code-editor.git",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sylvainjule/kirby-code-editor/zipball/adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"shasum": ""
},
"require": {
"getkirby/composer-installer": "^1.2"
},
"type": "kirby-plugin",
"extra": {
"installer-name": "code-editor"
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"description": "Code editor field for Kirby 3, 4 and 5",
"support": {
"issues": "https://github.com/sylvainjule/kirby-code-editor/issues",
"source": "https://github.com/sylvainjule/kirby-code-editor/tree/1.1.0"
},
"time": "2025-08-04T17:32:08+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View file

@ -14,4 +14,14 @@ Introduction: <p>Ah le Japon... Quel beau pays où nous trouvons des créatures
----
Customcss:
@page {
size: A4;
margin: 50mm 15mm 26mm 15mm;
background: rgba(255, 255, 255, 1);
}
----
Uuid: xi60pjkz5bp1nlwp

View file

@ -22,6 +22,13 @@ columns:
introduction:
label: Introduction
type: writer
customCss:
label: Custom CSS
type: code-editor
language: css
help: Custom CSS styling for this narrative's print view
theme: monokai
size: large
pages:
label: Pages
type: pages

View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.php]
indent_size = 4

View file

@ -0,0 +1,6 @@
.DS_Store
.cache
node_modules
package-lock.json
yarn.lock
composer.lock

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Sylvain Julé
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,111 @@
# Kirby Code editor
Code editor field for Kirby 3, 4 and 5.
![screenshot-code-editor](https://user-images.githubusercontent.com/14079751/109679014-7b043800-7b7b-11eb-8c4e-2ae25da8288d.png)
<br/>
## Overview
> This plugin is completely free and published under the MIT license. However, if you are using it in a commercial project and want to help me keep up with maintenance, you can consider [making a donation of your choice](https://paypal.me/sylvainjl).
- [1. Installation](#1-installation)
- [2. Setup](#2-setup)
- [3. Options](#3-options)
- [4. Available languages](#4-available-languages)
- [5. License](#5-license)
- [6. Credits](#6-credits)
<br/>
## 1. Installation
Download and copy this repository to ```/site/plugins/code-editor```
Alternatively, you can install it with composer: ```composer require sylvainjule/code-editor```
<br/>
## 2. Setup
This field adds a code editor in the panel:
```yaml
editor:
label: My code editor
type: code-editor
```
<br/>
## 3. Options
| Name | Type | Default | Options | Description |
| -------------------- | ------------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| language | `String` | `'css'` | - | Syntax mode of the editor. See below for available languages |
| size | `String` | `'small'` | - | Min height of the editor. `small / medium / large / huge` |
| lineNumbers | `Boolean` | `true` | - | Whether to show line numbers. |
| tabSize | `number` | `4` | - | The number of characters to insert when pressing tab key. |
| insertSpaces | `boolean` | `true` | - | Whether to use spaces for indentation. If you set it to `false`, you might also want to set `tabSize` to `1` |
| ignoreTabKey | `boolean` | `false` | - | Whether the editor should ignore tab key presses so that keyboard users can tab past the editor. Users can toggle this behaviour using `Ctrl+Shift+M` (Mac) / `Ctrl+M` manually when this is `false`. |
Note that you can make the default height any height you want with some [custom panel CSS](https://getkirby.com/docs/reference/system/options/panel#custom-panel-css). First, set the `size` option to any string you'd like:
```yaml
size: custom-size
```
Then in your `panel.css`:
```css
.k-code-editor-input[data-size="custom-size"] {
min-height: 15rem;
}
```
### 3.1. Default options
You can globally override the default options, instead of setting them on a per-field basis. In your `site/config/config.php`:
```php
return [
'sylvainjule.code-editor.language' => 'css',
'sylvainjule.code-editor.size' => 'small',
'sylvainjule.code-editor.lineNumbers' => true,
'sylvainjule.code-editor.tabSize' => 4,
'sylvainjule.code-editor.insertSpaces' => true,
'sylvainjule.code-editor.ignoreTabKey' => false,
];
```
<br/>
## 4. Available languages
Currently supported languages are:
* `css`
* `javascript`
* `json`
* `less`
* `php`
* `python`
* `ruby`
* `scss`
* `yaml`
<br/>
## 5. License
MIT
<br/>
## 6. Credits
**Code editor:**
- [Vue Prism Editor](https://github.com/koca/vue-prism-editor)

View file

@ -0,0 +1,20 @@
{
"name": "sylvainjule/code-editor",
"description": "Code editor field for Kirby 3, 4 and 5",
"type": "kirby-plugin",
"license": "MIT",
"version": "1.1.0",
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"require": {
"getkirby/composer-installer": "^1.2"
},
"extra": {
"installer-name": "code-editor"
},
"minimum-stability": "beta"
}

View file

@ -0,0 +1,28 @@
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import vue from "eslint-plugin-vue";
export default [
js.configs.recommended,
...vue.configs["flat/vue2-recommended"],
prettier,
{
rules: {
"vue/attributes-order": "error",
"vue/component-definition-name-casing": "off",
"vue/html-closing-bracket-newline": [
"error",
{
singleline: "never",
multiline: "always"
}
],
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
"vue/require-prop-types": "error"
},
languageOptions: {
ecmaVersion: 2022
}
}
];

View file

@ -0,0 +1 @@
.prism-editor-wrapper{width:100%;height:100%;display:flex;align-items:flex-start;overflow:auto;-o-tab-size:1.5em;tab-size:1.5em;-moz-tab-size:1.5em}@media (-ms-high-contrast:active),(-ms-high-contrast:none){.prism-editor-wrapper .prism-editor__textarea{color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::-moz-selection{background-color:#accef7!important;color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::selection{background-color:#accef7!important;color:transparent!important}}.prism-editor-wrapper .prism-editor__container{position:relative;text-align:left;box-sizing:border-box;padding:0;overflow:hidden;width:100%}.prism-editor-wrapper .prism-editor__line-numbers{height:100%;overflow:hidden;flex-shrink:0;padding-top:4px;margin-top:0;margin-right:10px}.prism-editor-wrapper .prism-editor__line-number{text-align:right;white-space:nowrap}.prism-editor-wrapper .prism-editor__textarea{position:absolute;top:0;left:0;height:100%;width:100%;resize:none;color:inherit;overflow:hidden;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;-webkit-text-fill-color:transparent}.prism-editor-wrapper .prism-editor__editor,.prism-editor-wrapper .prism-editor__textarea{margin:0;border:0;background:none;box-sizing:inherit;display:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-variant-ligatures:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;-moz-tab-size:inherit;-o-tab-size:inherit;tab-size:inherit;text-indent:inherit;text-rendering:inherit;text-transform:inherit;white-space:pre-wrap;word-wrap:keep-all;overflow-wrap:break-word;padding:0}.prism-editor-wrapper .prism-editor__textarea--empty{-webkit-text-fill-color:inherit!important}.prism-editor-wrapper .prism-editor__editor{position:relative;pointer-events:none}code[class*=language-],pre[class*=language-]{color:#ccc;background:none;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.comment,.token.block-comment,.token.prolog,.token.doctype,.token.cdata{color:#999}.token.punctuation{color:#ccc}.token.tag,.token.attr-name,.token.namespace,.token.deleted{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.number,.token.function{color:#f08d49}.token.property,.token.class-name,.token.constant,.token.symbol{color:#f8c555}.token.selector,.token.important,.token.atrule,.token.keyword,.token.builtin{color:#cc99cd}.token.string,.token.char,.token.attr-value,.token.regex,.token.variable{color:#7ec699}.token.operator,.token.entity,.token.url{color:#67cdcc}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}.k-code-editor-input{background:light-dark(var(--color-gray-950),var(--input-color-back));color:var(--color-gray-200);font-family:var(--font-mono);font-size:var(--text-sm);line-height:1.5;padding:var(--spacing-2);border-radius:var(--rounded)}.k-code-editor-input[data-size=small]{min-height:7.5rem}.k-code-editor-input[data-size=medium]{min-height:15rem}.k-code-editor-input[data-size=large]{min-height:30rem}.k-code-editor-input[data-size=huge]{min-height:45rem}.prism-editor__textarea:focus{outline:none}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,15 @@
<?php
Kirby::plugin('sylvainjule/code-editor', [
'options' => array(
'language' => 'css',
'size' => 'small',
'lineNumbers' => true,
'tabSize' => 4,
'insertSpaces' => true,
'ignoreTabKey' => false,
),
'fields' => array(
'code-editor' => require_once __DIR__ . '/lib/fields/code-editor.php',
),
]);

View file

@ -0,0 +1,32 @@
<?php
$options = require kirby()->root('kirby') . '/config/fields/textarea.php';
/* Merge new properties
--------------------------------*/
$options = A::merge($options, [
'props' => [
'size' => function($size = null) {
return $size ?? option('sylvainjule.code-editor.size');
},
'language' => function($language = null) {
return $language ?? option('sylvainjule.code-editor.language');
},
'lineNumbers' => function($lineNumbers = null) {
return $lineNumbers ?? option('sylvainjule.code-editor.lineNumbers');
},
'tabSize' => function($tabSize = null) {
return $tabSize ?? option('sylvainjule.code-editor.tabSize');
},
'insertSpaces' => function($insertSpaces = null) {
return $tabSize ?? option('sylvainjule.code-editor.insertSpaces');
},
'ignoreTabKey' => function($ignoreTabKey = null) {
return $tabSize ?? option('sylvainjule.code-editor.ignoreTabKey');
},
]
]);
// return the updated options
return $options;

View file

@ -0,0 +1,30 @@
{
"name": "kirby-code-editor",
"version": "1.0.3",
"description": "Code editor field for Kirby 3 and 4",
"main": "index.js",
"author": "Kirby Community",
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:sylvainjule/kirby-code-editor.git"
},
"scripts": {
"dev": "kirbyup src/index.js --watch",
"build": "kirbyup src/index.js",
"lint": "eslint \"src/**/*.{js,vue}\"",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{css,js,vue}\"",
"prepare": "node src/node/patchVuePrismEditor.mjs"
},
"devDependencies": {
"consola": "^3.4.2",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^9.33.0",
"kirbyup": "^3.3.0",
"prettier": "^3.5.3",
"prismjs": "^1.30.0",
"vue-prism-editor": "^1.3.0"
}
}

View file

@ -0,0 +1,97 @@
<template>
<k-field :input="uid" v-bind="$props" class="k-code-editor-field">
<prism-editor
v-model="code"
class="k-code-editor-input"
:highlight="highlighter"
:line-numbers="lineNumbers"
:tab-size="tabSize"
:insert-spaces="insertSpaces"
:ignore-tab-key="ignoreTabKey"
:data-size="size"
@input="onCodeInput"
/>
</k-field>
</template>
<script>
import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-markup-templating";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-css";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-json";
import "prismjs/components/prism-less";
import "prismjs/components/prism-php";
import "prismjs/components/prism-python";
import "prismjs/components/prism-ruby";
import "prismjs/components/prism-scss";
import "prismjs/components/prism-yaml";
import "prismjs/themes/prism-tomorrow.css";
export default {
components: { PrismEditor },
extends: "k-textarea-field",
props: {
size: String,
language: String,
lineNumbers: Boolean,
tabSize: Number,
insertSpaces: Boolean,
ignoreTabKey: Boolean,
},
data() {
return {
code: "",
};
},
mounted() {
this.code = this.value;
},
methods: {
highlighter() {
return highlight(this.code, languages[this.language]);
},
onCodeInput() {
this.$emit("input", this.code);
},
},
};
</script>
<style>
.k-code-editor-input {
background: light-dark(var(--color-gray-950), var(--input-color-back));
color: var(--color-gray-200);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.5;
padding: var(--spacing-2);
border-radius: var(--rounded);
}
.k-code-editor-input[data-size="small"] {
min-height: 7.5rem;
}
.k-code-editor-input[data-size="medium"] {
min-height: 15rem;
}
.k-code-editor-input[data-size="large"] {
min-height: 30rem;
}
.k-code-editor-input[data-size="huge"] {
min-height: 45rem;
}
.prism-editor__textarea:focus {
outline: none;
}
</style>

View file

@ -0,0 +1,7 @@
import CodeEditor from "./components/field/CodeEditor.vue";
window.panel.plugin("sylvainjule/code-editor", {
fields: {
"code-editor": CodeEditor,
},
});

View file

@ -0,0 +1,38 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import chalk from "chalk";
import consola from "consola";
const srcPath = "node_modules/vue-prism-editor/dist/prismeditor.esm.js";
async function main() {
consola.start("Vue Prism Editor patcher");
if (!existsSync(srcPath)) {
consola.error(
`couldn't find ${chalk.cyan(srcPath)}, did you run ${chalk.green(
"npm install"
)}?`
);
return;
}
const source = readFileSync(srcPath, "utf8");
if (!source.includes("Vue.extend")) {
consola.success("already patched");
return;
}
consola.info("patching the source component...");
let output = source
.replace(/^import Vue from 'vue';/, "")
.replace("/*#__PURE__*/Vue.extend(", "")
.replace(/\}\)(;\s+export)/, "}$1");
writeFileSync(srcPath, output, "utf8");
consola.success("successfully patched");
}
main().catch((err) => consola.error(err));

View file

@ -0,0 +1,116 @@
<?php
/**
* Web2Print Plugin
*
* Routes for web-to-print functionality including custom CSS management
*/
use Kirby\Cms\Response;
Kirby::plugin('geoproject/web2print', [
'routes' => [
// POST: Save custom CSS
[
'pattern' => 'narratives/(:all)/css',
'method' => 'POST',
'action' => function ($pagePath) {
// Check authentication
if (!kirby()->user()) {
return Response::json([
'status' => 'error',
'message' => 'Authentication required'
], 401);
}
// Verify CSRF token from header
$csrfToken = kirby()->request()->header('X-CSRF');
if (!csrf($csrfToken)) {
return Response::json([
'status' => 'error',
'message' => 'Invalid CSRF token'
], 403);
}
// Get page
$page = page($pagePath);
if (!$page || $page->intendedTemplate()->name() !== 'narrative') {
return Response::json([
'status' => 'error',
'message' => 'Narrative not found'
], 404);
}
// Get POST data
$data = kirby()->request()->data();
$customCss = $data['customCss'] ?? null;
if ($customCss === null) {
return Response::json([
'status' => 'error',
'message' => 'No CSS content provided'
], 400);
}
try {
// Update page with new custom CSS
$page->update([
'customCss' => $customCss
]);
// Reload page to get updated modification time
$page = page($pagePath);
// Return success with updated modified timestamp
return Response::json([
'status' => 'success',
'data' => [
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i')
]
]);
} catch (Exception $e) {
return Response::json([
'status' => 'error',
'message' => 'Failed to save CSS: ' . $e->getMessage()
], 500);
}
}
],
// GET: Load custom CSS and last modified time
[
'pattern' => 'narratives/(:all)/css',
'method' => 'GET',
'action' => function ($pagePath) {
// Check authentication
if (!kirby()->user()) {
return Response::json([
'status' => 'error',
'message' => 'Authentication required'
], 401);
}
// Get page
$page = page($pagePath);
if (!$page || $page->intendedTemplate()->name() !== 'narrative') {
return Response::json([
'status' => 'error',
'message' => 'Narrative not found'
], 404);
}
// Return custom CSS content and modified timestamp
return Response::json([
'status' => 'success',
'data' => [
'customCss' => $page->customCss()->value() ?? '',
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i')
]
]);
}
]
]
]);

View file

@ -14,6 +14,11 @@
<!-- À SUPPRIMER EN PRODUCTION -->
<meta name="robots" content="noindex, nofollow, noarchive">
<!-- CSRF Token for API calls -->
<?php if ($kirby->user()): ?>
<meta name="csrf" content="<?= csrf() ?>">
<?php endif ?>
<!-- APP -->
<?php if (Dir::exists('assets/dist')): ?>
<script type="module"

View file

@ -185,6 +185,9 @@ $data = [
'author' => $page->author()->value() ?? '',
'cover' => resolveFileUrl($page->cover(), $page),
'introduction' => resolveImagesInHtml($page->introduction()->value(), $page),
'customCss' => $page->customCss()->value() ?? '',
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i'),
'children' => []
];

View file

@ -752,6 +752,47 @@
},
"install-path": "../psr/log"
},
{
"name": "sylvainjule/code-editor",
"version": "1.1.0",
"version_normalized": "1.1.0.0",
"source": {
"type": "git",
"url": "https://github.com/sylvainjule/kirby-code-editor.git",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sylvainjule/kirby-code-editor/zipball/adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"shasum": ""
},
"require": {
"getkirby/composer-installer": "^1.2"
},
"time": "2025-08-04T17:32:08+00:00",
"type": "kirby-plugin",
"extra": {
"installer-name": "code-editor"
},
"installation-source": "dist",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"description": "Code editor field for Kirby 3, 4 and 5",
"support": {
"issues": "https://github.com/sylvainjule/kirby-code-editor/issues",
"source": "https://github.com/sylvainjule/kirby-code-editor/tree/1.1.0"
},
"install-path": "../../site/plugins/code-editor"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View file

@ -3,7 +3,7 @@
'name' => 'getkirby/plainkit',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -67,7 +67,7 @@
'getkirby/plainkit' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -124,6 +124,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'sylvainjule/code-editor' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'reference' => 'adbc2c8a728994cc57ea72a7f8628f27d202b8df',
'type' => 'kirby-plugin',
'install_path' => __DIR__ . '/../../site/plugins/code-editor',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0',

View file

@ -4,6 +4,7 @@ import EditorPanel from './components/editor/EditorPanel.vue';
import ElementPopup from './components/ElementPopup.vue';
import PagePopup from './components/PagePopup.vue';
import PreviewLoader from './components/PreviewLoader.vue';
import SaveButton from './components/SaveButton.vue';
import { onMounted, ref, watch, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import { useNarrativeStore } from './stores/narrative';
@ -554,9 +555,14 @@ const printPreview = async () => {
};
onMounted(async () => {
// Load narrative data if URL is provided (print mode)
// Load narrative data (narrativeUrl constructed from location, always present)
await narrativeStore.loadNarrative(location.href + '.json');
// Initialize stylesheet with custom CSS
if (narrativeStore.data) {
await stylesheetStore.initializeFromNarrative(narrativeStore.data);
}
// Render preview after data is loaded
renderPreview(true);
});
@ -582,6 +588,8 @@ onMounted(async () => {
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
<SaveButton />
<ElementPopup
ref="elementPopup"
:iframeRef="activeFrame"

View file

@ -435,7 +435,7 @@ const removeElementBlock = () => {
// Escape special regex characters in selector
const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Remove the block and any surrounding whitespace
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
'\n'
);
@ -581,7 +581,7 @@ const handleCssInput = (event) => {
cssDebounceTimer = setTimeout(() => {
const oldBlock = elementCss.value;
if (oldBlock) {
stylesheetStore.content = stylesheetStore.content.replace(oldBlock, newCss);
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
}
}, 500);
};
@ -592,7 +592,7 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatContent();
await stylesheetStore.formatCustomCss();
}
});

View file

@ -356,7 +356,7 @@ const getOrCreateTemplateBlock = () => {
const marginValue = `${margins.value.top.value}${margins.value.top.unit} ${margins.value.right.value}${margins.value.right.unit} ${margins.value.bottom.value}${margins.value.bottom.unit} ${margins.value.left.value}${margins.value.left.unit}`;
const newBlock = `\n@page ${templateName.value} {\n margin: ${marginValue};${background.value.value ? `\n background: ${background.value.value};` : ''}\n}\n`;
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
baseBlock,
baseBlock + newBlock
);
@ -376,7 +376,7 @@ const removeTemplateBlock = () => {
if (block) {
// Remove the block and any surrounding whitespace
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
'\n'
);
@ -399,7 +399,7 @@ const updateMargins = (force = false) => {
/(margin:\s*)[^;]+/,
`$1${marginValue}`
);
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
currentBlock,
updatedBlock
);
@ -408,7 +408,7 @@ const updateMargins = (force = false) => {
/(\s*})$/,
` margin: ${marginValue};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
currentBlock,
updatedBlock
);
@ -428,7 +428,7 @@ const updateBackground = (force = false) => {
/(background:\s*)[^;]+/,
`$1${background.value.value}`
);
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
currentBlock,
updatedBlock
);
@ -437,7 +437,7 @@ const updateBackground = (force = false) => {
/(\s*})$/,
` background: ${background.value.value};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
currentBlock,
updatedBlock
);
@ -674,7 +674,7 @@ const handleCssInput = (event) => {
// Get the actual CSS block (not the commented preview)
const oldBlock = pageCss.value;
if (oldBlock) {
stylesheetStore.content = stylesheetStore.content.replace(
stylesheetStore.replaceInCustomCss(
oldBlock,
newCss
);
@ -688,7 +688,7 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatContent();
await stylesheetStore.formatCustomCss();
}
});

View file

@ -0,0 +1,174 @@
<template>
<div class="save-button-wrapper">
<button
class="save-btn"
:class="{
'has-changes': isDirty,
'is-saving': isSaving,
'has-error': hasError,
'save-success': showSuccess
}"
:disabled="!isDirty || isSaving"
@click="handleSave"
:title="getTooltip()"
>
<!-- Save icon (default state) -->
<svg v-if="!isSaving && !showSuccess" class="save-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3ZM19 19H5V5H16.17L19 7.83V19ZM12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12ZM6 6H15V10H6V6Z"/>
</svg>
<!-- Spinner (saving state) -->
<div v-if="isSaving" class="spinner"></div>
<!-- Success checkmark (brief animation) -->
<svg v-if="showSuccess" class="success-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z"/>
</svg>
</button>
<!-- Last saved timestamp -->
<div v-if="lastSavedFormatted" class="last-saved">
Saved: {{ lastSavedFormatted }}
</div>
<!-- Error message tooltip -->
<div v-if="hasError && saveError" class="error-tooltip">
{{ saveError }}
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
const stylesheetStore = useStylesheetStore();
const isDirty = computed(() => stylesheetStore.isDirty);
const isSaving = computed(() => stylesheetStore.isSaving);
const saveError = computed(() => stylesheetStore.saveError);
const lastSavedFormatted = computed(() => stylesheetStore.lastSavedFormatted);
const hasError = computed(() => !!saveError.value);
const showSuccess = ref(false);
const handleSave = async () => {
const result = await stylesheetStore.saveCustomCss();
if (result.status === 'success') {
// Show success animation
showSuccess.value = true;
setTimeout(() => {
showSuccess.value = false;
}, 1500);
}
// Errors are handled in the store and reflected in hasError
};
const getTooltip = () => {
if (!isDirty.value) return 'No changes to save';
if (isSaving.value) return 'Saving...';
if (hasError.value) return saveError.value;
return 'Save custom CSS';
};
</script>
<style scoped>
.save-button-wrapper {
position: fixed;
top: 2rem;
right: 5rem;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.save-btn {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: none;
background: var(--color-interface-300, #ccc);
color: white;
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
position: relative;
}
.save-btn.has-changes {
background: var(--color-page-highlight, #ff8a50);
cursor: pointer;
}
.save-btn.has-changes:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.save-btn.is-saving {
cursor: wait;
}
.save-btn.has-error {
background: #e74c3c;
}
.save-btn.save-success {
background: #2ecc71;
}
.save-icon,
.success-icon {
width: 1.5rem;
height: 1.5rem;
}
.spinner {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border-top: 2px solid white;
border-right: 2px solid transparent;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.last-saved {
font-size: 0.75rem;
color: var(--color-interface-600, #666);
background: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
white-space: nowrap;
}
.error-tooltip {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: #e74c3c;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
max-width: 15rem;
word-wrap: break-word;
white-space: normal;
}
</style>

View file

@ -1,30 +1,57 @@
<template>
<div id="stylesheet-viewer">
<div class="header">
<h3>Stylesheet</h3>
<label class="toggle">
<span class="toggle-label">Mode édition</span>
<input type="checkbox" v-model="isEditable" />
<span class="toggle-switch"></span>
</label>
<!-- Base CSS Section (Collapsable, closed by default) -->
<div class="css-section">
<div class="section-header" @click="isBaseCssExpanded = !isBaseCssExpanded">
<h3>Base CSS</h3>
<svg
class="expand-icon"
:class="{ expanded: isBaseCssExpanded }"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
</div>
<div v-show="isBaseCssExpanded" class="section-content">
<pre class="readonly"><code class="hljs language-css" v-html="highlightedBaseCss"></code></pre>
</div>
</div>
<pre
v-if="!isEditable"
class="readonly"
><code class="hljs language-css" v-html="highlightedCss"></code></pre>
<!-- Custom CSS Section (Editable with toggle) -->
<div class="css-section custom-section">
<div class="section-header">
<h3>CSS personnalisé</h3>
<label class="toggle">
<span class="toggle-label">Mode édition</span>
<input type="checkbox" v-model="isCustomCssEditable" />
<span class="toggle-switch"></span>
</label>
</div>
<textarea
v-else
:value="stylesheetStore.content"
@input="handleInput"
spellcheck="false"
></textarea>
<div class="section-content">
<pre
v-if="!isCustomCssEditable"
class="readonly"
><code class="hljs language-css" v-html="highlightedCustomCss"></code></pre>
<textarea
v-else
:value="stylesheetStore.customCss"
@input="handleCustomCssInput"
@focus="handleFocus"
spellcheck="false"
placeholder="Ajoutez votre CSS personnalisé ici..."
></textarea>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, inject } from 'vue';
import { ref, computed, watch } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
@ -33,16 +60,21 @@ import 'highlight.js/styles/atom-one-dark.css';
hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore();
const activeTab = inject('activeTab');
const isEditable = ref(false);
const isBaseCssExpanded = ref(false);
const isCustomCssEditable = ref(false);
let debounceTimer = null;
const highlightedCss = computed(() => {
if (!stylesheetStore.content) return '';
return hljs.highlight(stylesheetStore.content, { language: 'css' }).value;
const highlightedBaseCss = computed(() => {
if (!stylesheetStore.baseCss) return '';
return hljs.highlight(stylesheetStore.baseCss, { language: 'css' }).value;
});
const handleInput = (event) => {
const highlightedCustomCss = computed(() => {
if (!stylesheetStore.customCss) return '';
return hljs.highlight(stylesheetStore.customCss, { language: 'css' }).value;
});
const handleCustomCssInput = (event) => {
const newContent = event.target.value;
if (debounceTimer) {
@ -50,24 +82,21 @@ const handleInput = (event) => {
}
debounceTimer = setTimeout(() => {
stylesheetStore.content = newContent;
stylesheetStore.customCss = newContent;
}, 500);
};
// Sync editing mode with store
watch(isEditable, async (newValue, oldValue) => {
const handleFocus = () => {
stylesheetStore.isEditing = true;
};
// Watch editing mode and format when exiting
watch(isCustomCssEditable, async (newValue, oldValue) => {
stylesheetStore.isEditing = newValue;
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatContent();
}
});
// Disable editing mode when changing tabs
watch(activeTab, (newTab) => {
if (newTab !== 'code' && isEditable.value) {
isEditable.value = false;
await stylesheetStore.formatCustomCss();
}
});
</script>
@ -79,18 +108,44 @@ watch(activeTab, (newTab) => {
height: 100%;
background: #282c34;
color: #fff;
gap: 1rem;
overflow-y: auto;
}
.header {
.css-section {
display: flex;
flex-direction: column;
background: #21252b;
border-radius: 0.25rem;
overflow: hidden;
}
.custom-section {
flex: 1;
min-height: 300px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: #2c313c;
cursor: pointer;
user-select: none;
}
.css-section.custom-section .section-header {
cursor: default;
}
h3 {
margin: 0;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toggle {
@ -144,11 +199,28 @@ h3 {
transform: translateX(20px);
}
.expand-icon {
width: 1.25rem;
height: 1.25rem;
color: #abb2bf;
transition: transform 0.2s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.section-content {
display: flex;
flex-direction: column;
flex: 1;
}
.readonly {
margin: 0;
flex: 1;
overflow-y: auto;
padding: 0.5rem;
padding: 1rem;
background: #1e1e1e;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
@ -162,14 +234,20 @@ h3 {
textarea {
width: 100%;
flex: 1;
min-height: 300px;
background: #1e1e1e;
color: #abb2bf;
border: none;
padding: 0.5rem;
padding: 1rem;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: none;
outline: none;
}
textarea::placeholder {
color: #5c6370;
font-style: italic;
}
</style>

View file

@ -354,10 +354,7 @@ const updateMargins = () => {
`$1${marginValue}`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
};
// Watch margin values (number inputs) with debounce
@ -398,19 +395,13 @@ const updateBackground = () => {
/(background:\s*)[^;]+/,
`$1${background.value.value}`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` background: ${background.value.value};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
}
};
@ -457,7 +448,7 @@ watch(runningTitle, (enabled) => {
});
const updatePageFooters = () => {
let currentCss = stylesheetStore.content;
let currentCss = stylesheetStore.customCss;
// Remove existing @page:left and @page:right rules
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
@ -539,7 +530,7 @@ const updatePageFooters = () => {
currentCss.slice(insertPosition);
}
stylesheetStore.content = currentCss;
stylesheetStore.setCustomCss(currentCss);
};
const syncFromStore = () => {

View file

@ -1,29 +1,48 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import cssParsingUtils from '../utils/css-parsing';
import prettier from 'prettier/standalone';
import parserPostcss from 'prettier/plugins/postcss';
import { getCsrfToken } from '../utils/kirby-auth';
export const useStylesheetStore = defineStore('stylesheet', () => {
const content = ref('');
// Base state
const baseCss = ref('');
const customCss = ref('');
const isEditing = ref(false);
let formatTimer = null;
let isFormatting = false;
let isInitializing = false;
// Format CSS with Prettier
const formatContent = async () => {
if (isFormatting || !content.value) return;
// Save/load state
const isDirty = ref(false);
const isSaving = ref(false);
const lastSaved = ref(null);
const lastSavedFormatted = ref('');
const saveError = ref(null);
const narrativeId = ref(null);
// Computed: combined CSS for preview
const content = computed(() => {
if (!baseCss.value) return customCss.value;
if (!customCss.value) return baseCss.value;
return baseCss.value + '\n\n/* Custom CSS */\n' + customCss.value;
});
// Format custom CSS with Prettier
const formatCustomCss = async () => {
if (isFormatting || !customCss.value) return;
try {
isFormatting = true;
const formatted = await prettier.format(content.value, {
const formatted = await prettier.format(customCss.value, {
parser: 'css',
plugins: [parserPostcss],
printWidth: 80,
tabWidth: 2,
useTabs: false,
});
content.value = formatted;
customCss.value = formatted;
} catch (error) {
console.error('CSS formatting error:', error);
} finally {
@ -31,46 +50,195 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
}
};
// Watch content and format after 500ms of inactivity (only when not editing)
watch(content, () => {
if (isFormatting || isEditing.value) return;
// Watch customCss and format after 500ms of inactivity (only when not editing)
watch(customCss, () => {
if (isFormatting || isEditing.value || isInitializing) return;
// Mark as dirty when customCss changes (unless we're saving)
if (!isSaving.value) {
isDirty.value = true;
}
clearTimeout(formatTimer);
formatTimer = setTimeout(() => {
formatContent();
formatCustomCss();
}, 500);
});
const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.css');
content.value = await response.text();
const response = await fetch('/assets/css/stylesheet.print.css');
baseCss.value = await response.text();
};
const updateProperty = (selector, property, value, unit) => {
content.value = cssParsingUtils.updateCssValue({
css: content.value,
// Update custom CSS, not the combined content
customCss.value = cssParsingUtils.updateCssValue({
css: customCss.value,
selector,
property,
value,
unit
unit,
});
};
const extractValue = (selector, property) => {
return cssParsingUtils.extractCssValue(content.value, selector, property);
// Try to extract from custom CSS first, then from base CSS
const customValue = cssParsingUtils.extractCssValue(customCss.value, selector, property);
if (customValue) return customValue;
return cssParsingUtils.extractCssValue(baseCss.value, selector, property);
};
const extractBlock = (selector) => {
return cssParsingUtils.extractCssBlock(content.value, selector);
// Try to extract from custom CSS first, then from base CSS
const customBlock = cssParsingUtils.extractCssBlock(customCss.value, selector);
if (customBlock) return customBlock;
return cssParsingUtils.extractCssBlock(baseCss.value, selector);
};
// Replace a CSS block in custom CSS (handles blocks from base CSS too)
const replaceBlock = (oldBlock, newBlock) => {
// Check if the old block exists in custom CSS
if (customCss.value.includes(oldBlock)) {
// Replace in custom CSS
customCss.value = customCss.value.replace(oldBlock, newBlock);
} else {
// Block is from base CSS, append new block to custom CSS (will override via cascade)
customCss.value = customCss.value.trim() + '\n\n' + newBlock;
}
};
// Replace content in custom CSS (for more complex string replacements)
const replaceInCustomCss = (searchValue, replaceValue) => {
customCss.value = customCss.value.replace(searchValue, replaceValue);
};
// Set custom CSS directly (for complex transformations)
const setCustomCss = (newCss) => {
customCss.value = newCss;
};
// Load base CSS from stylesheet.print.css
const loadBaseCss = async () => {
const response = await fetch('/assets/css/stylesheet.print.css');
baseCss.value = await response.text();
return baseCss.value;
};
// Initialize from narrative data (base + custom CSS)
const initializeFromNarrative = async (narrativeData) => {
// Set initializing flag to prevent marking as dirty during init
isInitializing = true;
try {
// Set narrative ID for API calls
narrativeId.value = narrativeData.id;
// Load base CSS
await loadBaseCss();
// Get custom CSS if exists
customCss.value = narrativeData.customCss || '';
// Set last saved info
if (narrativeData.modified) {
lastSaved.value = narrativeData.modified;
lastSavedFormatted.value = narrativeData.modifiedFormatted || '';
}
// Mark as not dirty initially
isDirty.value = false;
} finally {
// Always clear initializing flag
isInitializing = false;
}
};
// Save custom CSS to Kirby
const saveCustomCss = async () => {
if (!narrativeId.value) {
saveError.value = 'No narrative ID available';
return { status: 'error', message: saveError.value };
}
isSaving.value = true;
saveError.value = null;
try {
// Get CSRF token
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error(
'No CSRF token available. Please log in to Kirby Panel.'
);
}
// Make POST request to save CSS (save customCss directly)
const response = await fetch(`/narratives/${narrativeId.value}/css`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF': csrfToken,
},
body: JSON.stringify({
customCss: customCss.value,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(
result.message || `HTTP error! status: ${response.status}`
);
}
if (result.status === 'success') {
// Update last saved info
lastSaved.value = result.data.modified;
lastSavedFormatted.value = result.data.modifiedFormatted;
// Mark as not dirty
isDirty.value = false;
return { status: 'success' };
} else {
throw new Error(result.message || 'Failed to save CSS');
}
} catch (error) {
console.error('Error saving CSS:', error);
saveError.value = error.message;
return { status: 'error', message: error.message };
} finally {
isSaving.value = false;
}
};
return {
content,
// Core state
content, // computed: baseCss + customCss
baseCss,
customCss,
isEditing,
// Methods
loadStylesheet,
updateProperty,
extractValue,
extractBlock,
formatContent
replaceBlock,
replaceInCustomCss,
setCustomCss,
formatCustomCss,
loadBaseCss,
initializeFromNarrative,
// Save/load
isDirty,
isSaving,
lastSaved,
lastSavedFormatted,
saveError,
narrativeId,
saveCustomCss,
};
});

37
src/utils/kirby-auth.js Normal file
View file

@ -0,0 +1,37 @@
/**
* Kirby Authentication Utilities
*
* Helper functions for authentication and CSRF token management
*/
/**
* Get CSRF token from meta tag
* @returns {string|null} CSRF token or null if not found
*/
export function getCsrfToken() {
// Check for meta tag (added by header.php when user is logged in)
const metaTag = document.querySelector('meta[name="csrf"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Alternatively, could be in a cookie
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'kirby_csrf') {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Check if user is authenticated (has Kirby session)
* @returns {boolean} True if authenticated, false otherwise
*/
export function isAuthenticated() {
// Check for kirby session cookie
return document.cookie.includes('kirby_session');
}