Afiq 96fafd33d7 Add Switch Component and Enhance Form Builder Functionality
- Introduced a new Switch component for toggling options within forms, enhancing user interactivity.
- Updated formkit-custom.js to include the Switch component with appropriate props.
- Enhanced formkit-theme.js to define styles for the Switch component, ensuring consistent theming.
- Added CSS styles for the Switch component to improve visual presentation and user experience.
- Updated FormBuilderCanvas and FormBuilderComponents to support the new Switch component in the form builder interface.
- Enhanced documentation to include details about the new Switch component and its usage within forms.
2025-05-27 00:00:09 +08:00

2648 lines
94 KiB
Vue

<template>
<div class="flex flex-col h-screen bg-gray-50">
<!-- Header Bar -->
<header
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
>
<!-- Left section - Logo and navigation -->
<div class="flex items-center gap-3">
<Icon
@click="navigateTo('/', { external: true })"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Corrad Logo"
class="h-7"
/>
<div v-if="isPreview" class="bg-blue-500 text-white text-xs px-2 py-1 rounded-full ml-2">
Preview Mode
</div>
</div>
<!-- Middle section - Form name -->
<div class="flex-1 flex justify-center items-center mx-4">
<FormKit
v-if="!isPreview"
type="text"
name="formName"
placeholder="Form Name"
v-model="formName"
validation="required"
validation-visibility="live"
:validation-messages="{ required: 'Please enter a form name' }"
class="form-name-input max-w-md"
:classes="{
outer: 'mb-0 w-full',
}"
/>
<div v-else class="text-lg font-medium">{{ formName }}</div>
</div>
<!-- Right section - Actions -->
<div class="flex items-center">
<!-- Primary actions -->
<div class="flex items-center mr-2 border-r border-gray-600 pr-2">
<RsButton v-if="!isPreview" @click="handleSave" variant="primary" size="sm" class="mr-2">
<Icon name="material-symbols:save" class="mr-1" />
Save
</RsButton>
<RsButton @click="togglePreview" :variant="isPreview ? 'primary' : 'secondary'" size="sm">
<Icon :name="isPreview ? 'material-symbols:edit' : 'material-symbols:preview'" class="mr-1" />
{{ isPreview ? 'Edit' : 'Preview' }}
</RsButton>
</div>
<!-- Templates button -->
<div v-if="!isPreview" class="mr-2 border-r border-gray-600 pr-2">
<RsButton @click="showTemplatesModal = true" variant="secondary" size="sm">
<Icon name="material-symbols:description-outline" class="mr-1" />
Templates
</RsButton>
</div>
<!-- Secondary actions - only in edit mode -->
<div v-if="!isPreview" class="flex items-center">
<div class="dropdown relative">
<RsButton @click="showDropdown = !showDropdown" variant="tertiary" size="sm" class="flex items-center">
<Icon name="material-symbols:more-vert" class="w-5 h-5" />
</RsButton>
<div v-if="showDropdown" class="dropdown-menu absolute right-0 mt-2 bg-white rounded shadow-lg py-1 z-10 w-48 text-gray-800">
<button @click="showFormSettings = true; showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:code" class="mr-2 w-4 h-4" />
<span>Form Settings</span>
</button>
<button @click="navigateToManage(); showDropdown = false" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center">
<Icon name="material-symbols:settings" class="mr-2 w-4 h-4" />
<span>Manage Forms</span>
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Panel - Components Sidebar -->
<div
v-if="!isPreview"
class="w-64 bg-white border-r border-gray-200 flex flex-col overflow-hidden"
>
<div class="border-b border-gray-200">
<div class="flex">
<button
@click="leftSidebarTab = 'components'"
class="px-4 py-3 text-sm font-medium flex-1"
:class="{
'text-blue-600 border-b-2 border-blue-600': leftSidebarTab === 'components',
'text-gray-500 hover:text-gray-700': leftSidebarTab !== 'components'
}"
>
<div class="flex items-center justify-center">
<Icon name="material-symbols:category" class="w-4 h-4 mr-1.5" />
Components
</div>
</button>
<button
@click="leftSidebarTab = 'history'"
class="px-4 py-3 text-sm font-medium flex-1"
:class="{
'text-blue-600 border-b-2 border-blue-600': leftSidebarTab === 'history',
'text-gray-500 hover:text-gray-700': leftSidebarTab !== 'history'
}"
>
<div class="flex items-center justify-center">
<Icon name="material-symbols:history" class="w-4 h-4 mr-1.5" />
History
</div>
</button>
</div>
</div>
<!-- Components Tab Content -->
<div v-if="leftSidebarTab === 'components'" class="overflow-y-auto flex-1">
<div
class="p-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center"
>
<h2 class="text-sm font-medium text-gray-700">Components</h2>
<div class="relative">
<button class="px-2 py-1 text-xs text-gray-500 hover:text-gray-700">
<Icon name="material-symbols:refresh" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<FormBuilderComponents @add-component="handleAddComponent" />
</div>
<!-- History Tab Content -->
<div v-else-if="leftSidebarTab === 'history'" class="overflow-y-auto flex-1">
<div
class="p-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center"
>
<h2 class="text-sm font-medium text-gray-700">History</h2>
<div class="flex items-center space-x-1">
<button
@click="formStore.undo()"
class="p-1 text-xs rounded text-gray-500 hover:text-gray-700 disabled:opacity-50"
:disabled="!formStore.canUndo"
title="Undo"
>
<Icon name="material-symbols:undo" class="w-3.5 h-3.5" />
</button>
<button
@click="formStore.redo()"
class="p-1 text-xs rounded text-gray-500 hover:text-gray-700 disabled:opacity-50"
:disabled="formStore.currentHistoryIndex >= formStore.actionHistory.length - 1"
title="Redo"
>
<Icon name="material-symbols:redo" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<FormBuilderHistory />
</div>
</div>
<!-- Middle Panel - Form Canvas -->
<div class="flex-1 flex flex-col overflow-hidden">
<div
class="py-2 px-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center h-12"
>
<h2 class="text-sm font-medium text-gray-700">{{ isPreview ? 'Form Preview' : 'Form Canvas' }}</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">
{{ isPreview ? 'Preview mode - Form is interactive' : 'Drag components to build your form' }}
</span>
</div>
</div>
<div class="flex-1 overflow-auto p-6">
<div
id="form-drop-zone"
class="border-2 border-dashed border-gray-300 bg-white rounded-md min-h-[calc(100vh-170px)] p-4"
:class="{
'border-blue-400 bg-blue-50': formStore.isDraggingOver && !isPreview,
'border-solid': isPreview,
'preview-mode': isPreview
}"
@dragover="!isPreview && handleDragOver($event)"
@dragleave="!isPreview && handleDragLeave($event)"
@drop="!isPreview && handleDrop($event)"
@dragenter="!isPreview && handleDragEnter($event)"
>
<!-- Preview Mode -->
<div v-if="isPreview" class="form-container">
<FormScriptEngine
:form-data="previewFormData"
:custom-script="formStore.formCustomScript"
:custom-css="formStore.formCustomCSS"
:form-events="formStore.formEvents"
:script-mode="formStore.scriptMode"
@field-change="handleScriptFieldChange"
@field-validate="handleScriptFieldValidate"
/>
<FormKit type="form" @submit="handlePreviewSubmit" :actions="false" v-model="previewFormData">
<div class="grid-preview-container">
<template
v-for="(component, index) in formStore.formComponents"
:key="index"
>
<component-preview :component="component" :is-preview="false" />
</template>
</div>
<!-- Only show submit button if no submit button components exist in the form -->
<FormKit
v-if="!formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
type="submit"
label="Submit"
class="form-submit mt-6"
/>
</FormKit>
</div>
<!-- Edit Mode -->
<div v-else class="relative">
<FormBuilderCanvas
:form-components="formStore.formComponents"
@select-component="handleSelectComponent"
@move-component="handleMoveComponent"
@delete-component="handleDeleteComponent"
@update-component="handleUpdateComponent"
@optimize-layout="handleOptimizeLayout"
/>
<!-- Instruction Overlay when no component is selected -->
<div
v-if="!isPreview && formStore.formComponents.length > 0 && !formStore.selectedComponent"
class="absolute top-4 left-1/2 transform -translate-x-1/2 z-30"
>
<div class="instruction-tooltip">
<div class="flex items-center space-x-2">
<Icon name="heroicons:cursor-arrow-rays" class="w-5 h-5 text-blue-600" />
<span class="text-sm font-medium text-gray-700">Click any field to configure its settings</span>
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 text-gray-500" />
</div>
<div class="instruction-arrow"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Panel - Field Settings (Collapsible) -->
<div
v-if="!isPreview"
class="field-settings-panel"
:class="{
'panel-expanded': showFieldSettingsPanel,
'panel-collapsed': !showFieldSettingsPanel
}"
>
<!-- Expanded Panel Content -->
<div
v-if="showFieldSettingsPanel"
class="panel-content"
>
<!-- Panel Header with Toggle -->
<div class="panel-header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5 text-gray-600" />
<h3 class="text-sm font-semibold text-gray-800">Field Settings</h3>
</div>
<button
@click="toggleFieldSettingsPanel"
class="panel-header-toggle-btn"
title="Collapse Settings Panel"
>
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected Component Info or Empty State -->
<div class="panel-body">
<div v-if="!formStore.selectedComponent" class="empty-state">
<div class="text-center py-8">
<Icon name="heroicons:cursor-arrow-rays" class="w-12 h-12 text-gray-300 mx-auto mb-3" />
<h4 class="text-sm font-medium text-gray-600 mb-2">No Field Selected</h4>
<p class="text-xs text-gray-500 leading-relaxed">
Click on any form field in the canvas to configure its settings here.
</p>
</div>
</div>
<div v-else class="component-settings">
<!-- Component Header -->
<div class="component-header">
<div class="flex items-center space-x-3">
<div class="component-icon">
<Icon :name="getComponentIcon(formStore.selectedComponent.type)" class="w-6 h-6 text-blue-600" />
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-gray-900 truncate">
{{ getComponentTypeName(formStore.selectedComponent.type) }}
</h4>
<p class="text-xs text-gray-500 truncate">
{{ formStore.selectedComponent.props.label || formStore.selectedComponent.props.name || 'Untitled' }}
</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="quick-actions">
<button
@click="openFullSettingsModal"
class="action-btn primary"
title="Open full settings modal"
>
<Icon name="heroicons:adjustments-horizontal" class="w-4 h-4 mr-1.5" />
Full Settings
</button>
<button
@click="duplicateComponent"
class="action-btn secondary"
title="Duplicate this component"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4 mr-1.5" />
Duplicate
</button>
<button
@click="deleteComponent"
class="action-btn danger"
title="Delete this component"
>
<Icon name="heroicons:trash" class="w-4 h-4 mr-1.5" />
Delete
</button>
</div>
<!-- Inline Quick Settings -->
<div class="quick-settings">
<h5 class="settings-section-title">Quick Settings</h5>
<!-- Label Field -->
<div v-if="showQuickField('label')" class="setting-item">
<label class="setting-label">
{{ formStore.selectedComponent.type === 'button' ? 'Button Text' : 'Field Label' }}
</label>
<FormKit
type="text"
v-model="quickSettings.label"
@input="updateQuickSetting('label', $event)"
:placeholder="getPlaceholder('label')"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<!-- Name Field -->
<div v-if="showQuickField('name')" class="setting-item">
<label class="setting-label">Field Name</label>
<FormKit
type="text"
v-model="quickSettings.name"
@input="updateQuickSetting('name', $event)"
:placeholder="getPlaceholder('name')"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
<!-- Width Setting -->
<div v-if="showQuickField('width')" class="setting-item">
<label class="setting-label">Field Width</label>
<div class="width-selector-compact">
<button
v-for="option in compactWidthOptions"
:key="option.value"
@click="updateQuickSetting('width', option.value, option.gridColumns)"
class="width-btn"
:class="{ 'active': getComponentWidthPercent() === option.value }"
:title="option.description"
>
{{ option.name }}
</button>
</div>
</div>
<!-- Required Toggle -->
<div v-if="showQuickField('required')" class="setting-item">
<label class="setting-toggle">
<input
type="checkbox"
v-model="quickSettings.required"
@change="updateQuickValidation('required', $event.target.checked)"
class="toggle-input"
/>
<span class="toggle-slider"></span>
<span class="toggle-label">Required Field</span>
</label>
</div>
<!-- Placeholder -->
<div v-if="showQuickField('placeholder')" class="setting-item">
<label class="setting-label">Placeholder Text</label>
<FormKit
type="text"
v-model="quickSettings.placeholder"
@input="updateQuickSetting('placeholder', $event)"
:placeholder="getPlaceholder('placeholder')"
:classes="{ outer: 'mb-0', input: 'text-sm' }"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Collapsed State -->
<div
v-else
class="collapsed-info"
>
<!-- Collapsed Header with Toggle -->
<div class="collapsed-header">
<button
@click="toggleFieldSettingsPanel"
class="collapsed-toggle-btn"
title="Expand Settings Panel"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
</button>
</div>
<!-- Collapsed Content -->
<div class="collapsed-content">
<!-- Selected Component Info -->
<div v-if="formStore.selectedComponent" class="selected-component-collapsed">
<div class="component-icon-collapsed">
<Icon :name="getComponentIcon(formStore.selectedComponent.type)" class="w-4 h-4 text-blue-600" />
</div>
<div class="component-type-badge">
{{ getComponentTypeShort(formStore.selectedComponent.type) }}
</div>
</div>
<!-- No Selection State -->
<div v-else class="no-selection-collapsed">
<Icon name="heroicons:cursor-arrow-rays" class="w-4 h-4 text-gray-400 mb-2" />
<div class="text-xs text-gray-400 text-center writing-mode-vertical">
Select field
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form Settings Modal -->
<RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl" position="center">
<div>
<RsTab :tabs="settingsTabs" v-model="activeSettingsTab">
<!-- Form Info Tab -->
<template #info>
<div class="p-4 space-y-4">
<FormKit
type="text"
label="Form Name"
v-model="formStore.formName"
help="Name of your form"
validation="required"
/>
<FormKit
type="textarea"
label="Form Description"
v-model="formStore.formDescription"
help="Brief description of what this form is for"
rows="3"
/>
</div>
</template>
<!-- Custom JavaScript Tab -->
<template #javascript>
<div class="p-4">
<div class="mb-4">
<h3 class="text-lg font-medium mb-2">Custom JavaScript</h3>
<p class="text-sm text-gray-600 mb-4">
Write custom JavaScript to add dynamic behavior to your form.
Use <code class="bg-gray-100 px-1 rounded">this.getField('fieldName')</code> to access form fields.
</p>
<!-- Helper Functions Reference -->
<div class="mb-4 p-3 bg-blue-50 rounded border border-blue-200">
<details>
<summary class="text-sm font-medium text-blue-800 cursor-pointer">📚 Available Helper Functions</summary>
<div class="mt-2 text-xs text-blue-700 space-y-1">
<div><code>this.getField('name')</code> - Get field value</div>
<div><code>this.setField('name', value)</code> - Set field value</div>
<div><code>this.hideField('name')</code> - Hide field</div>
<div><code>this.showField('name')</code> - Show field</div>
<div><code>this.disableField('name')</code> - Disable field</div>
<div><code>this.enableField('name')</code> - Enable field</div>
<div><code>this.validateField('name')</code> - Trigger field validation</div>
<div><code>this.getAllFieldValues()</code> - Get all form values</div>
</div>
</details>
</div>
</div>
<RsCodeMirror
v-model="formStore.formCustomScript"
language="javascript"
height="300px"
placeholder="// Example: Hide/show fields based on selection
// this.onFieldChange('customer_type', (value) => {
// if (value === 'business') {
// this.showField('company_name');
// this.showField('tax_id');
// } else {
// this.hideField('company_name');
// this.hideField('tax_id');
// }
// });
// Example: Calculate total
// this.onFieldChange(['quantity', 'price'], () => {
// const qty = this.getField('quantity') || 0;
// const price = this.getField('price') || 0;
// this.setField('total', qty * price);
// });"
/>
</div>
</template>
<!-- Custom CSS Tab -->
<template #css>
<div class="p-4">
<div class="mb-4">
<h3 class="text-lg font-medium mb-2">Custom CSS</h3>
<p class="text-sm text-gray-600 mb-4">
Add custom styles to enhance your form appearance. Use <code class="bg-gray-100 px-1 rounded">.form-container</code> to target the form.
</p>
<!-- CSS Helper Reference -->
<div class="mb-4 p-3 bg-green-50 rounded border border-green-200">
<details>
<summary class="text-sm font-medium text-green-800 cursor-pointer">🎨 CSS Selectors</summary>
<div class="mt-2 text-xs text-green-700 space-y-1">
<div><code>.form-container</code> - Main form wrapper</div>
<div><code>.form-field</code> - Individual form fields</div>
<div><code>.form-field[data-name="fieldName"]</code> - Specific field</div>
<div><code>.form-section</code> - Form sections</div>
<div><code>.form-submit</code> - Submit button</div>
<div><code>.info-display</code> - Info display components</div>
</div>
</details>
</div>
</div>
<RsCodeMirror
v-model="formStore.formCustomCSS"
language="css"
height="300px"
placeholder="/* Example: Custom field styling */
/* .form-field[data-name='customer_name'] {
background: #f0f9ff;
border: 2px solid #0ea5e9;
}
/* Example: Hide field initially */
/* .form-field[data-name='hidden_field'] {
display: none;
}
/* Example: Custom info display styling */
/* .info-display {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
} */"
/>
</div>
</template>
<!-- Script Events Tab -->
<template #events>
<div class="p-4">
<div class="mb-4">
<h3 class="text-lg font-medium mb-2">Form Events</h3>
<p class="text-sm text-gray-600 mb-4">
Configure when your custom scripts should run.
</p>
</div>
<div class="space-y-4">
<div class="border rounded p-4">
<h4 class="font-medium mb-2">Event Triggers</h4>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" v-model="formStore.formEvents.onLoad" class="mr-2">
<span class="text-sm">On Form Load</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="formStore.formEvents.onFieldChange" class="mr-2">
<span class="text-sm">On Field Change</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="formStore.formEvents.onSubmit" class="mr-2">
<span class="text-sm">Before Form Submit</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="formStore.formEvents.onValidation" class="mr-2">
<span class="text-sm">On Field Validation</span>
</label>
</div>
</div>
<div class="border rounded p-4">
<h4 class="font-medium mb-2">Script Execution Mode</h4>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" v-model="formStore.scriptMode" value="safe" class="mr-2">
<span class="text-sm">Safe Mode (Recommended) - Limited but secure</span>
</label>
<label class="flex items-center">
<input type="radio" v-model="formStore.scriptMode" value="advanced" class="mr-2">
<span class="text-sm">Advanced Mode - Full JavaScript access</span>
</label>
</div>
</div>
</div>
</div>
</template>
<!-- JSON Tab -->
<template #json>
<div class="p-4">
<div class="mb-4">
<h3 class="text-lg font-medium mb-2">Form Configuration</h3>
<p class="text-sm text-gray-600 mb-4">
This section displays the complete form configuration as JSON for developers.
</p>
<!-- Form metadata -->
<div class="bg-gray-50 p-3 rounded border mb-4 text-sm">
<div class="grid grid-cols-2 gap-2">
<div>
<span class="font-medium">Component Count:</span> {{ formStore.formComponents.length }}
</div>
<div>
<span class="font-medium">Form ID:</span> {{ formStore.currentFormId || 'Not saved yet' }}
</div>
<div>
<span class="font-medium">Has Script:</span> {{ formStore.formCustomScript ? 'Yes' : 'No' }}
</div>
<div>
<span class="font-medium">Has Custom CSS:</span> {{ formStore.formCustomCSS ? 'Yes' : 'No' }}
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex flex-wrap justify-between mb-2 gap-2">
<div>
<!-- Import button and hidden file input -->
<input
type="file"
ref="jsonFileInput"
accept=".json"
style="display: none"
@change="handleJsonImport"
/>
<RsButton @click="$refs.jsonFileInput.click()" size="sm" variant="primary">
<Icon name="material-symbols:upload" class="mr-1" />
Import JSON
</RsButton>
</div>
<div class="flex space-x-2">
<RsButton @click="toggleJsonEditMode" size="sm" :variant="isEditingJson ? 'primary' : 'secondary'">
<Icon :name="isEditingJson ? 'material-symbols:edit-off' : 'material-symbols:edit'" class="mr-1" />
{{ isEditingJson ? 'View Mode' : 'Edit Mode' }}
</RsButton>
<RsButton @click="downloadJsonFile" size="sm" variant="secondary">
<Icon name="material-symbols:download" class="mr-1" />
Download
</RsButton>
<RsButton @click="copyJsonToClipboard" size="sm" variant="secondary">
<Icon name="material-symbols:content-copy" class="mr-1" />
Copy
</RsButton>
</div>
</div>
<!-- Warning message when in edit mode -->
<div v-if="isEditingJson" class="bg-yellow-50 border border-yellow-200 text-yellow-800 p-3 rounded mb-4">
<div class="flex items-start">
<Icon name="material-symbols:warning" class="w-5 h-5 mr-2 mt-0.5" />
<div>
<h4 class="font-medium text-sm">Warning: Direct JSON Editing</h4>
<p class="text-xs mt-1">
Changes made to JSON directly will replace your form configuration when you click "Apply JSON Changes".
Ensure the JSON is valid before applying changes.
</p>
</div>
</div>
</div>
</div>
<RsCodeMirror
v-if="isEditingJson"
v-model="editableJsonString"
language="json"
height="300px"
:readonly="false"
/>
<RsCodeMirror
v-else
:value="formJsonString"
language="json"
height="300px"
:readonly="true"
/>
<!-- Apply changes button only visible in edit mode -->
<div v-if="isEditingJson" class="mt-4 flex justify-end">
<RsButton @click="applyJsonChanges" variant="primary" size="md">
Apply JSON Changes
</RsButton>
</div>
</div>
</template>
</RsTab>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="showFormSettings = false" variant="tertiary">
Cancel
</RsButton>
<RsButton @click="saveFormSettings" variant="primary">
Save Settings
</RsButton>
</div>
</template>
</RsModal>
<!-- Unsaved Changes Modal -->
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
<div class="p-4">
<div class="flex items-center mb-4">
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3" />
<div>
<h3 class="font-medium text-lg">You have unsaved changes</h3>
<p class="text-gray-600">Are you sure you want to leave? Your changes will be lost.</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<RsButton @click="cancelNavigation" variant="tertiary">
Stay
</RsButton>
<RsButton @click="confirmNavigation" variant="danger">
Leave
</RsButton>
</div>
</template>
</RsModal>
<!-- Add Templates Modal -->
<FormTemplatesModal
v-model="showTemplatesModal"
@select-template="applyFormTemplate"
/>
<!-- Field Settings Modal -->
<FormBuilderFieldSettingsModal
v-model="showFieldSettings"
:component="formStore.selectedComponent"
@update-component="handleUpdateComponent"
@close="showFieldSettings = false"
/>
</div>
</template>
<script setup>
import { useFormBuilderStore } from "~/stores/formBuilder";
import FormBuilderHistory from "~/components/FormBuilderHistory.vue";
import FormTemplatesModal from '~/components/FormTemplatesModal.vue';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
definePageMeta({
title: "Form Builder",
description: "Create dynamic forms with drag and drop",
layout: "empty",
middleware: ["auth"],
requiresAuth: true,
});
const router = useRouter();
const formStore = useFormBuilderStore();
let toast;
// Try to use the toast composable if available
try {
toast = useToast();
} catch (error) {
// Create a simple toast object if composable is not available
toast = {
success: (msg) => console.log('Success:', msg),
error: (msg) => console.error('Error:', msg),
info: (msg) => console.info('Info:', msg),
warning: (msg) => console.warn('Warning:', msg)
};
}
const showPreview = ref(false);
const showUnsavedChangesModal = ref(false);
const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const navigationConfirmed = ref(false);
const leftSidebarTab = ref('components');
const showFormSettings = ref(false);
const activeSettingsTab = ref('info');
const isPreview = ref(false);
const showDropdown = ref(false);
const showTemplatesModal = ref(false);
const showFieldSettings = ref(false);
const showFieldSettingsPanel = ref(false);
// Settings tabs configuration
const settingsTabs = [
{ key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' },
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' },
{ key: 'json', label: 'JSON', icon: 'material-symbols:data-object' }
];
// Computed property for form name with getter and setter
const formName = computed({
get: () => formStore.formName,
set: (value) => {
if (value !== formStore.formName) {
formStore.setFormName(value);
}
}
});
// Form JSON representation for developer view
const formJson = computed(() => {
return {
formName: formStore.formName,
formDescription: formStore.formDescription,
formId: formStore.currentFormId,
components: formStore.formComponents.map(component => ({
id: component.id,
type: component.type,
props: component.props
})),
customScript: formStore.formCustomScript,
customCSS: formStore.formCustomCSS,
formEvents: formStore.formEvents,
scriptMode: formStore.scriptMode
};
});
// Convert form JSON to formatted string
const formJsonString = computed(() => {
return JSON.stringify(formJson.value, null, 2);
});
// JSON editing state
const isEditingJson = ref(false);
const editableJsonString = ref('');
// Toggle between read-only and edit mode for JSON
const toggleJsonEditMode = () => {
if (!isEditingJson.value) {
// Switching to edit mode - initialize the editable string
editableJsonString.value = formJsonString.value;
}
isEditingJson.value = !isEditingJson.value;
};
// Apply changes from JSON editor
const applyJsonChanges = () => {
try {
const importedJson = JSON.parse(editableJsonString.value);
// Validate imported JSON
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
throw new Error('Invalid form JSON structure. Must include formName and components array.');
}
// Import form data
formStore.setFormName(importedJson.formName);
formStore.formDescription = importedJson.formDescription || '';
formStore.formComponents = [];
// Import components
if (importedJson.components.length > 0) {
importedJson.components.forEach((component, index) => {
if (component.type && component.props) {
// Process component properties based on type
let processedProps = { ...component.props };
// Ensure required grid properties
if (!processedProps.width) {
processedProps.width = '100%';
}
if (!processedProps.gridColumn) {
processedProps.gridColumn = 'span 12';
}
// Handle special component types
switch (component.type) {
case 'image-preview':
// Ensure all required image preview properties
processedProps = {
label: processedProps.label || 'Image Preview',
name: processedProps.name || `image_preview_${index + 1}`,
help: processedProps.help || '',
imageUrl: processedProps.imageUrl || 'https://placehold.co/600x400',
altText: processedProps.altText || 'Preview image',
caption: processedProps.caption || '',
showZoom: processedProps.showZoom !== undefined ? processedProps.showZoom : true,
showCaption: processedProps.showCaption !== undefined ? processedProps.showCaption : true,
maxWidth: processedProps.maxWidth || '100%',
height: processedProps.height || 'auto',
...processedProps
};
break;
case 'repeating-group':
// Ensure all required repeating group properties
processedProps = {
label: processedProps.label || 'Repeating Group',
name: processedProps.name || `repeating_group_${index + 1}`,
help: processedProps.help || '',
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 1,
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 10,
buttonText: processedProps.buttonText || 'Add Item',
removeText: processedProps.removeText || 'Remove',
fields: processedProps.fields || [
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
],
...processedProps
};
break;
case 'dynamic-list':
// Ensure all required dynamic list properties
processedProps = {
label: processedProps.label || 'Dynamic List',
name: processedProps.name || `dynamic_list_${index + 1}`,
help: processedProps.help || '',
placeholder: processedProps.placeholder || 'Enter item',
buttonText: processedProps.buttonText || 'Add Item',
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 0,
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 20,
defaultItems: Array.isArray(processedProps.defaultItems) ? processedProps.defaultItems : ['Item 1', 'Item 2'],
...processedProps
};
break;
case 'info-display':
// Ensure all required info display properties
processedProps = {
title: processedProps.title || 'Information',
name: processedProps.name || `info_display_${index + 1}`,
help: processedProps.help || '',
layout: processedProps.layout || 'vertical',
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
backgroundColor: processedProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
{ label: 'Info Item', value: 'Value', key: 'item_1' }
],
...processedProps
};
break;
case 'file':
// Ensure all required file upload properties
processedProps = {
label: processedProps.label || 'File Upload',
name: processedProps.name || `file_upload_${index + 1}`,
help: processedProps.help || 'Upload a file',
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedProps
};
break;
case 'heading':
// Ensure all required heading properties
processedProps = {
value: processedProps.value || 'Heading',
level: processedProps.level || 2,
...processedProps
};
break;
case 'paragraph':
// Ensure all required paragraph properties
processedProps = {
value: processedProps.value || 'Paragraph text',
...processedProps
};
break;
case 'select':
case 'radio':
case 'checkbox':
// Ensure options array exists
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
processedProps.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
];
}
break;
default:
// Basic properties for all other component types
if (!processedProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.label = component.type.charAt(0).toUpperCase() + component.type.slice(1) + ' ' + (index + 1);
}
if (!processedProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.name = `${component.type}_${index + 1}`;
}
break;
}
// Create component in expected format
const formattedComponent = {
type: component.type,
name: processedProps.label || component.type,
category: getDefaultCategory(component.type),
icon: getDefaultIcon(component.type),
defaultProps: processedProps
};
// Add component to form
formStore.addComponent(formattedComponent);
} else {
console.warn('Skipping invalid component:', component);
}
});
}
// Import custom code if available
if (importedJson.customScript !== undefined) {
formStore.formCustomScript = importedJson.customScript;
}
if (importedJson.customCSS !== undefined) {
formStore.formCustomCSS = importedJson.customCSS;
}
// Import events if available
if (importedJson.formEvents) {
formStore.formEvents = importedJson.formEvents;
}
if (importedJson.scriptMode) {
formStore.scriptMode = importedJson.scriptMode;
}
// Mark as having unsaved changes
formStore.hasUnsavedChanges = true;
// Exit edit mode and update the editable JSON
isEditingJson.value = false;
toast.success('Form configuration updated from JSON');
} catch (err) {
console.error('Error applying JSON changes:', err);
toast.error(`Failed to apply JSON changes: ${err.message}`);
}
};
// Function to copy JSON to clipboard
const copyJsonToClipboard = () => {
navigator.clipboard.writeText(formJsonString.value)
.then(() => {
toast.success('JSON copied to clipboard');
})
.catch(err => {
console.error('Failed to copy JSON:', err);
toast.error('Failed to copy JSON to clipboard');
});
};
// Function to download JSON file
const downloadJsonFile = () => {
try {
const jsonBlob = new Blob([formJsonString.value], { type: 'application/json' });
const url = URL.createObjectURL(jsonBlob);
const link = document.createElement('a');
link.href = url;
link.download = `${formStore.formName.replace(/\s+/g, '-').toLowerCase()}_${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(url);
toast.success('JSON file downloaded');
} catch (err) {
console.error('Error downloading JSON:', err);
toast.error('Failed to download JSON file');
}
};
// Reference to file input element
const jsonFileInput = ref(null);
// Function to handle JSON import
const handleJsonImport = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedJson = JSON.parse(e.target.result);
// If in edit mode, just update the editable content
if (isEditingJson.value) {
editableJsonString.value = JSON.stringify(importedJson, null, 2);
toast.success('JSON loaded into editor. Click "Apply JSON Changes" to update the form.');
} else {
// Confirm before replacing current form
if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) {
// Reset file input
event.target.value = '';
return;
}
}
// Validate imported JSON
if (!importedJson.formName || !Array.isArray(importedJson.components)) {
throw new Error('Invalid form JSON structure');
}
// Import form data
formStore.setFormName(importedJson.formName);
formStore.formDescription = importedJson.formDescription || '';
formStore.formComponents = [];
// Import components
if (importedJson.components.length > 0) {
importedJson.components.forEach((component, index) => {
if (component.type && component.props) {
// Process component properties based on type
let processedProps = { ...component.props };
// Ensure required grid properties
if (!processedProps.width) {
processedProps.width = '100%';
}
if (!processedProps.gridColumn) {
processedProps.gridColumn = 'span 12';
}
// Handle special component types
switch (component.type) {
case 'image-preview':
// Ensure all required image preview properties
processedProps = {
label: processedProps.label || 'Image Preview',
name: processedProps.name || `image_preview_${index + 1}`,
help: processedProps.help || '',
imageUrl: processedProps.imageUrl || 'https://placehold.co/600x400',
altText: processedProps.altText || 'Preview image',
caption: processedProps.caption || '',
showZoom: processedProps.showZoom !== undefined ? processedProps.showZoom : true,
showCaption: processedProps.showCaption !== undefined ? processedProps.showCaption : true,
maxWidth: processedProps.maxWidth || '100%',
height: processedProps.height || 'auto',
...processedProps
};
break;
case 'repeating-group':
// Ensure all required repeating group properties
processedProps = {
label: processedProps.label || 'Repeating Group',
name: processedProps.name || `repeating_group_${index + 1}`,
help: processedProps.help || '',
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 1,
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 10,
buttonText: processedProps.buttonText || 'Add Item',
removeText: processedProps.removeText || 'Remove',
fields: processedProps.fields || [
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
],
...processedProps
};
break;
case 'dynamic-list':
// Ensure all required dynamic list properties
processedProps = {
label: processedProps.label || 'Dynamic List',
name: processedProps.name || `dynamic_list_${index + 1}`,
help: processedProps.help || '',
placeholder: processedProps.placeholder || 'Enter item',
buttonText: processedProps.buttonText || 'Add Item',
minItems: processedProps.minItems !== undefined ? processedProps.minItems : 0,
maxItems: processedProps.maxItems !== undefined ? processedProps.maxItems : 20,
defaultItems: Array.isArray(processedProps.defaultItems) ? processedProps.defaultItems : ['Item 1', 'Item 2'],
...processedProps
};
break;
case 'info-display':
// Ensure all required info display properties
processedProps = {
title: processedProps.title || 'Information',
name: processedProps.name || `info_display_${index + 1}`,
help: processedProps.help || '',
layout: processedProps.layout || 'vertical',
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
backgroundColor: processedProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
{ label: 'Info Item', value: 'Value', key: 'item_1' }
],
...processedProps
};
break;
case 'file':
// Ensure all required file upload properties
processedProps = {
label: processedProps.label || 'File Upload',
name: processedProps.name || `file_upload_${index + 1}`,
help: processedProps.help || 'Upload a file',
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedProps
};
break;
case 'heading':
// Ensure all required heading properties
processedProps = {
value: processedProps.value || 'Heading',
level: processedProps.level || 2,
...processedProps
};
break;
case 'paragraph':
// Ensure all required paragraph properties
processedProps = {
value: processedProps.value || 'Paragraph text',
...processedProps
};
break;
case 'select':
case 'radio':
case 'checkbox':
// Ensure options array exists
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
processedProps.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
];
}
break;
default:
// Basic properties for all other component types
if (!processedProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.label = component.type.charAt(0).toUpperCase() + component.type.slice(1) + ' ' + (index + 1);
}
if (!processedProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.name = `${component.type}_${index + 1}`;
}
break;
}
// Create component in expected format
const formattedComponent = {
type: component.type,
name: processedProps.label || component.type,
category: getDefaultCategory(component.type),
icon: getDefaultIcon(component.type),
// Use the processed default props
defaultProps: processedProps
};
console.log('Formatted component:', formattedComponent);
// Add component to form
formStore.addComponent(formattedComponent);
} else {
console.warn('Skipping invalid component:', component);
}
});
}
// Import custom code if available
if (importedJson.customScript) {
formStore.formCustomScript = importedJson.customScript;
}
if (importedJson.customCSS) {
formStore.formCustomCSS = importedJson.customCSS;
}
// Import events if available
if (importedJson.formEvents) {
formStore.formEvents = importedJson.formEvents;
}
if (importedJson.scriptMode) {
formStore.scriptMode = importedJson.scriptMode;
}
// Mark as having unsaved changes
formStore.hasUnsavedChanges = true;
toast.success('Form imported successfully');
}
} catch (err) {
console.error('Error importing JSON:', err);
toast.error(`Failed to import JSON: ${err.message}`);
}
// Reset file input
event.target.value = '';
};
reader.onerror = () => {
toast.error('Error reading file');
// Reset file input
event.target.value = '';
};
reader.readAsText(file);
};
// Initialize the form builder
onMounted(async () => {
try {
await formStore.loadSavedForms();
// Check if there's a form ID in the URL query parameters
const route = useRoute();
const formId = route.query.id;
if (formId) {
try {
await formStore.loadForm(formId);
toast.success(`Form '${formStore.formName}' loaded successfully`);
} catch (error) {
console.error('Error loading form from ID:', error);
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
}
}
} catch (error) {
console.error('Error initializing form builder:', error);
toast.error(`Initialization error: ${error.message || 'Unknown error'}`);
}
// Add the beforeunload event listener
window.addEventListener('beforeunload', handleBeforeUnload);
// Setup keyboard shortcuts
window.addEventListener('keydown', handleKeyboardShortcuts);
// Close dropdown when clicking outside
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('keydown', handleKeyboardShortcuts);
document.removeEventListener('click', handleClickOutside);
});
// Show warning if there are unsaved changes
const handleBeforeUnload = (event) => {
if (formStore.hasUnsavedChanges) {
event.preventDefault();
event.returnValue = '';
return '';
}
};
// Handle keyboard shortcuts
const handleKeyboardShortcuts = (event) => {
// Undo: Ctrl+Z
if (event.ctrlKey && event.key === 'z' && !event.shiftKey) {
event.preventDefault();
if (formStore.canUndo) {
formStore.undo();
}
}
// Redo: Ctrl+Y or Ctrl+Shift+Z
if ((event.ctrlKey && event.key === 'y') ||
(event.ctrlKey && event.shiftKey && event.key === 'z')) {
event.preventDefault();
if (formStore.currentHistoryIndex < formStore.actionHistory.length - 1) {
formStore.redo();
}
}
};
// Navigation guards
// Add navigation guard
onBeforeRouteLeave((to, from, next) => {
// If navigation was already confirmed or there are no unsaved changes, proceed
if (navigationConfirmed.value || !formStore.hasUnsavedChanges) {
next();
return;
}
// Otherwise show the confirmation modal
showUnsavedChangesModal.value = true;
pendingNavigation.value = () => {
navigationConfirmed.value = true;
next();
};
next(false);
});
// Navigation handlers
const cancelNavigation = () => {
showUnsavedChangesModal.value = false;
pendingNavigation.value = null;
navigationTarget.value = null;
navigationConfirmed.value = false;
};
const confirmNavigation = () => {
showUnsavedChangesModal.value = false;
if (pendingNavigation.value) {
pendingNavigation.value();
} else if (navigationTarget.value) {
navigationConfirmed.value = true; // Mark as confirmed before navigating
router.push(navigationTarget.value);
}
};
// Handler methods
const handleAddComponent = (component) => {
formStore.addComponent(component);
};
const handleSelectComponent = (component) => {
if (!component || !component.id) return;
formStore.selectComponent(component.id);
};
const handleUpdateComponent = (updatedComponent) => {
if (!updatedComponent || !updatedComponent.id) return;
formStore.updateComponent(updatedComponent);
};
const handleMoveComponent = ({ oldIndex, newIndex }) => {
formStore.moveComponent({ oldIndex, newIndex });
};
const handleDeleteComponent = (id) => {
if (!id) return;
formStore.deleteComponent(id);
};
const handleDragOver = (event) => {
// Always prevent default to enable drop
event.preventDefault();
// Set visual feedback
formStore.setDraggingOver(true);
// Set the drop effect
event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event) => {
// Check if we're really leaving the drop zone or just entering a child element
const dropZone = document.getElementById('form-drop-zone');
const relatedTarget = event.relatedTarget;
if (!dropZone || !dropZone.contains(relatedTarget)) {
formStore.setDraggingOver(false);
}
event.preventDefault();
};
const handleDrop = (event) => {
// Always prevent default
event.preventDefault();
event.stopPropagation();
// Reset drag over state
formStore.setDraggingOver(false);
try {
// Get the data from all possible formats
let componentData;
const formats = event.dataTransfer.types;
// Log available formats for debugging
console.log('Available formats:', formats);
// Try text/plain first (most compatible format)
if (formats.includes('text/plain')) {
const plainText = event.dataTransfer.getData('text/plain');
if (plainText) {
try {
componentData = JSON.parse(plainText);
} catch (e) {
console.warn('Failed to parse text/plain data', e);
}
}
}
// If that failed, try the custom format
if (!componentData && formats.includes('component')) {
const componentText = event.dataTransfer.getData('component');
if (componentText) {
try {
componentData = JSON.parse(componentText);
} catch (e) {
console.warn('Failed to parse component data', e);
}
}
}
// Safari fallback: if no data was received via dataTransfer, check global variable
if (!componentData && window.__draggedComponentData) {
console.log('Using fallback drag data for Safari');
componentData = window.__draggedComponentData;
// Clear the fallback data
window.__draggedComponentData = null;
}
// If we have valid component data, add it
if (componentData) {
formStore.addComponent(componentData);
} else {
console.warn('No valid component data found in drop event');
}
} catch (error) {
console.error('Error processing dropped component:', error);
}
};
const handleSave = async () => {
if (!formStore.formName.trim()) {
toast.error("Please enter a form name");
return;
}
if (formStore.formComponents.length === 0) {
toast.error("Please add at least one component to the form");
return;
}
try {
const savedForm = await formStore.saveForm();
toast.success("Form saved successfully");
// Update URL to include the form ID without reloading the page
const newPath = `/form-builder?id=${savedForm.formUUID}`;
window.history.replaceState({}, '', newPath);
} catch (error) {
console.error("Error saving form:", error);
toast.error(`Failed to save form: ${error.message || 'Unknown error'}`);
}
};
const togglePreview = () => {
if (!isPreview.value && formStore.formComponents.length === 0) {
toast.error("Please add at least one component to the form");
return;
}
isPreview.value = !isPreview.value;
};
const handlePreviewSubmit = (formData) => {
console.log("Form submitted:", formData);
toast.success("Form submitted successfully");
};
const saveFormSettings = () => {
// Form settings are automatically saved through the store's reactive updates
showFormSettings.value = false;
toast.success("Form settings saved successfully");
};
// Preview form data for script interactions
const previewFormData = ref({});
// Initialize preview form data with default values
watchEffect(() => {
const newFormData = {};
formStore.formComponents.forEach(component => {
if (component.props.name) {
// Set default values based on component type
switch (component.type) {
case 'checkbox':
newFormData[component.props.name] = [];
break;
case 'number':
case 'range':
newFormData[component.props.name] = component.props.value || 0;
break;
case 'switch':
newFormData[component.props.name] = component.props.value !== undefined ? component.props.value : false;
break;
case 'color':
newFormData[component.props.name] = component.props.value || '#3b82f6';
break;
case 'hidden':
newFormData[component.props.name] = component.props.value || '';
break;
case 'image-preview':
case 'info-display':
case 'button':
// These are display-only components and don't need form data values
break;
case 'repeating-group':
// Initialize with one empty group or use minItems to determine how many initial groups
const initialGroups = [];
const minItems = component.props.minItems || 1;
// Create the specified number of initial groups
for (let i = 0; i < minItems; i++) {
// Create an object with fields from the configuration
const group = {};
if (Array.isArray(component.props.fields)) {
component.props.fields.forEach(field => {
// Initialize each field with appropriate default value
switch (field.type) {
case 'number':
group[field.name] = 0;
break;
case 'checkbox':
group[field.name] = [];
break;
case 'select':
// If there's a default value in the options, use it
const defaultOption = Array.isArray(field.options) && field.options.length > 0
? field.options[0].value
: '';
group[field.name] = field.value || defaultOption || '';
break;
default:
group[field.name] = field.value || '';
}
});
}
initialGroups.push(group);
}
newFormData[component.props.name] = initialGroups;
break;
case 'dynamic-list':
// Initialize with default items from configuration
newFormData[component.props.name] = Array.isArray(component.props.defaultItems)
? [...component.props.defaultItems]
: [];
break;
case 'select':
case 'radio':
// For select and radio, initialize with first option value if available
if (Array.isArray(component.props.options) && component.props.options.length > 0) {
newFormData[component.props.name] = component.props.value || component.props.options[0].value;
} else {
newFormData[component.props.name] = '';
}
break;
case 'date':
case 'time':
case 'datetime-local':
// Initialize with current date/time
newFormData[component.props.name] = component.props.value || '';
break;
case 'file':
// Initialize file inputs as null
newFormData[component.props.name] = null;
break;
default:
// For text, textarea, email, password, url, tel, etc.
newFormData[component.props.name] = component.props.value || '';
}
}
});
previewFormData.value = newFormData;
});
// Handle script-driven field changes
const handleScriptFieldChange = ({ fieldName, value }) => {
previewFormData.value[fieldName] = value;
// Make form data accessible to component previews
formStore.updatePreviewFormData(previewFormData.value);
};
// Handle script-driven field validation
const handleScriptFieldValidate = ({ fieldName }) => {
// Could integrate with FormKit validation here
console.log(`Validating field: ${fieldName}`);
};
// Make form data accessible to component previews
watchEffect(() => {
formStore.updatePreviewFormData(previewFormData.value);
});
const navigateToManage = () => {
// If already confirmed or no unsaved changes, navigate directly
if (navigationConfirmed.value || !formStore.hasUnsavedChanges) {
router.push("/form-builder/manage");
return;
}
// Otherwise show confirmation modal
showUnsavedChangesModal.value = true;
navigationTarget.value = "/form-builder/manage";
};
const handleOptimizeLayout = () => {
formStore.optimizeGridLayout();
};
// Add the new handler function
const handleDragEnter = (event) => {
// Prevent default to allow drop
event.preventDefault();
// Set visual feedback
formStore.setDraggingOver(true);
};
function handleClickOutside(event) {
const dropdown = document.querySelector('.dropdown');
if (dropdown && !dropdown.contains(event.target)) {
showDropdown.value = false;
}
}
// Handle applying a template
const applyFormTemplate = (template) => {
// Confirm if there's already content in the form
if (formStore.formComponents.length > 0) {
if (!confirm("This will replace your current form content. Continue?")) {
return;
}
}
try {
console.log('Applying template:', template.name);
console.log('Template components:', template.components ? template.components.length : 0);
// Set form name if it's a new form or user allows overwrite
if (formStore.formName === 'New Form' || confirm("Update the form name to match the template?")) {
formStore.setFormName(template.name);
}
// Reset the form components
formStore.formComponents = [];
// Add the template components
if (template.components && template.components.length > 0) {
// Process each template component
template.components.forEach((component, index) => {
console.log(`Processing component ${index + 1}:`, component.type);
// Handle special component types with specific properties
let processedDefaultProps = {};
if (component.defaultProps) {
// Clone the default props to avoid reference issues
processedDefaultProps = JSON.parse(JSON.stringify(component.defaultProps));
// Ensure required grid properties are present
if (!processedDefaultProps.width) {
processedDefaultProps.width = '100%';
}
if (!processedDefaultProps.gridColumn) {
processedDefaultProps.gridColumn = 'span 12';
}
// Ensure specific properties for each component type
switch (component.type) {
case 'image-preview':
// Ensure all required image preview properties
processedDefaultProps = {
label: processedDefaultProps.label || 'Image Preview',
name: processedDefaultProps.name || `image_preview_${index + 1}`,
help: processedDefaultProps.help || '',
imageUrl: processedDefaultProps.imageUrl || 'https://placehold.co/600x400',
altText: processedDefaultProps.altText || 'Preview image',
caption: processedDefaultProps.caption || '',
showZoom: processedDefaultProps.showZoom !== undefined ? processedDefaultProps.showZoom : true,
showCaption: processedDefaultProps.showCaption !== undefined ? processedDefaultProps.showCaption : true,
maxWidth: processedDefaultProps.maxWidth || '100%',
height: processedDefaultProps.height || 'auto',
...processedDefaultProps
};
break;
case 'repeating-group':
// Ensure all required repeating group properties
processedDefaultProps = {
label: processedDefaultProps.label || 'Repeating Group',
name: processedDefaultProps.name || `repeating_group_${index + 1}`,
help: processedDefaultProps.help || '',
minItems: processedDefaultProps.minItems !== undefined ? processedDefaultProps.minItems : 1,
maxItems: processedDefaultProps.maxItems !== undefined ? processedDefaultProps.maxItems : 10,
buttonText: processedDefaultProps.buttonText || 'Add Item',
removeText: processedDefaultProps.removeText || 'Remove',
fields: processedDefaultProps.fields || [
{ type: 'text', name: 'field_1', label: 'Field 1', placeholder: 'Enter value' }
],
...processedDefaultProps
};
break;
case 'dynamic-list':
// Ensure all required dynamic list properties
processedDefaultProps = {
label: processedDefaultProps.label || 'Dynamic List',
name: processedDefaultProps.name || `dynamic_list_${index + 1}`,
help: processedDefaultProps.help || '',
placeholder: processedDefaultProps.placeholder || 'Enter item',
buttonText: processedDefaultProps.buttonText || 'Add Item',
minItems: processedDefaultProps.minItems !== undefined ? processedDefaultProps.minItems : 0,
maxItems: processedDefaultProps.maxItems !== undefined ? processedDefaultProps.maxItems : 20,
defaultItems: Array.isArray(processedDefaultProps.defaultItems) ? processedDefaultProps.defaultItems : ['Item 1', 'Item 2'],
...processedDefaultProps
};
break;
case 'info-display':
// Ensure all required info display properties
processedDefaultProps = {
title: processedDefaultProps.title || 'Information',
name: processedDefaultProps.name || `info_display_${index + 1}`,
help: processedDefaultProps.help || '',
layout: processedDefaultProps.layout || 'vertical',
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
{ label: 'Info Item', value: 'Value', key: 'item_1' }
],
...processedDefaultProps
};
break;
case 'file':
// Ensure all required file upload properties
processedDefaultProps = {
label: processedDefaultProps.label || 'File Upload',
name: processedDefaultProps.name || `file_upload_${index + 1}`,
help: processedDefaultProps.help || 'Upload a file',
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedDefaultProps
};
break;
case 'heading':
// Ensure all required heading properties
processedDefaultProps = {
value: processedDefaultProps.value || 'Heading',
level: processedDefaultProps.level || 2,
...processedDefaultProps
};
break;
case 'paragraph':
// Ensure all required paragraph properties
processedDefaultProps = {
value: processedDefaultProps.value || 'Paragraph text',
...processedDefaultProps
};
break;
case 'select':
case 'radio':
case 'checkbox':
// Ensure options array exists
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
processedDefaultProps.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
];
}
// Fall through for other basic properties
default:
// Basic properties for all other component types
if (!processedDefaultProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedDefaultProps.label = component.name || component.type.charAt(0).toUpperCase() + component.type.slice(1);
}
if (!processedDefaultProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedDefaultProps.name = `${component.type}_${index + 1}`;
}
break;
}
}
// Create a component in the expected format for addComponent
const formattedComponent = {
type: component.type,
name: component.name || component.type,
category: getDefaultCategory(component.type),
icon: getDefaultIcon(component.type),
// Use the processed default props
defaultProps: processedDefaultProps
};
console.log('Formatted component:', formattedComponent);
// Add the component to the form
formStore.addComponent(formattedComponent);
});
console.log('Total components added:', formStore.formComponents.length);
} else {
console.warn('No components found in template');
}
// Set script and CSS if available
if (template.script) {
formStore.formCustomScript = template.script;
console.log('Applied custom script');
}
if (template.css) {
formStore.formCustomCSS = template.css;
console.log('Applied custom CSS');
}
// Enable events if the template uses scripts
if (template.hasScript) {
formStore.formEvents = {
onLoad: true,
onFieldChange: true,
onSubmit: template.script && template.script.includes('onSubmit'),
onValidation: template.script && template.script.includes('onValidation')
};
console.log('Enabled form events');
}
// Mark as having unsaved changes
formStore.hasUnsavedChanges = true;
// Show success message
toast.success(`Template "${template.name}" applied successfully`);
} catch (error) {
console.error('Error applying template:', error);
toast.error('Failed to apply template: ' + error.message);
}
};
// Helper function to get default category for component type
const getDefaultCategory = (type) => {
const categories = {
// Basic inputs
'text': 'Basic Inputs',
'textarea': 'Basic Inputs',
'number': 'Basic Inputs',
'email': 'Basic Inputs',
'password': 'Basic Inputs',
'url': 'Basic Inputs',
'tel': 'Basic Inputs',
'hidden': 'Basic Inputs',
'date': 'Basic Inputs',
'time': 'Basic Inputs',
'datetime-local': 'Basic Inputs',
// Selection inputs
'select': 'Selection Inputs',
'checkbox': 'Selection Inputs',
'radio': 'Selection Inputs',
'switch': 'Selection Inputs',
'range': 'Selection Inputs',
'color': 'Selection Inputs',
// Layout elements
'heading': 'Layout',
'paragraph': 'Layout',
'divider': 'Layout',
'info-display': 'Layout',
// Advanced inputs
'file': 'Advanced',
'button': 'Advanced',
'image-preview': 'Advanced',
'repeating-group': 'Advanced',
'dynamic-list': 'Advanced'
};
return categories[type] || 'Basic Inputs';
};
// Helper function to get default icon for component type
const getDefaultIcon = (type) => {
const icons = {
// Basic inputs
'text': 'material-symbols:text-fields',
'textarea': 'material-symbols:article-outline',
'number': 'material-symbols:counter-1-outline',
'email': 'material-symbols:mail-outline',
'password': 'material-symbols:password',
'url': 'material-symbols:link',
'tel': 'heroicons:device-phone-mobile',
'hidden': 'material-symbols:visibility-off',
'date': 'material-symbols:calendar-month-outline',
'time': 'material-symbols:schedule-outline',
'datetime-local': 'material-symbols:event-outline',
// Selection inputs
'select': 'material-symbols:arrow-drop-down-circle-outline',
'checkbox': 'material-symbols:check-box-outline',
'radio': 'material-symbols:radio-button-checked-outline',
'switch': 'material-symbols:toggle-on',
'range': 'material-symbols:linear-scale',
'color': 'material-symbols:palette',
// Layout elements
'heading': 'material-symbols:title',
'paragraph': 'material-symbols:text-snippet-outline',
'divider': 'material-symbols:horizontal-rule',
'info-display': 'material-symbols:info-outline',
// Advanced inputs
'file': 'material-symbols:upload-file-outline',
'button': 'material-symbols:smart-button',
'image-preview': 'material-symbols:image-outline',
'repeating-group': 'material-symbols:playlist-add-outline',
'dynamic-list': 'material-symbols:format-list-bulleted'
};
return icons[type] || 'material-symbols:add';
};
// Component icon and name helper functions (available to template)
const getComponentIcon = (type) => {
const icons = {
text: 'heroicons:document-text',
textarea: 'heroicons:document-text',
number: 'heroicons:hashtag',
email: 'heroicons:envelope',
password: 'heroicons:key',
url: 'heroicons:link',
tel: 'heroicons:device-phone-mobile',
mask: 'heroicons:pencil-square',
hidden: 'heroicons:eye-slash',
select: 'heroicons:chevron-down',
checkbox: 'heroicons:check-badge',
radio: 'heroicons:radio',
switch: 'material-symbols:toggle-on',
date: 'heroicons:calendar-days',
time: 'heroicons:clock',
'datetime-local': 'heroicons:calendar',
range: 'heroicons:adjustments-horizontal',
color: 'heroicons:swatch',
file: 'heroicons:document-arrow-up',
otp: 'heroicons:key',
dropzone: 'heroicons:cloud-arrow-up',
button: 'heroicons:cursor-arrow-rays',
heading: 'heroicons:h1',
paragraph: 'heroicons:document-text',
divider: 'heroicons:minus',
'info-display': 'heroicons:information-circle'
}
return icons[type] || 'heroicons:square-3-stack-3d'
}
const getComponentTypeName = (type) => {
const names = {
text: 'Text Field',
textarea: 'Text Area',
number: 'Number Field',
email: 'Email Field',
password: 'Password Field',
url: 'URL Field',
tel: 'Phone Field',
mask: 'Masked Input',
hidden: 'Hidden Field',
select: 'Dropdown Menu',
checkbox: 'Checkboxes',
radio: 'Radio Buttons',
switch: 'Switch Toggle',
date: 'Date Picker',
time: 'Time Picker',
'datetime-local': 'Date & Time',
range: 'Range Slider',
color: 'Color Picker',
file: 'File Upload',
otp: 'Verification Code',
dropzone: 'File Drop Zone',
button: 'Action Button',
heading: 'Heading Text',
paragraph: 'Paragraph Text',
divider: 'Divider Line',
'info-display': 'Information Display'
}
return names[type] || 'Form Field'
}
const getComponentTypeShort = (type) => {
const shortNames = {
text: 'TXT',
textarea: 'TXT',
number: 'NUM',
email: 'EML',
password: 'PWD',
url: 'URL',
tel: 'TEL',
mask: 'MSK',
hidden: 'HID',
select: 'SEL',
checkbox: 'CHK',
radio: 'RAD',
switch: 'SWT',
date: 'DTE',
time: 'TME',
'datetime-local': 'DTM',
range: 'RNG',
color: 'CLR',
file: 'FIL',
otp: 'OTP',
dropzone: 'DRP',
button: 'BTN',
heading: 'H' + (formStore.selectedComponent?.props?.level || '2'),
paragraph: 'TXT',
divider: 'DIV',
'info-display': 'INF'
}
return shortNames[type] || 'FLD'
}
// Quick settings panel state and functions
const quickSettings = ref({});
// Compact width options for the sidebar
const compactWidthOptions = [
{ name: 'S', value: 25, gridColumns: 3, description: 'Small (25%)' },
{ name: 'M', value: 50, gridColumns: 6, description: 'Medium (50%)' },
{ name: 'L', value: 75, gridColumns: 9, description: 'Large (75%)' },
{ name: 'XL', value: 100, gridColumns: 12, description: 'Extra Large (100%)' }
];
// Watch for selected component changes to update quick settings
watch(() => formStore.selectedComponent, (newComponent) => {
if (newComponent) {
// Auto-open panel when component is selected
showFieldSettingsPanel.value = true;
// Initialize quick settings
quickSettings.value = {
label: newComponent.props.label || '',
name: newComponent.props.name || '',
placeholder: newComponent.props.placeholder || '',
required: newComponent.props.validation?.includes('required') || false
};
}
}, { immediate: true });
// Toggle panel function
const toggleFieldSettingsPanel = () => {
showFieldSettingsPanel.value = !showFieldSettingsPanel.value;
};
// Show/hide specific quick fields based on component type
const showQuickField = (fieldName) => {
if (!formStore.selectedComponent) return false;
const fieldConfig = {
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button'],
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display'],
required: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'date', 'time', 'datetime-local', 'file', 'otp', 'dropzone']
};
return fieldConfig[fieldName]?.includes(formStore.selectedComponent.type) || false;
};
// Get placeholder text for different fields
const getPlaceholder = (fieldName) => {
if (!formStore.selectedComponent) return '';
const placeholders = {
label: `Enter ${formStore.selectedComponent.type} label`,
name: `${formStore.selectedComponent.type}_field`,
placeholder: 'Enter placeholder text...'
};
return placeholders[fieldName] || '';
};
// Update quick setting and sync with component
const updateQuickSetting = (key, value, gridColumns = null) => {
if (!formStore.selectedComponent) return;
const updatedComponent = { ...formStore.selectedComponent };
if (key === 'width') {
updatedComponent.props.width = `${value}%`;
if (gridColumns) {
updatedComponent.props.gridColumn = `span ${gridColumns}`;
}
} else {
updatedComponent.props[key] = value;
}
formStore.updateComponent(updatedComponent);
quickSettings.value[key] = value;
};
// Update validation settings
const updateQuickValidation = (rule, enabled) => {
if (!formStore.selectedComponent) return;
const updatedComponent = { ...formStore.selectedComponent };
let current = updatedComponent.props.validation || '';
const rules = current.split('|').filter(r => r && r !== rule);
if (enabled) {
rules.push(rule);
}
updatedComponent.props.validation = rules.join('|');
formStore.updateComponent(updatedComponent);
quickSettings.value.required = enabled;
};
// Get current component width percentage
const getComponentWidthPercent = () => {
if (!formStore.selectedComponent) return 100;
const width = formStore.selectedComponent.props.width || '100%';
return parseInt(width.replace('%', ''));
};
// Open full settings modal
const openFullSettingsModal = () => {
showFieldSettings.value = true;
};
// Duplicate component
const duplicateComponent = () => {
if (!formStore.selectedComponent) return;
const originalComponent = formStore.selectedComponent;
const duplicatedProps = { ...originalComponent.props };
// Update name to avoid conflicts
if (duplicatedProps.name) {
duplicatedProps.name = duplicatedProps.name + '_copy';
}
// Update label to indicate it's a copy
if (duplicatedProps.label) {
duplicatedProps.label = duplicatedProps.label + ' (Copy)';
}
const componentToDuplicate = {
type: originalComponent.type,
name: getComponentTypeName(originalComponent.type) + ' (Copy)',
category: getDefaultCategory(originalComponent.type),
icon: getComponentIcon(originalComponent.type),
defaultProps: duplicatedProps
};
formStore.addComponent(componentToDuplicate);
toast.success('Component duplicated successfully');
};
// Delete component
const deleteComponent = () => {
if (!formStore.selectedComponent) return;
if (confirm('Are you sure you want to delete this component?')) {
formStore.deleteComponent(formStore.selectedComponent.id);
toast.success('Component deleted successfully');
}
};
</script>
<style scoped>
.form-name-input {
width: 100%;
max-width: 400px;
}
.form-name-input :deep(.formkit-inner) {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.form-name-input :deep(.formkit-inner:focus-within) {
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.form-name-input :deep(input::placeholder) {
color: rgba(255, 255, 255, 0.6);
}
.form-name-input :deep(.formkit-message) {
color: rgba(255, 200, 200, 0.9);
font-size: 0.7rem;
position: absolute;
}
.grid-preview-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-flow: row dense;
column-gap: 16px;
row-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.form-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.form-submit {
margin-top: 2rem;
width: 100%;
}
/* Drop zone styling */
#form-drop-zone {
transition: all 0.2s ease;
}
#form-drop-zone:not(.border-solid) {
cursor: default;
}
#form-drop-zone.border-blue-400 {
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.3);
}
/* Preview mode styling */
.preview-mode {
max-width: 1200px;
margin: 0 auto;
background-color: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border-radius: 8px !important;
border-color: #e5e7eb !important;
}
/* Floating Action Button */
.field-settings-fab {
@apply w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.field-settings-fab:hover {
transform: scale(1.05);
}
/* Instruction Tooltip */
.instruction-tooltip {
@apply bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 relative;
animation: fadeInBounce 0.6s ease-out;
}
.instruction-arrow {
@apply absolute top-full left-1/2 transform -translate-x-1/2;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #f3f4f6;
}
.instruction-arrow::before {
content: '';
position: absolute;
top: -9px;
left: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #d1d5db;
}
@keyframes fadeInBounce {
0% {
opacity: 0;
transform: translateY(-10px);
}
60% {
opacity: 1;
transform: translateY(5px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Enhanced hover states for form components */
.hover\:bg-blue-25:hover {
background-color: rgba(59, 130, 246, 0.04);
}
/* FAB Tooltip */
.fab-tooltip {
@apply absolute bottom-full right-0 mb-2 opacity-0 pointer-events-none transition-opacity duration-300;
}
.tooltip-arrow {
@apply absolute top-full right-4;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid #1f2937;
}
/* Enhanced component selection feedback */
.form-component {
position: relative;
}
.form-component::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, #3b82f6, #1d4ed8);
border-radius: 8px;
opacity: 0;
z-index: -1;
transition: opacity 0.3s ease;
}
.form-component.ring-2::before {
opacity: 0.1;
}
/* Field Settings Panel */
.field-settings-panel {
@apply relative bg-white border-l border-gray-200 flex flex-col overflow-hidden transition-all duration-300 ease-in-out;
}
.panel-expanded {
@apply w-80;
}
.panel-collapsed {
@apply w-12;
}
.panel-header-toggle-btn {
@apply text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100;
}
.collapsed-toggle-btn {
@apply w-8 h-8 bg-white border border-gray-200 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50 transition-all duration-200 shadow-sm;
}
.panel-content {
@apply flex-1 flex flex-col overflow-hidden;
}
.panel-header {
@apply px-4 py-3 border-b border-gray-200 bg-gray-50;
}
.panel-body {
@apply flex-1 overflow-y-auto;
}
.empty-state {
@apply h-full flex items-center justify-center;
}
.component-settings {
@apply p-4 space-y-4;
}
.component-header {
@apply pb-3 border-b border-gray-100;
}
.component-icon {
@apply w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0;
}
.quick-actions {
@apply grid grid-cols-1 gap-2;
}
.action-btn {
@apply flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200;
}
.action-btn.primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.action-btn.secondary {
@apply bg-gray-100 text-gray-700 hover:bg-gray-200;
}
.action-btn.danger {
@apply bg-red-100 text-red-700 hover:bg-red-200;
}
.quick-settings {
@apply space-y-3;
}
.settings-section-title {
@apply text-xs font-semibold text-gray-600 uppercase tracking-wide mb-3;
}
.setting-item {
@apply space-y-1.5;
}
.setting-label {
@apply block text-xs font-medium text-gray-700;
}
.width-selector-compact {
@apply flex space-x-1;
}
.width-btn {
@apply flex-1 px-2 py-1.5 text-xs font-medium text-center rounded-md border border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50 transition-colors duration-200;
}
.width-btn.active {
@apply border-blue-500 bg-blue-50 text-blue-700 ring-1 ring-blue-200;
}
.setting-toggle {
@apply flex items-center cursor-pointer;
}
.toggle-input {
@apply sr-only;
}
.toggle-slider {
@apply relative inline-block w-8 h-4 mr-2 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out;
}
.setting-toggle input:checked + .toggle-slider {
@apply bg-blue-600;
}
.toggle-slider::before {
@apply absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform duration-200 ease-in-out;
content: '';
}
.setting-toggle input:checked + .toggle-slider::before {
@apply transform translate-x-4;
}
.toggle-label {
@apply text-xs font-medium text-gray-700;
}
.collapsed-info {
@apply h-full flex flex-col px-2 py-3;
}
.collapsed-header {
@apply mb-4 flex justify-center;
}
.collapsed-content {
@apply text-center w-full flex-1 flex flex-col justify-center items-center;
}
.selected-component-collapsed {
@apply flex flex-col items-center space-y-2;
}
.component-icon-collapsed {
@apply w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center;
}
.component-type-badge {
@apply text-xs font-bold text-blue-700 bg-blue-100 px-1.5 py-1 rounded whitespace-nowrap;
writing-mode: vertical-rl;
text-orientation: upright;
}
.no-selection-collapsed {
@apply flex flex-col items-center space-y-2;
}
.writing-mode-vertical {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
}
</style>