feat: add custom CSS save system with dual-editor interface
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
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:
parent
4d1183d1af
commit
0f46618066
32 changed files with 1207 additions and 89 deletions
|
|
@ -22,7 +22,8 @@
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
|
"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": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
|
|
||||||
40
public/composer.lock
generated
40
public/composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "0b7fb803e22a45eb87e24172337208aa",
|
"content-hash": "82adb49b472cb54cd88e72b31f49ada3",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "christian-riesen/base32",
|
"name": "christian-riesen/base32",
|
||||||
|
|
@ -725,6 +725,44 @@
|
||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"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",
|
"name": "symfony/deprecation-contracts",
|
||||||
"version": "v3.6.0",
|
"version": "v3.6.0",
|
||||||
|
|
|
||||||
|
|
@ -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
|
Uuid: xi60pjkz5bp1nlwp
|
||||||
|
|
@ -22,6 +22,13 @@ columns:
|
||||||
introduction:
|
introduction:
|
||||||
label: Introduction
|
label: Introduction
|
||||||
type: writer
|
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:
|
pages:
|
||||||
label: Pages
|
label: Pages
|
||||||
type: pages
|
type: pages
|
||||||
|
|
|
||||||
12
public/site/plugins/code-editor/.editorconfig
Normal file
12
public/site/plugins/code-editor/.editorconfig
Normal 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
|
||||||
6
public/site/plugins/code-editor/.gitignore
vendored
Normal file
6
public/site/plugins/code-editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.DS_Store
|
||||||
|
.cache
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
composer.lock
|
||||||
21
public/site/plugins/code-editor/LICENSE
Normal file
21
public/site/plugins/code-editor/LICENSE
Normal 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.
|
||||||
111
public/site/plugins/code-editor/README.md
Normal file
111
public/site/plugins/code-editor/README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Kirby – Code editor
|
||||||
|
|
||||||
|
Code editor field for Kirby 3, 4 and 5.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<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)
|
||||||
20
public/site/plugins/code-editor/composer.json
Normal file
20
public/site/plugins/code-editor/composer.json
Normal 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"
|
||||||
|
}
|
||||||
28
public/site/plugins/code-editor/eslint.config.mjs
Normal file
28
public/site/plugins/code-editor/eslint.config.mjs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
1
public/site/plugins/code-editor/index.css
Normal file
1
public/site/plugins/code-editor/index.css
Normal 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}
|
||||||
14
public/site/plugins/code-editor/index.js
Normal file
14
public/site/plugins/code-editor/index.js
Normal file
File diff suppressed because one or more lines are too long
15
public/site/plugins/code-editor/index.php
Normal file
15
public/site/plugins/code-editor/index.php
Normal 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',
|
||||||
|
),
|
||||||
|
]);
|
||||||
32
public/site/plugins/code-editor/lib/fields/code-editor.php
Normal file
32
public/site/plugins/code-editor/lib/fields/code-editor.php
Normal 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;
|
||||||
30
public/site/plugins/code-editor/package.json
Normal file
30
public/site/plugins/code-editor/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
7
public/site/plugins/code-editor/src/index.js
Normal file
7
public/site/plugins/code-editor/src/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import CodeEditor from "./components/field/CodeEditor.vue";
|
||||||
|
|
||||||
|
window.panel.plugin("sylvainjule/code-editor", {
|
||||||
|
fields: {
|
||||||
|
"code-editor": CodeEditor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
116
public/site/plugins/web2print/index.php
Normal file
116
public/site/plugins/web2print/index.php
Normal 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')
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
@ -14,6 +14,11 @@
|
||||||
<!-- À SUPPRIMER EN PRODUCTION -->
|
<!-- À SUPPRIMER EN PRODUCTION -->
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||||
|
|
||||||
|
<!-- CSRF Token for API calls -->
|
||||||
|
<?php if ($kirby->user()): ?>
|
||||||
|
<meta name="csrf" content="<?= csrf() ?>">
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- APP -->
|
<!-- APP -->
|
||||||
<?php if (Dir::exists('assets/dist')): ?>
|
<?php if (Dir::exists('assets/dist')): ?>
|
||||||
<script type="module"
|
<script type="module"
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,9 @@ $data = [
|
||||||
'author' => $page->author()->value() ?? '',
|
'author' => $page->author()->value() ?? '',
|
||||||
'cover' => resolveFileUrl($page->cover(), $page),
|
'cover' => resolveFileUrl($page->cover(), $page),
|
||||||
'introduction' => resolveImagesInHtml($page->introduction()->value(), $page),
|
'introduction' => resolveImagesInHtml($page->introduction()->value(), $page),
|
||||||
|
'customCss' => $page->customCss()->value() ?? '',
|
||||||
|
'modified' => $page->modified(),
|
||||||
|
'modifiedFormatted' => $page->modified('d/m/Y H:i'),
|
||||||
'children' => []
|
'children' => []
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
41
public/vendor/composer/installed.json
vendored
41
public/vendor/composer/installed.json
vendored
|
|
@ -752,6 +752,47 @@
|
||||||
},
|
},
|
||||||
"install-path": "../psr/log"
|
"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",
|
"name": "symfony/deprecation-contracts",
|
||||||
"version": "v3.6.0",
|
"version": "v3.6.0",
|
||||||
|
|
|
||||||
13
public/vendor/composer/installed.php
vendored
13
public/vendor/composer/installed.php
vendored
|
|
@ -3,7 +3,7 @@
|
||||||
'name' => 'getkirby/plainkit',
|
'name' => 'getkirby/plainkit',
|
||||||
'pretty_version' => 'dev-main',
|
'pretty_version' => 'dev-main',
|
||||||
'version' => 'dev-main',
|
'version' => 'dev-main',
|
||||||
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
|
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
|
||||||
'type' => 'project',
|
'type' => 'project',
|
||||||
'install_path' => __DIR__ . '/../../',
|
'install_path' => __DIR__ . '/../../',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
'getkirby/plainkit' => array(
|
'getkirby/plainkit' => array(
|
||||||
'pretty_version' => 'dev-main',
|
'pretty_version' => 'dev-main',
|
||||||
'version' => 'dev-main',
|
'version' => 'dev-main',
|
||||||
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
|
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
|
||||||
'type' => 'project',
|
'type' => 'project',
|
||||||
'install_path' => __DIR__ . '/../../',
|
'install_path' => __DIR__ . '/../../',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
|
|
@ -124,6 +124,15 @@
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
'dev_requirement' => false,
|
'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(
|
'symfony/deprecation-contracts' => array(
|
||||||
'pretty_version' => 'v3.6.0',
|
'pretty_version' => 'v3.6.0',
|
||||||
'version' => '3.6.0.0',
|
'version' => '3.6.0.0',
|
||||||
|
|
|
||||||
10
src/App.vue
10
src/App.vue
|
|
@ -4,6 +4,7 @@ import EditorPanel from './components/editor/EditorPanel.vue';
|
||||||
import ElementPopup from './components/ElementPopup.vue';
|
import ElementPopup from './components/ElementPopup.vue';
|
||||||
import PagePopup from './components/PagePopup.vue';
|
import PagePopup from './components/PagePopup.vue';
|
||||||
import PreviewLoader from './components/PreviewLoader.vue';
|
import PreviewLoader from './components/PreviewLoader.vue';
|
||||||
|
import SaveButton from './components/SaveButton.vue';
|
||||||
import { onMounted, ref, watch, computed, provide } from 'vue';
|
import { onMounted, ref, watch, computed, provide } from 'vue';
|
||||||
import { useStylesheetStore } from './stores/stylesheet';
|
import { useStylesheetStore } from './stores/stylesheet';
|
||||||
import { useNarrativeStore } from './stores/narrative';
|
import { useNarrativeStore } from './stores/narrative';
|
||||||
|
|
@ -554,9 +555,14 @@ const printPreview = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(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');
|
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
|
// Render preview after data is loaded
|
||||||
renderPreview(true);
|
renderPreview(true);
|
||||||
});
|
});
|
||||||
|
|
@ -582,6 +588,8 @@ onMounted(async () => {
|
||||||
|
|
||||||
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
|
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
|
||||||
|
|
||||||
|
<SaveButton />
|
||||||
|
|
||||||
<ElementPopup
|
<ElementPopup
|
||||||
ref="elementPopup"
|
ref="elementPopup"
|
||||||
:iframeRef="activeFrame"
|
:iframeRef="activeFrame"
|
||||||
|
|
|
||||||
|
|
@ -435,7 +435,7 @@ const removeElementBlock = () => {
|
||||||
// Escape special regex characters in selector
|
// Escape special regex characters in selector
|
||||||
const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
// Remove the block and any surrounding whitespace
|
// Remove the block and any surrounding whitespace
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
|
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
|
||||||
'\n'
|
'\n'
|
||||||
);
|
);
|
||||||
|
|
@ -581,7 +581,7 @@ const handleCssInput = (event) => {
|
||||||
cssDebounceTimer = setTimeout(() => {
|
cssDebounceTimer = setTimeout(() => {
|
||||||
const oldBlock = elementCss.value;
|
const oldBlock = elementCss.value;
|
||||||
if (oldBlock) {
|
if (oldBlock) {
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(oldBlock, newCss);
|
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
@ -592,7 +592,7 @@ watch(isEditable, async (newValue, oldValue) => {
|
||||||
|
|
||||||
// Format when exiting editing mode
|
// Format when exiting editing mode
|
||||||
if (oldValue && !newValue) {
|
if (oldValue && !newValue) {
|
||||||
await stylesheetStore.formatContent();
|
await stylesheetStore.formatCustomCss();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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`;
|
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,
|
||||||
baseBlock + newBlock
|
baseBlock + newBlock
|
||||||
);
|
);
|
||||||
|
|
@ -376,7 +376,7 @@ const removeTemplateBlock = () => {
|
||||||
|
|
||||||
if (block) {
|
if (block) {
|
||||||
// Remove the block and any surrounding whitespace
|
// Remove the block and any surrounding whitespace
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
|
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
|
||||||
'\n'
|
'\n'
|
||||||
);
|
);
|
||||||
|
|
@ -399,7 +399,7 @@ const updateMargins = (force = false) => {
|
||||||
/(margin:\s*)[^;]+/,
|
/(margin:\s*)[^;]+/,
|
||||||
`$1${marginValue}`
|
`$1${marginValue}`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
currentBlock,
|
currentBlock,
|
||||||
updatedBlock
|
updatedBlock
|
||||||
);
|
);
|
||||||
|
|
@ -408,7 +408,7 @@ const updateMargins = (force = false) => {
|
||||||
/(\s*})$/,
|
/(\s*})$/,
|
||||||
` margin: ${marginValue};\n$1`
|
` margin: ${marginValue};\n$1`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
currentBlock,
|
currentBlock,
|
||||||
updatedBlock
|
updatedBlock
|
||||||
);
|
);
|
||||||
|
|
@ -428,7 +428,7 @@ const updateBackground = (force = false) => {
|
||||||
/(background:\s*)[^;]+/,
|
/(background:\s*)[^;]+/,
|
||||||
`$1${background.value.value}`
|
`$1${background.value.value}`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
currentBlock,
|
currentBlock,
|
||||||
updatedBlock
|
updatedBlock
|
||||||
);
|
);
|
||||||
|
|
@ -437,7 +437,7 @@ const updateBackground = (force = false) => {
|
||||||
/(\s*})$/,
|
/(\s*})$/,
|
||||||
` background: ${background.value.value};\n$1`
|
` background: ${background.value.value};\n$1`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
currentBlock,
|
currentBlock,
|
||||||
updatedBlock
|
updatedBlock
|
||||||
);
|
);
|
||||||
|
|
@ -674,7 +674,7 @@ const handleCssInput = (event) => {
|
||||||
// Get the actual CSS block (not the commented preview)
|
// Get the actual CSS block (not the commented preview)
|
||||||
const oldBlock = pageCss.value;
|
const oldBlock = pageCss.value;
|
||||||
if (oldBlock) {
|
if (oldBlock) {
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceInCustomCss(
|
||||||
oldBlock,
|
oldBlock,
|
||||||
newCss
|
newCss
|
||||||
);
|
);
|
||||||
|
|
@ -688,7 +688,7 @@ watch(isEditable, async (newValue, oldValue) => {
|
||||||
|
|
||||||
// Format when exiting editing mode
|
// Format when exiting editing mode
|
||||||
if (oldValue && !newValue) {
|
if (oldValue && !newValue) {
|
||||||
await stylesheetStore.formatContent();
|
await stylesheetStore.formatCustomCss();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
174
src/components/SaveButton.vue
Normal file
174
src/components/SaveButton.vue
Normal 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>
|
||||||
|
|
@ -1,30 +1,57 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="stylesheet-viewer">
|
<div id="stylesheet-viewer">
|
||||||
<div class="header">
|
<!-- Base CSS Section (Collapsable, closed by default) -->
|
||||||
<h3>Stylesheet</h3>
|
<div class="css-section">
|
||||||
<label class="toggle">
|
<div class="section-header" @click="isBaseCssExpanded = !isBaseCssExpanded">
|
||||||
<span class="toggle-label">Mode édition</span>
|
<h3>Base CSS</h3>
|
||||||
<input type="checkbox" v-model="isEditable" />
|
<svg
|
||||||
<span class="toggle-switch"></span>
|
class="expand-icon"
|
||||||
</label>
|
: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>
|
</div>
|
||||||
|
|
||||||
<pre
|
<!-- Custom CSS Section (Editable with toggle) -->
|
||||||
v-if="!isEditable"
|
<div class="css-section custom-section">
|
||||||
class="readonly"
|
<div class="section-header">
|
||||||
><code class="hljs language-css" v-html="highlightedCss"></code></pre>
|
<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
|
<div class="section-content">
|
||||||
v-else
|
<pre
|
||||||
:value="stylesheetStore.content"
|
v-if="!isCustomCssEditable"
|
||||||
@input="handleInput"
|
class="readonly"
|
||||||
spellcheck="false"
|
><code class="hljs language-css" v-html="highlightedCustomCss"></code></pre>
|
||||||
></textarea>
|
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:value="stylesheetStore.customCss"
|
||||||
|
@input="handleCustomCssInput"
|
||||||
|
@focus="handleFocus"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Ajoutez votre CSS personnalisé ici..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, inject } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { useStylesheetStore } from '../stores/stylesheet';
|
import { useStylesheetStore } from '../stores/stylesheet';
|
||||||
import hljs from 'highlight.js/lib/core';
|
import hljs from 'highlight.js/lib/core';
|
||||||
import css from 'highlight.js/lib/languages/css';
|
import css from 'highlight.js/lib/languages/css';
|
||||||
|
|
@ -33,16 +60,21 @@ import 'highlight.js/styles/atom-one-dark.css';
|
||||||
hljs.registerLanguage('css', css);
|
hljs.registerLanguage('css', css);
|
||||||
|
|
||||||
const stylesheetStore = useStylesheetStore();
|
const stylesheetStore = useStylesheetStore();
|
||||||
const activeTab = inject('activeTab');
|
const isBaseCssExpanded = ref(false);
|
||||||
const isEditable = ref(false);
|
const isCustomCssEditable = ref(false);
|
||||||
let debounceTimer = null;
|
let debounceTimer = null;
|
||||||
|
|
||||||
const highlightedCss = computed(() => {
|
const highlightedBaseCss = computed(() => {
|
||||||
if (!stylesheetStore.content) return '';
|
if (!stylesheetStore.baseCss) return '';
|
||||||
return hljs.highlight(stylesheetStore.content, { language: 'css' }).value;
|
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;
|
const newContent = event.target.value;
|
||||||
|
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
|
|
@ -50,24 +82,21 @@ const handleInput = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
stylesheetStore.content = newContent;
|
stylesheetStore.customCss = newContent;
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync editing mode with store
|
const handleFocus = () => {
|
||||||
watch(isEditable, async (newValue, oldValue) => {
|
stylesheetStore.isEditing = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch editing mode and format when exiting
|
||||||
|
watch(isCustomCssEditable, async (newValue, oldValue) => {
|
||||||
stylesheetStore.isEditing = newValue;
|
stylesheetStore.isEditing = newValue;
|
||||||
|
|
||||||
// Format when exiting editing mode
|
// Format when exiting editing mode
|
||||||
if (oldValue && !newValue) {
|
if (oldValue && !newValue) {
|
||||||
await stylesheetStore.formatContent();
|
await stylesheetStore.formatCustomCss();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable editing mode when changing tabs
|
|
||||||
watch(activeTab, (newTab) => {
|
|
||||||
if (newTab !== 'code' && isEditable.value) {
|
|
||||||
isEditable.value = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -79,18 +108,44 @@ watch(activeTab, (newTab) => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #282c34;
|
background: #282c34;
|
||||||
color: #fff;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
|
|
@ -144,11 +199,28 @@ h3 {
|
||||||
transform: translateX(20px);
|
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 {
|
.readonly {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.5rem;
|
padding: 1rem;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Courier New', Courier, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
@ -162,14 +234,20 @@ h3 {
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
color: #abb2bf;
|
color: #abb2bf;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem;
|
padding: 1rem;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Courier New', Courier, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #5c6370;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -354,10 +354,7 @@ const updateMargins = () => {
|
||||||
`$1${marginValue}`
|
`$1${marginValue}`
|
||||||
);
|
);
|
||||||
|
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||||
currentBlock,
|
|
||||||
updatedBlock
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch margin values (number inputs) with debounce
|
// Watch margin values (number inputs) with debounce
|
||||||
|
|
@ -398,19 +395,13 @@ const updateBackground = () => {
|
||||||
/(background:\s*)[^;]+/,
|
/(background:\s*)[^;]+/,
|
||||||
`$1${background.value.value}`
|
`$1${background.value.value}`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||||
currentBlock,
|
|
||||||
updatedBlock
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const updatedBlock = currentBlock.replace(
|
const updatedBlock = currentBlock.replace(
|
||||||
/(\s*})$/,
|
/(\s*})$/,
|
||||||
` background: ${background.value.value};\n$1`
|
` background: ${background.value.value};\n$1`
|
||||||
);
|
);
|
||||||
stylesheetStore.content = stylesheetStore.content.replace(
|
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||||
currentBlock,
|
|
||||||
updatedBlock
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -457,7 +448,7 @@ watch(runningTitle, (enabled) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePageFooters = () => {
|
const updatePageFooters = () => {
|
||||||
let currentCss = stylesheetStore.content;
|
let currentCss = stylesheetStore.customCss;
|
||||||
|
|
||||||
// Remove existing @page:left and @page:right rules
|
// Remove existing @page:left and @page:right rules
|
||||||
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
|
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
|
||||||
|
|
@ -539,7 +530,7 @@ const updatePageFooters = () => {
|
||||||
currentCss.slice(insertPosition);
|
currentCss.slice(insertPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
stylesheetStore.content = currentCss;
|
stylesheetStore.setCustomCss(currentCss);
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncFromStore = () => {
|
const syncFromStore = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,48 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import cssParsingUtils from '../utils/css-parsing';
|
import cssParsingUtils from '../utils/css-parsing';
|
||||||
import prettier from 'prettier/standalone';
|
import prettier from 'prettier/standalone';
|
||||||
import parserPostcss from 'prettier/plugins/postcss';
|
import parserPostcss from 'prettier/plugins/postcss';
|
||||||
|
import { getCsrfToken } from '../utils/kirby-auth';
|
||||||
|
|
||||||
export const useStylesheetStore = defineStore('stylesheet', () => {
|
export const useStylesheetStore = defineStore('stylesheet', () => {
|
||||||
const content = ref('');
|
// Base state
|
||||||
|
const baseCss = ref('');
|
||||||
|
const customCss = ref('');
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
let formatTimer = null;
|
let formatTimer = null;
|
||||||
let isFormatting = false;
|
let isFormatting = false;
|
||||||
|
let isInitializing = false;
|
||||||
|
|
||||||
// Format CSS with Prettier
|
// Save/load state
|
||||||
const formatContent = async () => {
|
const isDirty = ref(false);
|
||||||
if (isFormatting || !content.value) return;
|
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 {
|
try {
|
||||||
isFormatting = true;
|
isFormatting = true;
|
||||||
const formatted = await prettier.format(content.value, {
|
const formatted = await prettier.format(customCss.value, {
|
||||||
parser: 'css',
|
parser: 'css',
|
||||||
plugins: [parserPostcss],
|
plugins: [parserPostcss],
|
||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
tabWidth: 2,
|
tabWidth: 2,
|
||||||
useTabs: false,
|
useTabs: false,
|
||||||
});
|
});
|
||||||
content.value = formatted;
|
customCss.value = formatted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('CSS formatting error:', error);
|
console.error('CSS formatting error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -31,46 +50,195 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch content and format after 500ms of inactivity (only when not editing)
|
// Watch customCss and format after 500ms of inactivity (only when not editing)
|
||||||
watch(content, () => {
|
watch(customCss, () => {
|
||||||
if (isFormatting || isEditing.value) return;
|
if (isFormatting || isEditing.value || isInitializing) return;
|
||||||
|
|
||||||
|
// Mark as dirty when customCss changes (unless we're saving)
|
||||||
|
if (!isSaving.value) {
|
||||||
|
isDirty.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(formatTimer);
|
clearTimeout(formatTimer);
|
||||||
formatTimer = setTimeout(() => {
|
formatTimer = setTimeout(() => {
|
||||||
formatContent();
|
formatCustomCss();
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadStylesheet = async () => {
|
const loadStylesheet = async () => {
|
||||||
const response = await fetch('/assets/css/stylesheet.css');
|
const response = await fetch('/assets/css/stylesheet.print.css');
|
||||||
content.value = await response.text();
|
baseCss.value = await response.text();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProperty = (selector, property, value, unit) => {
|
const updateProperty = (selector, property, value, unit) => {
|
||||||
content.value = cssParsingUtils.updateCssValue({
|
// Update custom CSS, not the combined content
|
||||||
css: content.value,
|
customCss.value = cssParsingUtils.updateCssValue({
|
||||||
|
css: customCss.value,
|
||||||
selector,
|
selector,
|
||||||
property,
|
property,
|
||||||
value,
|
value,
|
||||||
unit
|
unit,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractValue = (selector, property) => {
|
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) => {
|
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 {
|
return {
|
||||||
content,
|
// Core state
|
||||||
|
content, // computed: baseCss + customCss
|
||||||
|
baseCss,
|
||||||
|
customCss,
|
||||||
isEditing,
|
isEditing,
|
||||||
|
|
||||||
|
// Methods
|
||||||
loadStylesheet,
|
loadStylesheet,
|
||||||
updateProperty,
|
updateProperty,
|
||||||
extractValue,
|
extractValue,
|
||||||
extractBlock,
|
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
37
src/utils/kirby-auth.js
Normal 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');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue