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
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue