feat: add shared Jinja macros for reusable UI components

Add comprehensive macro library in app/templates/shared/macros/:

- pagination.html: pagination(), pagination_simple()
- alerts.html: loading_state(), error_state(), alert(), toast()
- badges.html: badge(), status_badge(), role_badge(), severity_badge()
- buttons.html: btn(), btn_primary(), btn_danger(), action_button()
- forms.html: form_input(), form_select(), form_textarea(), form_toggle()
- tables.html: table_wrapper(), table_header(), table_empty_state()
- cards.html: stat_card(), card(), info_card(), filter_card()
- headers.html: page_header(), section_header(), breadcrumbs()

These macros standardize TailAdmin styling with Alpine.js integration
and dark mode support, reducing code duplication across templates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-06 18:34:59 +01:00
parent 3520bcb069
commit 64ab9031d2
8 changed files with 1716 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
{#
Alert & Loading State Macros
============================
Reusable components for alerts, loading states, and notifications.
Usage:
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert %}
{{ loading_state('Loading vendors...') }}
{{ error_state('Error loading data', 'error') }}
{{ alert('success', 'Success!', 'Your changes have been saved.') }}
#}
{#
Loading State
=============
Shows a centered loading spinner with message.
Parameters:
- message: Text to display below the spinner (default: 'Loading...')
- show_condition: Alpine.js condition for x-show (default: 'loading')
#}
{% macro loading_state(message='Loading...', show_condition='loading') %}
<div x-show="{{ show_condition }}" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
{% endmacro %}
{#
Error State
===========
Shows an error message box with icon.
Parameters:
- title: Error title (default: 'Error')
- error_var: Alpine.js variable containing error message (default: 'error')
- show_condition: Alpine.js condition for x-show (default: 'error && !loading')
#}
{% macro error_state(title='Error', error_var='error', show_condition='error && !loading') %}
<div x-show="{{ show_condition }}" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">{{ title }}</p>
<p class="text-sm" x-text="{{ error_var }}"></p>
</div>
</div>
{% endmacro %}
{#
Alert
=====
A versatile alert component for different message types.
Parameters:
- type: 'success' | 'warning' | 'error' | 'info' (default: 'info')
- title: Alert title
- message: Alert message (can be Alpine.js expression with x-text)
- dismissible: Whether the alert can be dismissed (default: false)
- show_condition: Alpine.js condition for x-show (default: 'true')
- icon: Override the default icon (optional)
#}
{% macro alert(type='info', title='', message='', dismissible=false, show_condition='true', icon=none) %}
{% set colors = {
'success': 'bg-green-100 border-green-400 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-200',
'warning': 'bg-yellow-100 border-yellow-400 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200',
'error': 'bg-red-100 border-red-400 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-200',
'info': 'bg-blue-100 border-blue-400 text-blue-700 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-200'
} %}
{% set icons = {
'success': 'check-circle',
'warning': 'exclamation',
'error': 'exclamation-circle',
'info': 'information-circle'
} %}
{% set alert_icon = icon if icon else icons[type] %}
<div x-show="{{ show_condition }}" class="mb-4 p-4 border rounded-lg flex items-start {{ colors[type] }}" role="alert">
<span x-html="$icon('{{ alert_icon }}', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div class="flex-1">
{% if title %}
<p class="font-semibold">{{ title }}</p>
{% endif %}
{% if message %}
<p class="text-sm">{{ message }}</p>
{% endif %}
</div>
{% if dismissible %}
<button @click="$el.parentElement.remove()" class="ml-4 hover:opacity-75 focus:outline-none" aria-label="Dismiss">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
{% endif %}
</div>
{% endmacro %}
{#
Alert with Alpine.js binding
============================
Alert where the message comes from an Alpine.js variable.
Parameters:
- type: 'success' | 'warning' | 'error' | 'info' (default: 'info')
- title: Alert title
- message_var: Alpine.js variable for the message
- show_condition: Alpine.js condition for x-show (default: 'true')
#}
{% macro alert_dynamic(type='info', title='', message_var='message', show_condition='true') %}
{% set colors = {
'success': 'bg-green-100 border-green-400 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-200',
'warning': 'bg-yellow-100 border-yellow-400 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200',
'error': 'bg-red-100 border-red-400 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-200',
'info': 'bg-blue-100 border-blue-400 text-blue-700 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-200'
} %}
{% set icons = {
'success': 'check-circle',
'warning': 'exclamation',
'error': 'exclamation-circle',
'info': 'information-circle'
} %}
<div x-show="{{ show_condition }}" class="mb-4 p-4 border rounded-lg flex items-start {{ colors[type] }}" role="alert">
<span x-html="$icon('{{ icons[type] }}', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div class="flex-1">
{% if title %}
<p class="font-semibold">{{ title }}</p>
{% endif %}
<p class="text-sm" x-text="{{ message_var }}"></p>
</div>
</div>
{% endmacro %}
{#
Toast Notification
==================
A toast notification that appears and auto-dismisses.
Parameters:
- type: 'success' | 'warning' | 'error' | 'info' (default: 'success')
- message_var: Alpine.js variable for the message
- show_var: Alpine.js variable to control visibility
- duration: Auto-dismiss duration in ms (default: 3000, 0 to disable)
#}
{% macro toast(type='success', message_var='toastMessage', show_var='showToast', duration=3000) %}
{% set colors = {
'success': 'bg-green-500',
'warning': 'bg-yellow-500',
'error': 'bg-red-500',
'info': 'bg-blue-500'
} %}
{% set icons = {
'success': 'check-circle',
'warning': 'exclamation',
'error': 'exclamation-circle',
'info': 'information-circle'
} %}
<div
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform translate-y-2"
{% if duration > 0 %}
x-init="$watch('{{ show_var }}', value => { if (value) setTimeout(() => {{ show_var }} = false, {{ duration }}) })"
{% endif %}
class="fixed bottom-4 right-4 z-50 flex items-center px-4 py-3 text-white rounded-lg shadow-lg {{ colors[type] }}"
role="alert"
>
<span x-html="$icon('{{ icons[type] }}', 'w-5 h-5 mr-2')"></span>
<span x-text="{{ message_var }}"></span>
<button @click="{{ show_var }} = false" class="ml-4 hover:opacity-75 focus:outline-none" aria-label="Dismiss">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
{% endmacro %}

View File

@@ -0,0 +1,211 @@
{#
Badge Macros
============
Reusable badge components for status indicators, labels, and tags.
Usage:
{% from 'shared/macros/badges.html' import badge, status_badge, verification_badge, role_badge %}
{{ badge('New', 'purple') }}
{{ status_badge('is_active', 'Active', 'Inactive') }}
{{ verification_badge('item.is_verified') }}
{{ role_badge('user.role') }}
#}
{#
Basic Badge
===========
A simple colored badge.
Parameters:
- text: Badge text (static)
- color: 'gray' | 'red' | 'green' | 'blue' | 'yellow' | 'purple' | 'orange' | 'pink' (default: 'gray')
- size: 'sm' | 'md' (default: 'sm')
#}
{% macro badge(text, color='gray', size='sm') %}
{% set colors = {
'gray': 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
'red': 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
'green': 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
'blue': 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
'yellow': 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
'purple': 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
'orange': 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
'pink': 'text-pink-700 bg-pink-100 dark:text-pink-100 dark:bg-pink-700'
} %}
{% set sizes = {
'sm': 'px-2 py-1 text-xs',
'md': 'px-3 py-1 text-sm'
} %}
<span class="inline-flex items-center font-semibold leading-tight rounded-full {{ colors[color] }} {{ sizes[size] }}">
{{ text }}
</span>
{% endmacro %}
{#
Dynamic Badge
=============
A badge with text from Alpine.js variable.
Parameters:
- text_var: Alpine.js variable for badge text
- color: Badge color (default: 'gray')
- size: 'sm' | 'md' (default: 'sm')
#}
{% macro badge_dynamic(text_var, color='gray', size='sm') %}
{% set colors = {
'gray': 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
'red': 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
'green': 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
'blue': 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
'yellow': 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
'purple': 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
'orange': 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
'pink': 'text-pink-700 bg-pink-100 dark:text-pink-100 dark:bg-pink-700'
} %}
{% set sizes = {
'sm': 'px-2 py-1 text-xs',
'md': 'px-3 py-1 text-sm'
} %}
<span class="inline-flex items-center font-semibold leading-tight rounded-full {{ colors[color] }} {{ sizes[size] }}" x-text="{{ text_var }}"></span>
{% endmacro %}
{#
Status Badge (Boolean)
======================
A badge that changes based on a boolean condition.
Parameters:
- condition: Alpine.js boolean expression
- true_label: Text when true (default: 'Active')
- false_label: Text when false (default: 'Inactive')
- true_color: Color when true (default: 'green')
- false_color: Color when false (default: 'gray')
#}
{% macro status_badge(condition, true_label='Active', false_label='Inactive', true_color='green', false_color='gray') %}
{% set colors = {
'gray': 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
'red': 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
'green': 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
'blue': 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
'yellow': 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
'purple': 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
'orange': 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700'
} %}
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{{ condition }} ? '{{ colors[true_color] }}' : '{{ colors[false_color] }}'">
<span x-text="{{ condition }} ? '{{ true_label }}' : '{{ false_label }}'"></span>
</span>
{% endmacro %}
{#
Verification Badge
==================
A specialized badge for verified/pending status with icon.
Parameters:
- condition: Alpine.js boolean expression (e.g., 'item.is_verified')
- verified_label: Text when verified (default: 'Verified')
- pending_label: Text when not verified (default: 'Pending')
#}
{% macro verification_badge(condition, verified_label='Verified', pending_label='Pending') %}
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{{ condition }} ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
<span x-show="{{ condition }}" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="{{ condition }} ? '{{ verified_label }}' : '{{ pending_label }}'"></span>
</span>
{% endmacro %}
{#
Role Badge
==========
A badge for user roles with role-specific colors.
Parameters:
- role_var: Alpine.js variable containing the role string
- capitalize: Whether to capitalize the role text (default: true)
#}
{% macro role_badge(role_var, capitalize=true) %}
<span class="px-2 py-1 text-xs font-semibold leading-tight rounded-full {{ 'capitalize' if capitalize else '' }}"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': {{ role_var }} === 'admin',
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': {{ role_var }} === 'vendor',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': {{ role_var }} === 'customer',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !['admin', 'vendor', 'customer'].includes({{ role_var }})
}"
x-text="{{ role_var }}">
</span>
{% endmacro %}
{#
Order Status Badge
==================
A specialized badge for order statuses.
Parameters:
- status_var: Alpine.js variable containing the status string
#}
{% macro order_status_badge(status_var) %}
<span class="px-2 py-1 text-xs font-semibold leading-tight rounded-full capitalize"
:class="{
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': {{ status_var }} === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': {{ status_var }} === 'processing',
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': {{ status_var }} === 'shipped',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': {{ status_var }} === 'delivered',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': {{ status_var }} === 'cancelled',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !['pending', 'processing', 'shipped', 'delivered', 'cancelled'].includes({{ status_var }})
}"
x-text="{{ status_var }}">
</span>
{% endmacro %}
{#
Severity Badge
==============
A badge for severity levels (error, warning, info).
Parameters:
- severity_var: Alpine.js variable containing 'error' | 'warning' | 'info'
#}
{% macro severity_badge(severity_var) %}
<span class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': {{ severity_var }} === 'error',
'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700': {{ severity_var }} === 'warning',
'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700': {{ severity_var }} === 'info'
}"
x-text="{{ severity_var }}">
</span>
{% endmacro %}
{#
Count Badge
===========
A small badge typically used for counts (notifications, items, etc.).
Parameters:
- count_var: Alpine.js variable or expression for the count
- color: Badge color (default: 'red')
- show_zero: Whether to show when count is 0 (default: false)
#}
{% macro count_badge(count_var, color='red', show_zero=false) %}
{% set colors = {
'red': 'bg-red-500 text-white',
'green': 'bg-green-500 text-white',
'blue': 'bg-blue-500 text-white',
'purple': 'bg-purple-500 text-white',
'gray': 'bg-gray-500 text-white'
} %}
<span
x-show="{{ count_var }} > 0 || {{ 'true' if show_zero else 'false' }}"
class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full {{ colors[color] }}"
x-text="{{ count_var }}">
</span>
{% endmacro %}

View File

@@ -0,0 +1,224 @@
{#
Button Macros
=============
Reusable button components for actions, navigation, and forms.
Usage:
{% from 'shared/macros/buttons.html' import btn, btn_primary, btn_secondary, btn_danger, action_button %}
{{ btn_primary('Save Changes', icon='save', type='submit') }}
{{ btn_secondary('Cancel', href='/back') }}
{{ btn_danger('Delete', onclick='deleteItem()') }}
{{ action_button('eye', 'viewItem()', 'blue', 'View details') }}
#}
{#
Base Button
===========
A flexible button component that can be styled as primary, secondary, etc.
Parameters:
- text: Button text
- variant: 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'ghost' (default: 'primary')
- size: 'sm' | 'md' | 'lg' (default: 'md')
- icon: Icon name (optional, uses $icon helper)
- icon_position: 'left' | 'right' (default: 'left')
- type: 'button' | 'submit' | 'reset' (default: 'button')
- href: If provided, renders as <a> tag instead of <button>
- onclick: JavaScript onclick handler
- disabled: Alpine.js expression for disabled state
- loading: Alpine.js expression for loading state
- class_extra: Additional CSS classes
#}
{% macro btn(text, variant='primary', size='md', icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, class_extra='') %}
{% set variants = {
'primary': 'text-white bg-purple-600 border-transparent hover:bg-purple-700 focus:shadow-outline-purple',
'secondary': 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700',
'danger': 'text-white bg-red-600 border-transparent hover:bg-red-700 focus:shadow-outline-red',
'success': 'text-white bg-green-600 border-transparent hover:bg-green-700 focus:shadow-outline-green',
'warning': 'text-white bg-yellow-600 border-transparent hover:bg-yellow-700 focus:shadow-outline-yellow',
'ghost': 'text-gray-600 dark:text-gray-400 bg-transparent border-transparent hover:bg-gray-100 dark:hover:bg-gray-700'
} %}
{% set sizes = {
'sm': 'px-3 py-1 text-xs',
'md': 'px-4 py-2 text-sm',
'lg': 'px-6 py-3 text-base'
} %}
{% set base_classes = 'inline-flex items-center font-medium leading-5 transition-colors duration-150 border rounded-lg focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed' %}
{% if href %}
<a href="{{ href }}" class="{{ base_classes }} {{ variants[variant] }} {{ sizes[size] }} {{ class_extra }}">
{% if icon and icon_position == 'left' %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 {{ 'mr-2' if text else '' }}')"></span>
{% endif %}
{{ text }}
{% if icon and icon_position == 'right' %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 {{ 'ml-2' if text else '' }}')"></span>
{% endif %}
</a>
{% else %}
<button
type="{{ type }}"
{% if onclick %}@click="{{ onclick }}"{% endif %}
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="{{ base_classes }} {{ variants[variant] }} {{ sizes[size] }} {{ class_extra }}"
>
{% if loading %}
<span x-show="{{ loading }}" x-html="$icon('spinner', 'w-4 h-4 {{ 'mr-2' if text else '' }}')"></span>
{% endif %}
{% if icon and icon_position == 'left' %}
<span {% if loading %}x-show="!{{ loading }}"{% endif %} x-html="$icon('{{ icon }}', 'w-4 h-4 {{ 'mr-2' if text else '' }}')"></span>
{% endif %}
{{ text }}
{% if icon and icon_position == 'right' %}
<span {% if loading %}x-show="!{{ loading }}"{% endif %} x-html="$icon('{{ icon }}', 'w-4 h-4 {{ 'ml-2' if text else '' }}')"></span>
{% endif %}
</button>
{% endif %}
{% endmacro %}
{# Convenience macros for common button types #}
{% macro btn_primary(text, icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, size='md') %}
{{ btn(text, 'primary', size, icon, icon_position, type, href, onclick, disabled, loading) }}
{% endmacro %}
{% macro btn_secondary(text, icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, size='md') %}
{{ btn(text, 'secondary', size, icon, icon_position, type, href, onclick, disabled, loading) }}
{% endmacro %}
{% macro btn_danger(text, icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, size='md') %}
{{ btn(text, 'danger', size, icon, icon_position, type, href, onclick, disabled, loading) }}
{% endmacro %}
{% macro btn_success(text, icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, size='md') %}
{{ btn(text, 'success', size, icon, icon_position, type, href, onclick, disabled, loading) }}
{% endmacro %}
{% macro btn_ghost(text, icon=none, icon_position='left', type='button', href=none, onclick=none, disabled=none, loading=none, size='md') %}
{{ btn(text, 'ghost', size, icon, icon_position, type, href, onclick, disabled, loading) }}
{% endmacro %}
{#
Action Button (Icon Only)
=========================
A small icon button for table actions (view, edit, delete).
Parameters:
- icon: Icon name
- onclick: JavaScript onclick handler
- color: 'blue' | 'purple' | 'red' | 'green' | 'gray' (default: 'gray')
- title: Tooltip text
- disabled: Alpine.js expression for disabled state
- href: If provided, renders as <a> tag
#}
{% macro action_button(icon, onclick=none, color='gray', title='', disabled=none, href=none) %}
{% set colors = {
'blue': 'text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700',
'purple': 'text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700',
'red': 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700',
'green': 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700',
'gray': 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-700',
'orange': 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700'
} %}
{% set base_classes = 'flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed' %}
{% if href %}
<a href="{{ href }}" class="{{ base_classes }} {{ colors[color] }}" title="{{ title }}">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
</a>
{% else %}
<button
@click="{{ onclick }}"
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="{{ base_classes }} {{ colors[color] }}"
title="{{ title }}"
>
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
</button>
{% endif %}
{% endmacro %}
{#
Action Button Group
===================
A group of action buttons (typically for table rows).
Usage:
{{ action_button_group([
{'icon': 'eye', 'onclick': 'view(item)', 'color': 'blue', 'title': 'View'},
{'icon': 'edit', 'onclick': 'edit(item)', 'color': 'purple', 'title': 'Edit'},
{'icon': 'delete', 'onclick': 'delete(item)', 'color': 'red', 'title': 'Delete'}
]) }}
#}
{% macro action_button_group(buttons) %}
<div class="flex items-center space-x-2 text-sm">
{% for button in buttons %}
{{ action_button(
button.icon,
button.get('onclick'),
button.get('color', 'gray'),
button.get('title', ''),
button.get('disabled'),
button.get('href')
) }}
{% endfor %}
</div>
{% endmacro %}
{#
Button Group
============
A group of buttons with proper spacing.
#}
{% macro button_group(class_extra='') %}
<div class="flex items-center space-x-3 {{ class_extra }}">
{{ caller() }}
</div>
{% endmacro %}
{#
Back Button
===========
A standardized back navigation button.
Parameters:
- href: URL to navigate back to
- text: Button text (default: 'Back')
#}
{% macro back_button(href, text='Back') %}
<a href="{{ href }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ text }}
</a>
{% endmacro %}
{#
Submit Button with Loading
==========================
A form submit button with built-in loading state.
Parameters:
- text: Button text
- loading_text: Text shown while loading (default: 'Saving...')
- loading_var: Alpine.js variable for loading state (default: 'saving')
- icon: Icon name (default: 'save')
#}
{% macro submit_button(text='Save', loading_text='Saving...', loading_var='saving', icon='save') %}
<button
type="submit"
:disabled="{{ loading_var }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="{{ loading_var }}" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-show="!{{ loading_var }}" x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2')"></span>
<span x-text="{{ loading_var }} ? '{{ loading_text }}' : '{{ text }}'"></span>
</button>
{% endmacro %}

View File

@@ -0,0 +1,206 @@
{#
Card Macros
===========
Reusable card components including stats cards, info cards, and action cards.
Usage:
{% from 'shared/macros/cards.html' import stat_card, info_card, card %}
{{ stat_card('users', 'Total Users', 'stats.total', 'blue') }}
{{ info_card('User Details', 'View and edit user information') }}
#}
{#
Stat Card
=========
A statistics card with icon and value.
Parameters:
- icon: Icon name
- label: Card label/title
- value: Alpine.js expression for the value
- color: 'orange' | 'green' | 'blue' | 'purple' | 'red' | 'yellow' (default: 'orange')
- format: Optional format function to apply to value
#}
{% macro stat_card(icon, label, value, color='orange', format=none) %}
{% set colors = {
'orange': 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500',
'green': 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500',
'blue': 'text-blue-500 bg-blue-100 dark:text-blue-100 dark:bg-blue-500',
'purple': 'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500',
'red': 'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500',
'yellow': 'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500',
'teal': 'text-teal-500 bg-teal-100 dark:text-teal-100 dark:bg-teal-500'
} %}
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full {{ colors[color] }}">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
{{ label }}
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="{% if format %}{{ format }}({{ value }}){% else %}{{ value }} || 0{% endif %}">
0
</p>
</div>
</div>
{% endmacro %}
{#
Stats Grid
==========
A grid container for stat cards.
Parameters:
- columns: Number of columns (default: 4)
#}
{% macro stats_grid(columns=4) %}
{% set col_classes = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-2 xl:grid-cols-4'
} %}
<div class="grid gap-6 mb-8 {{ col_classes.get(columns, 'md:grid-cols-4') }}">
{{ caller() }}
</div>
{% endmacro %}
{#
Card
====
A basic card container.
Parameters:
- title: Card title (optional)
- subtitle: Card subtitle (optional)
- class_extra: Additional CSS classes
- padding: Whether to add padding (default: true)
#}
{% macro card(title=none, subtitle=none, class_extra='', padding=true) %}
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 {{ 'p-6' if padding else '' }} {{ class_extra }}">
{% if title %}
<div class="{% if padding %}{% else %}p-4 border-b dark:border-gray-700{% endif %}">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">{{ title }}</h3>
{% if subtitle %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subtitle }}</p>
{% endif %}
</div>
{% endif %}
<div class="{% if title and not padding %}p-4{% endif %}">
{{ caller() }}
</div>
</div>
{% endmacro %}
{#
Info Card
=========
An informational card with icon and description.
Parameters:
- icon: Icon name
- title: Card title
- description: Card description
- color: Icon color (default: 'purple')
- href: Optional link URL
#}
{% macro info_card(icon, title, description, color='purple', href=none) %}
{% set colors = {
'purple': 'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500',
'blue': 'text-blue-500 bg-blue-100 dark:text-blue-100 dark:bg-blue-500',
'green': 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500',
'orange': 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500'
} %}
{% if href %}
<a href="{{ href }}" class="block p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 hover:shadow-md transition-shadow">
{% else %}
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{% endif %}
<div class="flex items-center">
<div class="p-3 mr-4 rounded-full {{ colors[color] }}">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
</div>
<div>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200">{{ title }}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ description }}</p>
</div>
</div>
{% if href %}
</a>
{% else %}
</div>
{% endif %}
{% endmacro %}
{#
Quick Actions Card
==================
A card with quick action buttons and status badges.
Parameters:
- title: Card title (default: 'Quick Actions')
#}
{% macro quick_actions_card(title='Quick Actions') %}
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
{{ title }}
</h3>
<div class="flex flex-wrap items-center gap-3">
{{ caller() }}
</div>
</div>
{% endmacro %}
{#
Coming Soon Card
================
A placeholder card for features under development.
Parameters:
- emoji: Emoji to display
- title: Feature title
- description: Feature description
- back_url: URL for back button
- back_label: Back button text (default: 'Go Back')
#}
{% macro coming_soon_card(emoji='📦', title='Coming Soon', description='This feature is under development.', back_url='/', back_label='Go Back') %}
<div class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full p-12 bg-white dark:bg-gray-800 text-center">
<div class="text-6xl mb-4">{{ emoji }}</div>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
{{ title }}
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{{ description }}
</p>
<a href="{{ back_url }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
{{ back_label }}
</a>
</div>
</div>
{% endmacro %}
{#
Filter Card
===========
A card for search and filter controls.
Parameters:
- show_condition: Alpine.js condition (default: '!loading')
#}
{% macro filter_card(show_condition='!loading') %}
<div x-show="{{ show_condition }}" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{{ caller() }}
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,332 @@
{#
Form Macros
===========
Reusable form input components.
Usage:
{% from 'shared/macros/forms.html' import form_input, form_select, form_textarea, form_checkbox %}
{{ form_input('Email', 'email', 'formData.email', type='email', required=true) }}
{{ form_select('Status', 'formData.status', [{'value': 'active', 'label': 'Active'}]) }}
{{ form_textarea('Description', 'formData.description', rows=4) }}
#}
{#
Form Input
==========
A text input field with label, validation, and error handling.
Parameters:
- label: Field label
- name: Input name attribute
- x_model: Alpine.js x-model binding
- type: Input type (default: 'text')
- placeholder: Placeholder text
- required: Whether the field is required (default: false)
- disabled: Alpine.js expression for disabled state
- error: Alpine.js expression for error message
- help: Help text shown below the input
- maxlength: Maximum length
- min: Minimum value (for number inputs)
- max: Maximum value (for number inputs)
- step: Step value (for number inputs)
- autocomplete: Autocomplete attribute
- class_extra: Additional CSS classes for the input
#}
{% macro form_input(label, name, x_model, type='text', placeholder='', required=false, disabled=none, error=none, help=none, maxlength=none, min=none, max=none, step=none, autocomplete=none, class_extra='') %}
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
</span>
<input
type="{{ type }}"
name="{{ name }}"
x-model="{{ x_model }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
{% if min is not none %}min="{{ min }}"{% endif %}
{% if max is not none %}max="{{ max }}"{% endif %}
{% if step %}step="{{ step }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input {{ class_extra }}"
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
>
{% if error %}
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
{% endif %}
{% if help %}
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ help }}</span>
{% endif %}
</label>
{% endmacro %}
{#
Form Select
===========
A select dropdown with label and error handling.
Parameters:
- label: Field label
- x_model: Alpine.js x-model binding
- options: List of options [{'value': '', 'label': ''}] or Alpine.js expression
- name: Input name attribute
- required: Whether the field is required (default: false)
- disabled: Alpine.js expression for disabled state
- error: Alpine.js expression for error message
- placeholder: First empty option text (default: 'Select...')
- on_change: Alpine.js @change handler
#}
{% macro form_select(label, x_model, options=[], name='', required=false, disabled=none, error=none, placeholder='Select...', on_change=none) %}
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
</span>
<select
{% if name %}name="{{ name }}"{% endif %}
x-model="{{ x_model }}"
{% if required %}required{% endif %}
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
{% if on_change %}@change="{{ on_change }}"{% endif %}
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
>
{% if placeholder %}
<option value="">{{ placeholder }}</option>
{% endif %}
{% for option in options %}
<option value="{{ option.value }}">{{ option.label }}</option>
{% endfor %}
</select>
{% if error %}
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
{% endif %}
</label>
{% endmacro %}
{#
Form Select (Dynamic Options)
=============================
A select dropdown with options from Alpine.js.
Parameters:
- label: Field label
- x_model: Alpine.js x-model binding
- options_var: Alpine.js variable containing options array
- value_key: Key for option value (default: 'value')
- label_key: Key for option label (default: 'label')
- ... (other params same as form_select)
#}
{% macro form_select_dynamic(label, x_model, options_var, value_key='value', label_key='label', name='', required=false, disabled=none, error=none, placeholder='Select...', on_change=none) %}
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
</span>
<select
{% if name %}name="{{ name }}"{% endif %}
x-model="{{ x_model }}"
{% if required %}required{% endif %}
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
{% if on_change %}@change="{{ on_change }}"{% endif %}
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
>
{% if placeholder %}
<option value="">{{ placeholder }}</option>
{% endif %}
<template x-for="option in {{ options_var }}" :key="option.{{ value_key }}">
<option :value="option.{{ value_key }}" x-text="option.{{ label_key }}"></option>
</template>
</select>
{% if error %}
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
{% endif %}
</label>
{% endmacro %}
{#
Form Textarea
=============
A textarea field with label and error handling.
Parameters:
- label: Field label
- x_model: Alpine.js x-model binding
- name: Input name attribute
- rows: Number of rows (default: 3)
- placeholder: Placeholder text
- required: Whether the field is required (default: false)
- disabled: Alpine.js expression for disabled state
- error: Alpine.js expression for error message
- maxlength: Maximum length
#}
{% macro form_textarea(label, x_model, name='', rows=3, placeholder='', required=false, disabled=none, error=none, maxlength=none) %}
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
</span>
<textarea
{% if name %}name="{{ name }}"{% endif %}
x-model="{{ x_model }}"
rows="{{ rows }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
></textarea>
{% if error %}
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
{% endif %}
</label>
{% endmacro %}
{#
Form Checkbox
=============
A checkbox input with label.
Parameters:
- label: Checkbox label
- x_model: Alpine.js x-model binding
- name: Input name attribute
- disabled: Alpine.js expression for disabled state
- help: Help text shown below
#}
{% macro form_checkbox(label, x_model, name='', disabled=none, help=none) %}
<label class="flex items-center mb-4 text-sm">
<input
type="checkbox"
{% if name %}name="{{ name }}"{% endif %}
x-model="{{ x_model }}"
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="form-checkbox h-4 w-4 text-purple-600 dark:bg-gray-700 dark:border-gray-600 focus:ring-purple-500 dark:focus:ring-purple-600"
>
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ label }}</span>
</label>
{% if help %}
<span class="text-xs text-gray-500 dark:text-gray-400 -mt-2 mb-4 block ml-6">{{ help }}</span>
{% endif %}
{% endmacro %}
{#
Form Toggle Switch
==================
A toggle switch (styled checkbox).
Parameters:
- label: Toggle label
- x_model: Alpine.js x-model binding
- disabled: Alpine.js expression for disabled state
#}
{% macro form_toggle(label, x_model, disabled=none) %}
<div class="flex items-center mb-4">
<button
type="button"
@click="{{ x_model }} = !{{ x_model }}"
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{{ x_model }} ? 'bg-purple-600' : 'bg-gray-200 dark:bg-gray-700'"
role="switch"
:aria-checked="{{ x_model }}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="{{ x_model }} ? 'translate-x-5' : 'translate-x-0'"
></span>
</button>
<span class="ml-3 text-sm text-gray-700 dark:text-gray-400">{{ label }}</span>
</div>
{% endmacro %}
{#
Form Radio Group
================
A group of radio buttons.
Parameters:
- label: Group label
- name: Radio group name
- x_model: Alpine.js x-model binding
- options: List of options [{'value': '', 'label': ''}]
- inline: Whether to display inline (default: false)
#}
{% macro form_radio_group(label, name, x_model, options=[], inline=false) %}
<div class="mb-4">
<span class="block text-sm text-gray-700 dark:text-gray-400 mb-2">{{ label }}</span>
<div class="{{ 'flex flex-wrap gap-4' if inline else 'space-y-2' }}">
{% for option in options %}
<label class="flex items-center text-sm">
<input
type="radio"
name="{{ name }}"
value="{{ option.value }}"
x-model="{{ x_model }}"
class="form-radio h-4 w-4 text-purple-600 dark:bg-gray-700 dark:border-gray-600 focus:ring-purple-500"
>
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ option.label }}</span>
</label>
{% endfor %}
</div>
</div>
{% endmacro %}
{#
Search Input
============
A search input with icon.
Parameters:
- x_model: Alpine.js x-model binding
- placeholder: Placeholder text (default: 'Search...')
- on_input: Alpine.js @input handler (e.g., 'debouncedSearch()')
- class_extra: Additional CSS classes
#}
{% macro search_input(x_model, placeholder='Search...', on_input=none, class_extra='') %}
<div class="relative {{ class_extra }}">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="{{ x_model }}"
{% if on_input %}@input="{{ on_input }}"{% endif %}
placeholder="{{ placeholder }}"
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
{% endmacro %}
{#
Filter Select
=============
A compact select for filtering (no label, inline style).
Parameters:
- x_model: Alpine.js x-model binding
- options: List of options [{'value': '', 'label': ''}]
- on_change: Alpine.js @change handler
- placeholder: First option text (default: 'All')
#}
{% macro filter_select(x_model, options=[], on_change=none, placeholder='All') %}
<select
x-model="{{ x_model }}"
{% if on_change %}@change="{{ on_change }}"{% endif %}
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">{{ placeholder }}</option>
{% for option in options %}
<option value="{{ option.value }}">{{ option.label }}</option>
{% endfor %}
</select>
{% endmacro %}

View File

@@ -0,0 +1,222 @@
{#
Header Macros
=============
Reusable page header and navigation components.
Usage:
{% from 'shared/macros/headers.html' import page_header, section_header, breadcrumbs %}
{{ page_header('User Management', action_label='Create User', action_url='/users/create') }}
{{ section_header('Account Settings') }}
{{ breadcrumbs([{'label': 'Home', 'url': '/'}, {'label': 'Users'}]) }}
#}
{#
Page Header
===========
A page header with title, optional subtitle, and action button.
Parameters:
- title: Page title
- subtitle: Page subtitle (optional)
- action_label: Action button text (optional)
- action_url: Action button URL (optional)
- action_icon: Action button icon (default: 'plus')
- action_onclick: JavaScript onclick handler (optional, instead of URL)
- back_url: Back button URL (optional)
- back_label: Back button text (default: 'Back')
#}
{% macro page_header(title, subtitle=none, action_label=none, action_url=none, action_icon='plus', action_onclick=none, back_url=none, back_label='Back') %}
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
{{ title }}
</h2>
{% if subtitle %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ subtitle }}
</p>
{% endif %}
</div>
<div class="flex items-center space-x-3">
{% if back_url %}
<a href="{{ back_url }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ back_label }}
</a>
{% endif %}
{% if action_label and (action_url or action_onclick) %}
{% if action_url %}
<a href="{{ action_url }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('{{ action_icon }}', 'w-4 h-4 mr-2')"></span>
{{ action_label }}
</a>
{% else %}
<button @click="{{ action_onclick }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('{{ action_icon }}', 'w-4 h-4 mr-2')"></span>
{{ action_label }}
</button>
{% endif %}
{% endif %}
</div>
</div>
{% endmacro %}
{#
Page Header (Dynamic)
=====================
A page header where the title comes from Alpine.js.
Parameters:
- title_var: Alpine.js variable for the title
- subtitle_var: Alpine.js variable for subtitle (optional)
- ... other params same as page_header
#}
{% macro page_header_dynamic(title_var, subtitle_var=none, action_label=none, action_url=none, action_icon='plus', back_url=none, back_label='Back') %}
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="{{ title_var }}"></h2>
{% if subtitle_var %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="{{ subtitle_var }}"></p>
{% endif %}
</div>
<div class="flex items-center space-x-3">
{% if back_url %}
<a href="{{ back_url }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
{{ back_label }}
</a>
{% endif %}
{% if action_label and action_url %}
<a href="{{ action_url }}"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('{{ action_icon }}', 'w-4 h-4 mr-2')"></span>
{{ action_label }}
</a>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Section Header
==============
A section header within a page.
Parameters:
- title: Section title
- subtitle: Section subtitle (optional)
- action_label: Action button text (optional)
- action_onclick: Action button onclick (optional)
- icon: Section icon (optional)
#}
{% macro section_header(title, subtitle=none, action_label=none, action_onclick=none, icon=none) %}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
{% if icon %}
<span x-html="$icon('{{ icon }}', 'w-5 h-5 mr-2 text-gray-500')"></span>
{% endif %}
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">{{ title }}</h3>
{% if subtitle %}
<p class="text-sm text-gray-500 dark:text-gray-400">{{ subtitle }}</p>
{% endif %}
</div>
</div>
{% if action_label and action_onclick %}
<button @click="{{ action_onclick }}"
class="px-3 py-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-gray-700 rounded-lg transition-colors">
{{ action_label }}
</button>
{% endif %}
</div>
{% endmacro %}
{#
Breadcrumbs
===========
Navigation breadcrumbs.
Parameters:
- items: List of breadcrumb items [{'label': '', 'url': ''}]
Last item (without url) is treated as current page
- separator: Separator character (default: '/')
#}
{% macro breadcrumbs(items, separator='/') %}
<nav class="flex mb-6" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-2 text-sm">
{% for item in items %}
<li class="inline-flex items-center">
{% if not loop.first %}
<span class="mx-2 text-gray-400">{{ separator }}</span>
{% endif %}
{% if item.url and not loop.last %}
<a href="{{ item.url }}" class="text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
{{ item.label }}
</a>
{% else %}
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ item.label }}</span>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endmacro %}
{#
Card Header
===========
A header for card sections.
Parameters:
- title: Card header title
- subtitle: Card header subtitle (optional)
- class_extra: Additional CSS classes
#}
{% macro card_header(title, subtitle=none, class_extra='') %}
<div class="mb-4 {{ class_extra }}">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">{{ title }}</h3>
{% if subtitle %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ subtitle }}</p>
{% endif %}
</div>
{% endmacro %}
{#
Tab Header
==========
A tab navigation header.
Parameters:
- tabs: List of tab items [{'id': '', 'label': '', 'icon': ''}]
- active_var: Alpine.js variable for active tab
#}
{% macro tab_header(tabs, active_var='activeTab') %}
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="flex space-x-4" aria-label="Tabs">
{% for tab in tabs %}
<button
@click="{{ active_var }} = '{{ tab.id }}'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="{{ active_var }} === '{{ tab.id }}'
? 'border-purple-600 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
>
{% if tab.icon %}
<span x-html="$icon('{{ tab.icon }}', 'w-4 h-4 inline mr-2')"></span>
{% endif %}
{{ tab.label }}
</button>
{% endfor %}
</nav>
</div>
{% endmacro %}

View File

@@ -0,0 +1,128 @@
{#
Pagination Macro
================
A reusable pagination component for tables and lists.
Usage:
{% from 'shared/macros/pagination.html' import pagination %}
{{ pagination() }}
Required Alpine.js data properties:
- pagination.page: Current page number
- pagination.total: Total number of items
- startIndex: First item index on current page
- endIndex: Last item index on current page
- totalPages: Total number of pages
- pageNumbers: Array of page numbers with '...' for ellipsis
Required Alpine.js methods:
- previousPage(): Go to previous page
- nextPage(): Go to next page
- goToPage(pageNum): Go to specific page
#}
{% macro pagination(show_condition="true") %}
<div x-show="{{ show_condition }}" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
{# Results Info #}
<span class="flex items-center col-span-3">
Showing <span class="mx-1 font-bold" x-text="startIndex"></span>-<span class="mx-1 font-bold" x-text="endIndex"></span> of <span class="mx-1 font-bold" x-text="pagination.total"></span>
</span>
<span class="col-span-2"></span>
{# Pagination Controls #}
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
{# Previous Button #}
<li>
<button
@click="previousPage()"
:disabled="pagination.page === 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Previous"
>
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
{# Page Numbers #}
<template x-for="pageNum in pageNumbers" :key="pageNum">
<li>
<button
x-show="pageNum !== '...'"
@click="goToPage(pageNum)"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === pageNum ? 'text-white bg-purple-600 border border-purple-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="pageNum"
></button>
<span x-show="pageNum === '...'" class="px-3 py-1">...</span>
</li>
</template>
{# Next Button #}
<li>
<button
@click="nextPage()"
:disabled="pagination.page === totalPages"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Next"
>
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
</ul>
</nav>
</span>
</div>
{% endmacro %}
{#
Simple Pagination Macro (Prev/Next only)
========================================
A simpler pagination with just previous/next buttons.
Usage:
{% from 'shared/macros/pagination.html' import pagination_simple %}
{{ pagination_simple() }}
#}
{% macro pagination_simple(show_condition="true") %}
<div x-show="{{ show_condition }}" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center">
<span class="text-sm text-gray-700 dark:text-gray-400">
Showing <span class="font-semibold" x-text="startIndex"></span>-<span class="font-semibold" x-text="endIndex"></span>
of <span class="font-semibold" x-text="pagination.total"></span> results
</span>
</div>
<div class="flex items-center space-x-2">
<button
@click="previousPage()"
:disabled="pagination.page === 1"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<span class="text-sm text-gray-700 dark:text-gray-400">
Page <span x-text="pagination.page"></span> of <span x-text="totalPages"></span>
</span>
<button
@click="nextPage()"
:disabled="pagination.page === totalPages"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,215 @@
{#
Table Macros
============
Reusable table components.
Usage:
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% call table_wrapper() %}
{{ table_header(['Name', 'Email', 'Status', 'Actions']) }}
<tbody>...</tbody>
{% endcall %}
#}
{#
Table Wrapper
=============
Wraps the table with proper overflow and shadow styling.
#}
{% macro table_wrapper(class_extra='') %}
<div class="w-full overflow-hidden rounded-lg shadow-xs {{ class_extra }}">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
{{ caller() }}
</table>
</div>
</div>
{% endmacro %}
{#
Table Header
============
Renders the table header row.
Parameters:
- columns: List of column names
- sortable: Whether columns are sortable (default: false) - future enhancement
#}
{% macro table_header(columns) %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
{% for column in columns %}
<th class="px-4 py-3">{{ column }}</th>
{% endfor %}
</tr>
</thead>
{% endmacro %}
{#
Table Body Wrapper
==================
Wraps the tbody with proper styling.
#}
{% macro table_body() %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ caller() }}
</tbody>
{% endmacro %}
{#
Table Empty State
=================
Shows a centered message when the table has no data.
Parameters:
- colspan: Number of columns to span
- icon: Icon name (default: 'inbox')
- title: Empty state title
- message: Empty state message
- show_condition: Alpine.js condition (default: 'true')
- has_filters: Whether to show filter hint (default: true)
#}
{% macro table_empty_state(colspan, icon='inbox', title='No data found', message='', show_condition='true', has_filters=true) %}
<template x-if="{{ show_condition }}">
<tr>
<td colspan="{{ colspan }}" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('{{ icon }}', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ title }}</p>
{% if message %}
<p class="text-xs mt-1">{{ message }}</p>
{% elif has_filters %}
<p class="text-xs mt-1">Try adjusting your search or filters</p>
{% endif %}
</div>
</td>
</tr>
</template>
{% endmacro %}
{#
Table Row
=========
A standard table row with hover styling.
#}
{% macro table_row(class_extra='') %}
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 {{ class_extra }}">
{{ caller() }}
</tr>
{% endmacro %}
{#
Table Cell
==========
A standard table cell.
#}
{% macro table_cell(class_extra='') %}
<td class="px-4 py-3 {{ class_extra }}">
{{ caller() }}
</td>
{% endmacro %}
{#
Table Cell with Text
====================
A simple text cell.
Parameters:
- text: Static text or Alpine.js expression for x-text
- is_dynamic: Whether text is Alpine.js expression (default: false)
- truncate: Whether to truncate with max-width (default: false)
- max_width: Max width class (default: 'max-w-xs')
#}
{% macro table_cell_text(text, is_dynamic=false, truncate=false, max_width='max-w-xs') %}
<td class="px-4 py-3 text-sm">
{% if truncate %}
<p class="truncate {{ max_width }}" {% if is_dynamic %}x-text="{{ text }}" :title="{{ text }}"{% endif %}>
{% if not is_dynamic %}{{ text }}{% endif %}
</p>
{% else %}
{% if is_dynamic %}
<span x-text="{{ text }}"></span>
{% else %}
{{ text }}
{% endif %}
{% endif %}
</td>
{% endmacro %}
{#
Table Cell with Avatar
======================
A cell with avatar image and text.
Parameters:
- image_src: Image source (Alpine.js expression)
- title: Primary text (Alpine.js expression)
- subtitle: Secondary text (Alpine.js expression, optional)
- fallback_icon: Icon to show if no image (default: 'user')
#}
{% macro table_cell_avatar(image_src, title, subtitle=none, fallback_icon='user') %}
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<template x-if="{{ image_src }}">
<img class="object-cover w-full h-full rounded-full" :src="{{ image_src }}" :alt="{{ title }}" loading="lazy">
</template>
<template x-if="!{{ image_src }}">
<div class="flex items-center justify-center w-full h-full bg-gray-200 dark:bg-gray-700 rounded-full">
<span x-html="$icon('{{ fallback_icon }}', 'w-4 h-4 text-gray-500')"></span>
</div>
</template>
<div class="absolute inset-0 rounded-full shadow-inner" aria-hidden="true"></div>
</div>
<div>
<p class="font-semibold" x-text="{{ title }}"></p>
{% if subtitle %}
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="{{ subtitle }}"></p>
{% endif %}
</div>
</div>
</td>
{% endmacro %}
{#
Table Cell with Date
====================
A cell that formats a date.
Parameters:
- date_var: Alpine.js variable containing the date
- format_func: JavaScript date formatting function (default: 'formatDate')
#}
{% macro table_cell_date(date_var, format_func='formatDate') %}
<td class="px-4 py-3 text-sm">
<span x-text="{{ format_func }}({{ date_var }})"></span>
</td>
{% endmacro %}
{#
Table Loading Overlay
=====================
An overlay shown while table data is loading.
Parameters:
- show_condition: Alpine.js condition (default: 'loading')
- message: Loading message
#}
{% macro table_loading_overlay(show_condition='loading', message='Loading...') %}
<div x-show="{{ show_condition }}" class="absolute inset-0 bg-white/75 dark:bg-gray-800/75 flex items-center justify-center z-10">
<div class="text-center">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
</div>
{% endmacro %}