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

@ -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');
}