All checks were successful
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.
- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
across 123 templates. Images/fonts/JSON locales intentionally
unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.
Closes plan from docs/proposals/static-asset-cache-busting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
10 KiB
HTML
206 lines
10 KiB
HTML
{# app/modules/cms/templates/cms/admin/content-pages.html #}
|
|
{% extends "admin/base.html" %}
|
|
{% from 'shared/macros/headers.html' import page_header %}
|
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
|
{% from 'shared/macros/tabs.html' import tabs_inline, tab_button %}
|
|
{% from 'shared/macros/modals.html' import confirm_modal_dynamic %}
|
|
|
|
{% block title %}Content Pages{% endblock %}
|
|
|
|
{% block alpine_data %}contentPagesManager(){% endblock %}
|
|
|
|
{% block content %}
|
|
{{ page_header('Content Pages', subtitle='Manage platform defaults and store-specific content pages', action_label='Create Page', action_url='/admin/content-pages/create') }}
|
|
|
|
{{ loading_state('Loading pages...') }}
|
|
|
|
{{ error_state('Error loading pages') }}
|
|
|
|
<!-- Tabs and Filters -->
|
|
<div x-show="!loading" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<!-- Tabs -->
|
|
{% call tabs_inline() %}
|
|
{{ tab_button('all', 'All Pages', count_var='allPages.length') }}
|
|
{{ tab_button('platform_marketing', 'Platform Marketing', count_var='platformMarketingPages.length') }}
|
|
{{ tab_button('store_defaults', 'Store Defaults', count_var='storeDefaultPages.length') }}
|
|
{{ tab_button('store_overrides', 'Store Overrides', count_var='storeOverridePages.length') }}
|
|
{% endcall %}
|
|
|
|
<!-- Filters Row -->
|
|
<div class="flex items-center gap-3">
|
|
<!-- Platform Filter -->
|
|
<div class="relative">
|
|
<select
|
|
x-model="selectedPlatform"
|
|
class="pl-3 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 cursor-pointer"
|
|
style="appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: none;"
|
|
>
|
|
<option value="">All Platforms</option>
|
|
<template x-for="platform in platforms" :key="platform.id">
|
|
<option :value="platform.code" x-text="platform.name"></option>
|
|
</template>
|
|
</select>
|
|
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400')"></span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<div class="relative">
|
|
<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="searchQuery"
|
|
placeholder="Search pages..."
|
|
class="pl-10 pr-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pages Table -->
|
|
<div x-show="!loading && filteredPages.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full whitespace-nowrap">
|
|
<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:bg-gray-700/50 dark:text-gray-400">
|
|
<th class="px-4 py-3">Page</th>
|
|
<th class="px-4 py-3">Slug</th>
|
|
<th class="px-4 py-3">Type</th>
|
|
<th class="px-4 py-3">Status</th>
|
|
<th class="px-4 py-3">Navigation</th>
|
|
<th class="px-4 py-3">Updated</th>
|
|
<th class="px-4 py-3">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800 divide-y dark:divide-gray-700">
|
|
<template x-for="page in filteredPages" :key="page.id">
|
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<!-- Page Title -->
|
|
<td class="px-4 py-3">
|
|
<div>
|
|
<p class="font-semibold text-gray-900 dark:text-white" x-text="page.title"></p>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="page.store_name">
|
|
Store: <span x-text="page.store_name"></span>
|
|
</p>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Slug -->
|
|
<td class="px-4 py-3 text-sm">
|
|
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="'/' + page.slug"></code>
|
|
</td>
|
|
|
|
<!-- Type (Three-Tier System) -->
|
|
<td class="px-4 py-3 text-sm">
|
|
<span
|
|
class="px-2 py-1 text-xs font-semibold rounded-full"
|
|
:class="getPageTierClass(page)"
|
|
x-text="getPageTierLabel(page)"
|
|
></span>
|
|
<!-- Platform badge -->
|
|
<span
|
|
x-show="page.platform_name"
|
|
class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded"
|
|
x-text="page.platform_name"
|
|
></span>
|
|
</td>
|
|
|
|
<!-- Status -->
|
|
<td class="px-4 py-3 text-sm">
|
|
<span
|
|
class="px-2 py-1 text-xs font-semibold rounded-full"
|
|
:class="page.is_published ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'"
|
|
x-text="page.is_published ? 'Published' : 'Draft'"
|
|
></span>
|
|
</td>
|
|
|
|
<!-- Navigation -->
|
|
<td class="px-4 py-3 text-xs">
|
|
<div class="flex gap-1 flex-wrap">
|
|
<span x-show="page.show_in_header" class="px-2 py-0.5 font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300 rounded-full">Header</span>
|
|
<span x-show="page.show_in_footer" class="px-2 py-0.5 font-medium bg-teal-100 text-teal-800 dark:bg-teal-900/50 dark:text-teal-300 rounded-full">Footer</span>
|
|
<span x-show="page.show_in_legal" class="px-2 py-0.5 font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 rounded-full">Legal</span>
|
|
<span x-show="!page.show_in_header && !page.show_in_footer && !page.show_in_legal" class="text-gray-400">—</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Updated -->
|
|
<td class="px-4 py-3 text-xs" x-text="formatDate(page.updated_at)"></td>
|
|
|
|
<!-- Actions -->
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center space-x-2 text-sm">
|
|
<a
|
|
:href="`/admin/content-pages/${page.id}/edit`"
|
|
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
|
title="Edit"
|
|
>
|
|
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
|
</a>
|
|
<button
|
|
@click="promptDeletePage(page)"
|
|
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
|
title="Delete"
|
|
>
|
|
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div x-show="!loading && filteredPages.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
|
<span x-html="$icon('document-text', 'inline w-16 h-16 text-gray-400 mb-4')"></span>
|
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">No pages found</h3>
|
|
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="searchQuery">
|
|
No pages match your search: "<span x-text="searchQuery"></span>"
|
|
</p>
|
|
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="!searchQuery && activeTab === 'store'">
|
|
No store-specific pages have been created yet.
|
|
</p>
|
|
<a
|
|
href="/admin/content-pages/create"
|
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
|
>
|
|
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
|
Create First Page
|
|
</a>
|
|
</div>
|
|
|
|
{{ confirm_modal_dynamic(
|
|
'createHomepageModal',
|
|
'Create Homepage',
|
|
"'No homepage found for ' + (pendingHomepagePlatform || '') + '. Would you like to create one?'",
|
|
'createHomepage()',
|
|
'showCreateHomepageConfirm',
|
|
'Create',
|
|
'Cancel',
|
|
'info'
|
|
) }}
|
|
|
|
{{ confirm_modal_dynamic(
|
|
'deletePageModal',
|
|
'Delete Page',
|
|
"'Are you sure you want to delete \"' + (pageToDelete?.title || '') + '\"?'",
|
|
'deletePage(pageToDelete)',
|
|
'showDeletePageConfirm',
|
|
'Delete',
|
|
'Cancel',
|
|
'danger'
|
|
) }}
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script defer src="{{ static_v(request, 'cms_static', path='admin/js/content-pages.js') }}"></script>
|
|
{% endblock %}
|