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
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 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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
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