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

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