feat: add shared utilities and table macros

- Add shared table macros for consistent table styling
- Add shared JavaScript utilities (date formatting, etc.)
- Add admin settings API enhancements
- Add admin schema updates

🤖 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-21 14:12:43 +01:00
parent a118edced5
commit 9cf0a568c0
4 changed files with 346 additions and 0 deletions

View File

@@ -24,6 +24,9 @@ from models.schema.admin import (
AdminSettingListResponse,
AdminSettingResponse,
AdminSettingUpdate,
PublicDisplaySettingsResponse,
RowsPerPageResponse,
RowsPerPageUpdateResponse,
)
router = APIRouter(prefix="/settings")
@@ -173,6 +176,81 @@ def upsert_setting(
return result
# ============================================================================
# CONVENIENCE ENDPOINTS FOR COMMON SETTINGS
# ============================================================================
@router.get("/display/rows-per-page", response_model=RowsPerPageResponse)
def get_rows_per_page(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> RowsPerPageResponse:
"""Get the platform-wide rows per page setting."""
value = admin_settings_service.get_setting_value(db, "rows_per_page", default="20")
return RowsPerPageResponse(rows_per_page=int(value))
@router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse)
def set_rows_per_page(
rows: int = Query(..., ge=10, le=100, description="Rows per page (10-100)"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> RowsPerPageUpdateResponse:
"""
Set the platform-wide rows per page setting.
Valid values: 10, 20, 50, 100
"""
valid_values = [10, 20, 50, 100]
if rows not in valid_values:
# Round to nearest valid value
rows = min(valid_values, key=lambda x: abs(x - rows))
setting_data = AdminSettingCreate(
key="rows_per_page",
value=str(rows),
value_type="integer",
category="display",
description="Default number of rows per page in admin tables",
is_public=True,
)
admin_settings_service.upsert_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
target_type="setting",
target_id="rows_per_page",
details={"value": rows},
)
db.commit()
return RowsPerPageUpdateResponse(
rows_per_page=rows, message="Rows per page setting updated"
)
@router.get("/display/public", response_model=PublicDisplaySettingsResponse)
def get_public_display_settings(
db: Session = Depends(get_db),
) -> PublicDisplaySettingsResponse:
"""
Get public display settings (no auth required).
Returns settings that can be used by frontend without admin auth.
"""
rows_per_page = admin_settings_service.get_setting_value(
db, "rows_per_page", default="20"
)
return PublicDisplaySettingsResponse(rows_per_page=int(rows_per_page))
@router.delete("/{key}")
def delete_setting(
key: str,

View File

@@ -283,3 +283,168 @@
</div>
</div>
{% endmacro %}
{#
Numbered Pagination
===================
A numbered pagination component with first/prev/pages/next/last buttons.
Parameters:
- page_var: Alpine.js variable for current page (default: 'page')
- total_var: Alpine.js variable for total items (default: 'total')
- limit_var: Alpine.js variable for items per page (default: 'limit')
- on_change: Alpine.js function to call on page change (default: 'loadItems()')
- show_rows_per_page: Whether to show rows per page selector (default: false)
- rows_options: List of rows per page options (default: [10, 20, 50, 100])
Required Alpine.js:
page: 1,
total: 0,
limit: 20,
get totalPages() { return Math.ceil(this.total / this.limit); },
getPageNumbers() {
// Returns array of page numbers to display
const total = this.totalPages;
const current = this.page;
const maxVisible = 5;
if (total <= maxVisible) {
return Array.from({length: total}, (_, i) => i + 1);
}
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
return Array.from({length: end - start + 1}, (_, i) => start + i);
},
goToPage(p) {
this.page = p;
this.loadItems();
}
#}
{% macro numbered_pagination(page_var='page', total_var='total', limit_var='limit', on_change='loadItems()', show_rows_per_page=false, rows_options=[10, 20, 50, 100]) %}
<div x-show="{{ total_var }} > {{ limit_var }}" class="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4 pt-4 border-t dark:border-gray-700">
{# Info text #}
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing
<span x-text="(({{ page_var }} - 1) * {{ limit_var }}) + 1"></span>-<span x-text="Math.min({{ page_var }} * {{ limit_var }}, {{ total_var }})"></span>
of <span x-text="{{ total_var }}"></span> items
</span>
{# Pagination controls #}
<div class="flex items-center gap-1">
{# First page #}
<button
@click="{{ page_var }} = 1; {{ on_change }}"
:disabled="{{ page_var }} <= 1"
class="px-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
title="First page"
>
<span x-html="$icon('chevron-double-left', 'w-4 h-4')"></span>
</button>
{# Previous page #}
<button
@click="{{ page_var }}--; {{ on_change }}"
:disabled="{{ page_var }} <= 1"
class="px-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
title="Previous page"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
{# Page numbers #}
<template x-for="p in getPageNumbers()" :key="p">
<button
@click="goToPage(p)"
class="px-3 py-1 text-sm font-medium rounded-md border transition-colors"
:class="p === {{ page_var }}
? 'bg-purple-600 text-white border-purple-600 dark:bg-purple-500 dark:border-purple-500'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
x-text="p"
></button>
</template>
{# Next page #}
<button
@click="{{ page_var }}++; {{ on_change }}"
:disabled="{{ page_var }} >= Math.ceil({{ total_var }} / {{ limit_var }})"
class="px-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
title="Next page"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
{# Last page #}
<button
@click="{{ page_var }} = Math.ceil({{ total_var }} / {{ limit_var }}); {{ on_change }}"
:disabled="{{ page_var }} >= Math.ceil({{ total_var }} / {{ limit_var }})"
class="px-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
title="Last page"
>
<span x-html="$icon('chevron-double-right', 'w-4 h-4')"></span>
</button>
</div>
{% if show_rows_per_page %}
{# Rows per page selector #}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Rows:</label>
<select
x-model.number="{{ limit_var }}"
@change="{{ page_var }} = 1; {{ on_change }}"
class="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
{% for opt in rows_options %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
{% endmacro %}
{#
Simple Pagination
=================
A simpler prev/next pagination without page numbers.
Use this for quick implementation or when numbered pagination isn't needed.
Parameters:
- page_var: Alpine.js variable for current page (default: 'page')
- total_var: Alpine.js variable for total items (default: 'total')
- limit_var: Alpine.js variable for items per page (default: 'limit')
- on_change: Alpine.js function to call on page change (default: 'loadItems()')
#}
{% macro simple_pagination(page_var='page', total_var='total', limit_var='limit', on_change='loadItems()') %}
<div x-show="{{ total_var }} > {{ limit_var }}" class="flex items-center justify-between mt-4 pt-4 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing
<span x-text="(({{ page_var }} - 1) * {{ limit_var }}) + 1"></span>-<span x-text="Math.min({{ page_var }} * {{ limit_var }}, {{ total_var }})"></span>
of <span x-text="{{ total_var }}"></span>
</span>
<div class="flex items-center gap-2">
<button
@click="{{ page_var }}--; {{ on_change }}"
:disabled="{{ page_var }} <= 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 disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<button
@click="{{ page_var }}++; {{ on_change }}"
:disabled="{{ page_var }} * {{ limit_var }} >= {{ total_var }}"
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 disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
{% endmacro %}

View File

@@ -187,6 +187,30 @@ class AdminSettingListResponse(BaseModel):
category: str | None = None
# ============================================================================
# DISPLAY SETTINGS SCHEMAS
# ============================================================================
class RowsPerPageResponse(BaseModel):
"""Response for rows per page setting."""
rows_per_page: int
class RowsPerPageUpdateResponse(BaseModel):
"""Response after updating rows per page."""
rows_per_page: int
message: str
class PublicDisplaySettingsResponse(BaseModel):
"""Public display settings (no auth required)."""
rows_per_page: int
# ============================================================================
# PLATFORM ALERT SCHEMAS
# ============================================================================

View File

@@ -184,7 +184,86 @@ const Utils = {
}
};
// ============================================================================
// Platform Settings
// ============================================================================
/**
* Platform settings cache and loader
*/
const PlatformSettings = {
_settings: null,
_loading: false,
_loadPromise: null,
/**
* Load platform display settings from API
* @returns {Promise<Object>} Platform settings
*/
async load() {
// Return cached settings if available
if (this._settings) {
return this._settings;
}
// Return existing promise if already loading
if (this._loadPromise) {
return this._loadPromise;
}
this._loading = true;
this._loadPromise = (async () => {
try {
const response = await fetch('/api/v1/admin/settings/display/public');
if (response.ok) {
this._settings = await response.json();
} else {
// Default settings
this._settings = { rows_per_page: 20 };
}
} catch (error) {
console.warn('Failed to load platform settings, using defaults:', error);
this._settings = { rows_per_page: 20 };
}
this._loading = false;
return this._settings;
})();
return this._loadPromise;
},
/**
* Get rows per page setting
* @returns {number} Rows per page (default: 20)
*/
getRowsPerPage() {
return this._settings?.rows_per_page || 20;
},
/**
* Get rows per page synchronously (returns cached or default)
* @returns {number} Rows per page
*/
get rowsPerPage() {
return this._settings?.rows_per_page || 20;
},
/**
* Reset cached settings (call after updating settings)
*/
reset() {
this._settings = null;
this._loadPromise = null;
}
};
// Load settings on page load
document.addEventListener('DOMContentLoaded', () => {
PlatformSettings.load();
});
// Make available globally
window.PlatformSettings = PlatformSettings;
window.Utils = Utils;
// Export for modules