docs: add comprehensive frontend landing page plan and download design skills
Add detailed landing page development plan in docs/frontend_landing_plan.md: - Complete landing page structure (Hero, Problem/Solution, Features, Demo, CTA) - Design guidelines from downloaded skills (typography, color, motion, composition) - Security considerations (XSS prevention, input sanitization, CSP) - Performance targets (LCP <2.5s, bundle <150KB, Lighthouse >90) - Responsiveness and accessibility requirements (WCAG 2.1 AA) - Success KPIs and monitoring setup - 3-week development timeline with daily tasks - Definition of Done checklist Download 10+ frontend/UI/UX skills via universal-skills-manager: - frontend-ui-ux: UI/UX design without mockups - frontend-design-guidelines: Production-grade interface guidelines - frontend-developer: React best practices (40+ rules) - frontend-engineer: Next.js 14 App Router patterns - ui-ux-master: Comprehensive design systems and accessibility - ui-ux-systems-designer: Information architecture and interaction - ui-ux-design-user-experience: Platform-specific guidelines - Plus additional reference materials and validation scripts Configure universal-skills MCP with SkillsMP API key for curated skill access. Safety first: All skills validated before installation, no project code modified. Refs: Universal Skills Manager (github:jacob-bd/universal-skills-manager) Next: Begin Sprint 3 landing page development
This commit is contained in:
129
.opencode/skills/templates/htmx-template.html
Normal file
129
.opencode/skills/templates/htmx-template.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Header Section -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4 md:p-6">
|
||||
<h1 class="card-title text-2xl">Page Title</h1>
|
||||
<p class="text-sm opacity-70">Page description</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTMX Content Section with Loading State -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4 md:p-6">
|
||||
<h2 class="card-title text-xl mb-4">Section Title</h2>
|
||||
|
||||
<!-- HTMX Dynamic Content -->
|
||||
<div id="dynamic-content"
|
||||
hx-get="/api/v1/endpoint/html"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="min-h-[100px] flex items-center justify-center">
|
||||
<!-- Loading spinner (shown until HTMX loads content) -->
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Section -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4 md:p-6">
|
||||
<h2 class="card-title text-xl mb-4">💡 Quick Actions</h2>
|
||||
|
||||
<!-- Desktop: Grid Layout (visible on >= md) -->
|
||||
<div class="hidden md:grid md:grid-cols-3 gap-3">
|
||||
<button onclick="openCreateModal()"
|
||||
class="btn btn-success gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<button onclick="openEditModal()"
|
||||
class="btn btn-warning gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<a href="/another-page" class="btn btn-accent gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
Go to Page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Mini Cards Row (visible on < md) -->
|
||||
<div class="grid grid-cols-3 gap-2 md:hidden">
|
||||
<button onclick="openCreateModal()"
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-xl bg-success text-success-content hover:brightness-95 active:scale-95 transition-all shadow-md">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Create</span>
|
||||
</button>
|
||||
|
||||
<button onclick="openEditModal()"
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-xl bg-warning text-warning-content hover:brightness-95 active:scale-95 transition-all shadow-md">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Edit</span>
|
||||
</button>
|
||||
|
||||
<a href="/another-page"
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-xl bg-accent text-accent-content hover:brightness-95 active:scale-95 transition-all shadow-md">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Go</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Section (Mobile-only) -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body py-4">
|
||||
<!-- Header with expand indicator (clickable on mobile only) -->
|
||||
<div class="flex items-center justify-between cursor-pointer md:cursor-default"
|
||||
onclick="toggleSection()" id="section-header">
|
||||
<h2 class="card-title text-xl mb-2">📊 Collapsible Section</h2>
|
||||
<span class="expand-indicator badge badge-outline badge-sm opacity-60 hover:opacity-100 cursor-pointer transition-opacity md:hidden">
|
||||
<span class="indicator-text">+</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible content (hidden on mobile by default) -->
|
||||
<div id="section-content"
|
||||
class="min-h-[100px] hidden md:block">
|
||||
<p>This content is collapsible on mobile devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for collapsible section -->
|
||||
<script>
|
||||
function toggleSection() {
|
||||
// Only toggle on mobile (< md breakpoint)
|
||||
if (window.innerWidth >= 768) return;
|
||||
|
||||
const content = document.getElementById('section-content');
|
||||
const indicator = document.querySelector('#section-header .indicator-text');
|
||||
|
||||
if (content.classList.contains('hidden')) {
|
||||
content.classList.remove('hidden');
|
||||
indicator.textContent = '−';
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
indicator.textContent = '+';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
401
.opencode/skills/templates/js-module.js
Normal file
401
.opencode/skills/templates/js-module.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* ES6 Module Template for Family Budget Project
|
||||
*
|
||||
* Features:
|
||||
* - ES6 class-based structure
|
||||
* - Event handling
|
||||
* - API integration with Fetch API
|
||||
* - LocalStorage for offline support
|
||||
* - Error handling
|
||||
* - Toast notifications
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this template to frontend/web/static/js/<module-name>.js
|
||||
* 2. Update class name, API endpoints, and methods
|
||||
* 3. Import in base.html or specific page:
|
||||
* <script type="module" src="/static/js/<module-name>.js"></script>
|
||||
* 4. Initialize:
|
||||
* <script type="module">
|
||||
* import { ResourceManager } from '/static/js/resource-manager.js';
|
||||
* window.resourceManager = new ResourceManager();
|
||||
* </script>
|
||||
*/
|
||||
|
||||
/**
|
||||
* ResourceManager Class
|
||||
* Manages CRUD operations for a resource
|
||||
*/
|
||||
export class ResourceManager {
|
||||
constructor(options = {}) {
|
||||
// Configuration
|
||||
this.apiEndpoint = options.apiEndpoint || '/api/v1/resources';
|
||||
this.cacheKey = options.cacheKey || 'resources_cache';
|
||||
this.cacheDuration = options.cacheDuration || 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// State
|
||||
this.resources = [];
|
||||
this.loading = false;
|
||||
this.lastFetch = 0;
|
||||
|
||||
// Event handlers storage
|
||||
this.eventHandlers = {
|
||||
onCreate: [],
|
||||
onUpdate: [],
|
||||
onDelete: [],
|
||||
onError: []
|
||||
};
|
||||
|
||||
// Initialize
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
*/
|
||||
async init() {
|
||||
console.log('[ResourceManager] Initializing...');
|
||||
|
||||
// Load from cache first (for offline support)
|
||||
this.loadFromCache();
|
||||
|
||||
// Fetch fresh data from API
|
||||
await this.fetchResources();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
console.log('[ResourceManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Example: Listen for custom events
|
||||
document.addEventListener('resource:refresh', () => {
|
||||
this.fetchResources(true); // Force refresh
|
||||
});
|
||||
|
||||
// Example: Online/offline detection
|
||||
window.addEventListener('online', () => {
|
||||
console.log('[ResourceManager] Back online, syncing...');
|
||||
this.syncOfflineChanges();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('[ResourceManager] Offline mode activated');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch resources from API
|
||||
* @param {boolean} force - Force refresh, ignore cache
|
||||
*/
|
||||
async fetchResources(force = false) {
|
||||
// Check cache freshness
|
||||
const cacheAge = Date.now() - this.lastFetch;
|
||||
if (!force && cacheAge < this.cacheDuration && this.resources.length > 0) {
|
||||
console.log('[ResourceManager] Using cached data');
|
||||
return this.resources;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin' // Include cookies (JWT)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.resources = data;
|
||||
this.lastFetch = Date.now();
|
||||
|
||||
// Save to cache
|
||||
this.saveToCache();
|
||||
|
||||
console.log(`[ResourceManager] Fetched ${this.resources.length} resources`);
|
||||
return this.resources;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ResourceManager] Fetch error:', error);
|
||||
this.triggerEvent('onError', error);
|
||||
|
||||
// Fallback to cache on error
|
||||
if (this.resources.length === 0) {
|
||||
this.loadFromCache();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new resource
|
||||
* @param {Object} data - Resource data
|
||||
*/
|
||||
async createResource(data) {
|
||||
try {
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create resource');
|
||||
}
|
||||
|
||||
const newResource = await response.json();
|
||||
this.resources.push(newResource);
|
||||
this.saveToCache();
|
||||
|
||||
// Trigger event
|
||||
this.triggerEvent('onCreate', newResource);
|
||||
|
||||
console.log('[ResourceManager] Resource created:', newResource.id);
|
||||
return newResource;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ResourceManager] Create error:', error);
|
||||
this.triggerEvent('onError', error);
|
||||
|
||||
// Offline mode: queue for sync
|
||||
if (!navigator.onLine) {
|
||||
this.queueOfflineOperation('CREATE', data);
|
||||
throw new Error('Offline: Operation queued for sync');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing resource
|
||||
* @param {number} id - Resource ID
|
||||
* @param {Object} data - Updated data
|
||||
*/
|
||||
async updateResource(id, data) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiEndpoint}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to update resource');
|
||||
}
|
||||
|
||||
const updatedResource = await response.json();
|
||||
|
||||
// Update in local array
|
||||
const index = this.resources.findIndex(r => r.id === id);
|
||||
if (index !== -1) {
|
||||
this.resources[index] = updatedResource;
|
||||
this.saveToCache();
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
this.triggerEvent('onUpdate', updatedResource);
|
||||
|
||||
console.log('[ResourceManager] Resource updated:', id);
|
||||
return updatedResource;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ResourceManager] Update error:', error);
|
||||
this.triggerEvent('onError', error);
|
||||
|
||||
// Offline mode: queue for sync
|
||||
if (!navigator.onLine) {
|
||||
this.queueOfflineOperation('UPDATE', { id, ...data });
|
||||
throw new Error('Offline: Operation queued for sync');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete resource
|
||||
* @param {number} id - Resource ID
|
||||
*/
|
||||
async deleteResource(id) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiEndpoint}/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete resource');
|
||||
}
|
||||
|
||||
// Remove from local array
|
||||
this.resources = this.resources.filter(r => r.id !== id);
|
||||
this.saveToCache();
|
||||
|
||||
// Trigger event
|
||||
this.triggerEvent('onDelete', id);
|
||||
|
||||
console.log('[ResourceManager] Resource deleted:', id);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ResourceManager] Delete error:', error);
|
||||
this.triggerEvent('onError', error);
|
||||
|
||||
// Offline mode: queue for sync
|
||||
if (!navigator.onLine) {
|
||||
this.queueOfflineOperation('DELETE', { id });
|
||||
throw new Error('Offline: Operation queued for sync');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource by ID
|
||||
* @param {number} id - Resource ID
|
||||
*/
|
||||
getResourceById(id) {
|
||||
return this.resources.find(r => r.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter resources
|
||||
* @param {Function} predicate - Filter function
|
||||
*/
|
||||
filterResources(predicate) {
|
||||
return this.resources.filter(predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save resources to LocalStorage
|
||||
*/
|
||||
saveToCache() {
|
||||
try {
|
||||
const cacheData = {
|
||||
resources: this.resources,
|
||||
timestamp: this.lastFetch
|
||||
};
|
||||
localStorage.setItem(this.cacheKey, JSON.stringify(cacheData));
|
||||
} catch (error) {
|
||||
console.warn('[ResourceManager] Cache save failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load resources from LocalStorage
|
||||
*/
|
||||
loadFromCache() {
|
||||
try {
|
||||
const cached = localStorage.getItem(this.cacheKey);
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
this.resources = cacheData.resources || [];
|
||||
this.lastFetch = cacheData.timestamp || 0;
|
||||
console.log(`[ResourceManager] Loaded ${this.resources.length} resources from cache`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ResourceManager] Cache load failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue offline operation for sync
|
||||
*/
|
||||
queueOfflineOperation(operation, data) {
|
||||
const queueKey = `${this.cacheKey}_queue`;
|
||||
const queue = JSON.parse(localStorage.getItem(queueKey) || '[]');
|
||||
queue.push({
|
||||
operation,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
localStorage.setItem(queueKey, JSON.stringify(queue));
|
||||
console.log(`[ResourceManager] Queued ${operation} operation for sync`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync offline changes when back online
|
||||
*/
|
||||
async syncOfflineChanges() {
|
||||
const queueKey = `${this.cacheKey}_queue`;
|
||||
const queue = JSON.parse(localStorage.getItem(queueKey) || '[]');
|
||||
|
||||
if (queue.length === 0) return;
|
||||
|
||||
console.log(`[ResourceManager] Syncing ${queue.length} offline operations`);
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
switch (item.operation) {
|
||||
case 'CREATE':
|
||||
await this.createResource(item.data);
|
||||
break;
|
||||
case 'UPDATE':
|
||||
await this.updateResource(item.data.id, item.data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
await this.deleteResource(item.data.id);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceManager] Sync error:', error);
|
||||
// Keep failed operations in queue
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear synced operations
|
||||
localStorage.removeItem(queueKey);
|
||||
console.log('[ResourceManager] Sync completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event handler
|
||||
* @param {string} event - Event name (onCreate, onUpdate, onDelete, onError)
|
||||
* @param {Function} handler - Event handler function
|
||||
*/
|
||||
on(event, handler) {
|
||||
if (this.eventHandlers[event]) {
|
||||
this.eventHandlers[event].push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger event
|
||||
* @param {string} event - Event name
|
||||
* @param {*} data - Event data
|
||||
*/
|
||||
triggerEvent(event, data) {
|
||||
if (this.eventHandlers[event]) {
|
||||
this.eventHandlers[event].forEach(handler => handler(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-export for global usage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.ResourceManager = ResourceManager;
|
||||
}
|
||||
226
.opencode/skills/templates/tailwind-component.html
Normal file
226
.opencode/skills/templates/tailwind-component.html
Normal file
@@ -0,0 +1,226 @@
|
||||
{% macro resource_modal(modal_id='modal_resource', title='Resource') %}
|
||||
<!--
|
||||
DaisyUI Modal Component Template
|
||||
|
||||
Usage:
|
||||
1. Import in your template:
|
||||
{% from "components/modal_resource.html" import resource_modal %}
|
||||
|
||||
2. Call the macro:
|
||||
{{ resource_modal(modal_id='modal_create_resource', title='Create Resource') }}
|
||||
|
||||
3. Open modal from JavaScript:
|
||||
document.getElementById('modal_create_resource').showModal();
|
||||
|
||||
Features:
|
||||
- Responsive width (max-w-2xl on desktop, full width on mobile)
|
||||
- Max height with overflow (90vh)
|
||||
- Auto-centering (my-auto)
|
||||
- DaisyUI styled form controls
|
||||
- HTMX-ready form handling
|
||||
- Mobile-optimized button sizes
|
||||
- Tailwind CSS utility classes
|
||||
-->
|
||||
<dialog id="{{ modal_id }}" class="modal">
|
||||
<div class="modal-box w-full max-w-2xl p-4 max-h-[90vh] overflow-y-auto my-auto">
|
||||
<!-- Modal Header -->
|
||||
<h3 class="font-bold text-lg mb-3">➕ {{ title }}</h3>
|
||||
|
||||
<!-- Modal Form -->
|
||||
<form id="form_{{ modal_id }}" class="space-y-2">
|
||||
<!-- Text Input Example -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Name *</span>
|
||||
</label>
|
||||
<input type="text" name="name" required
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Enter name"
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Select Dropdown Example -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Category *</span>
|
||||
</label>
|
||||
<select name="category_id" required class="select select-bordered">
|
||||
<option value="">-- Select category --</option>
|
||||
<!-- Options populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Radio Button Group Example (DaisyUI buttons) -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-semibold">Type *</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="btn btn-sm btn-outline btn-error resource-type-btn btn-active" data-type="type1">
|
||||
<input type="radio" name="type" value="type1" class="hidden" checked />
|
||||
Type 1
|
||||
</label>
|
||||
<label class="btn btn-sm btn-outline btn-success resource-type-btn" data-type="type2">
|
||||
<input type="radio" name="type" value="type2" class="hidden" />
|
||||
Type 2
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Number Input Example -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Amount *</span>
|
||||
</label>
|
||||
<input type="number" name="amount" step="1" min="0" required
|
||||
class="input input-bordered" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<!-- Date Input Example with Quick Buttons -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Date *</span>
|
||||
</label>
|
||||
<!-- Quick date buttons (mobile-optimized) -->
|
||||
<div class="flex gap-1 mb-1">
|
||||
<button type="button" class="btn btn-xs btn-outline flex-1" onclick="setDate(0)">Today</button>
|
||||
<button type="button" class="btn btn-xs btn-outline flex-1" onclick="setDate(-1)">Yesterday</button>
|
||||
<button type="button" class="btn btn-xs btn-outline flex-1" onclick="setDate(-2)">2 days ago</button>
|
||||
</div>
|
||||
<input type="text" name="date" required
|
||||
class="input input-bordered w-full date-input"
|
||||
placeholder="DD.MM.YYYY"
|
||||
maxlength="10"
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Textarea Example -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" class="textarea textarea-bordered"
|
||||
placeholder="Optional comment" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox Example -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" name="is_active" class="checkbox checkbox-primary" checked />
|
||||
<span class="label-text">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<div class="modal-action mt-3">
|
||||
<!-- Cancel Button -->
|
||||
<button type="button" onclick="{{ modal_id }}.close()" class="btn btn-sm btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<!-- Save Button (with offline mode support) -->
|
||||
<button type="button" class="btn btn-sm save-btn"
|
||||
data-form-id="form_{{ modal_id }}"
|
||||
data-modal-id="{{ modal_id }}"
|
||||
onclick="saveResource(this)"
|
||||
title="Save">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal Backdrop (click to close) -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- CSS for type buttons toggle (add to global CSS) -->
|
||||
<style>
|
||||
.resource-type-btn {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.resource-type-btn.btn-active {
|
||||
opacity: 1;
|
||||
}
|
||||
.resource-type-btn:not(.btn-active) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- JavaScript Example (add to global JS or page-specific script) -->
|
||||
<script>
|
||||
// Toggle radio button styles
|
||||
document.querySelectorAll('.resource-type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Remove btn-active from all buttons
|
||||
document.querySelectorAll('.resource-type-btn').forEach(b => {
|
||||
b.classList.remove('btn-active');
|
||||
});
|
||||
// Add btn-active to clicked button
|
||||
this.classList.add('btn-active');
|
||||
// Check the radio input
|
||||
this.querySelector('input[type="radio"]').checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Quick date setter
|
||||
function setDate(daysOffset) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysOffset);
|
||||
const dateStr = date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
const input = document.querySelector('#{{ modal_id }} .date-input');
|
||||
if (input) input.value = dateStr;
|
||||
}
|
||||
|
||||
// Save resource (HTMX or Fetch API)
|
||||
async function saveResource(button) {
|
||||
const formId = button.dataset.formId;
|
||||
const modalId = button.dataset.modalId;
|
||||
const form = document.getElementById(formId);
|
||||
const modal = document.getElementById(modalId);
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
// Example: POST to API endpoint
|
||||
const response = await fetch('/api/v1/resources', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
modal.close();
|
||||
// Refresh data via HTMX
|
||||
htmx.trigger('#dynamic-content', 'refresh');
|
||||
// Show success toast
|
||||
showToast('Resource created successfully', 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || 'Failed to create resource', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
showToast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user