skrift 0.1.0a14__py3-none-any.whl → 0.1.0a16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skrift/admin/controller.py +114 -26
- skrift/alembic/versions/20260202_add_content_scheduling.py +35 -0
- skrift/alembic/versions/20260202_add_page_ordering.py +32 -0
- skrift/alembic/versions/20260202_add_page_revisions.py +46 -0
- skrift/alembic/versions/20260202_add_seo_fields.py +42 -0
- skrift/claude_skill/SKILL.md +205 -0
- skrift/claude_skill/__init__.py +0 -0
- skrift/claude_skill/architecture.md +328 -0
- skrift/claude_skill/patterns.md +552 -0
- skrift/cli.py +47 -0
- skrift/controllers/sitemap.py +116 -0
- skrift/controllers/web.py +18 -1
- skrift/db/models/__init__.py +2 -1
- skrift/db/models/page.py +26 -1
- skrift/db/models/page_revision.py +45 -0
- skrift/db/services/page_service.py +113 -6
- skrift/db/services/revision_service.py +146 -0
- skrift/db/services/setting_service.py +7 -0
- skrift/lib/__init__.py +12 -1
- skrift/lib/flash.py +103 -0
- skrift/lib/hooks.py +292 -0
- skrift/lib/seo.py +103 -0
- skrift/static/css/style.css +92 -2
- skrift/templates/admin/pages/edit.html +44 -4
- skrift/templates/admin/pages/list.html +8 -1
- skrift/templates/admin/pages/revisions.html +59 -0
- skrift/templates/base.html +58 -4
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/METADATA +14 -3
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/RECORD +31 -16
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/entry_points.txt +0 -0
skrift/lib/hooks.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""WordPress-like hook/filter system for Skrift CMS extensibility.
|
|
2
|
+
|
|
3
|
+
This module provides an async-first hook system that allows registering
|
|
4
|
+
callbacks to be executed at specific points in the application lifecycle.
|
|
5
|
+
|
|
6
|
+
Actions: Execute callbacks without modifying a value (side effects)
|
|
7
|
+
Filters: Execute callbacks that can modify a value (transformations)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from skrift.lib.hooks import hooks, action, filter
|
|
11
|
+
|
|
12
|
+
# Using decorators (auto-registered on import)
|
|
13
|
+
@action("after_page_save", priority=10)
|
|
14
|
+
async def notify_on_page_save(page):
|
|
15
|
+
print(f"Page saved: {page.title}")
|
|
16
|
+
|
|
17
|
+
@filter("page_seo_meta", priority=10)
|
|
18
|
+
async def add_custom_meta(meta, page):
|
|
19
|
+
meta["author"] = "Custom Author"
|
|
20
|
+
return meta
|
|
21
|
+
|
|
22
|
+
# Using direct registration
|
|
23
|
+
hooks.add_action("before_page_save", my_callback)
|
|
24
|
+
hooks.add_filter("page_og_meta", my_modifier)
|
|
25
|
+
|
|
26
|
+
# Triggering hooks
|
|
27
|
+
await hooks.do_action("after_page_save", page)
|
|
28
|
+
meta = await hooks.apply_filters("page_seo_meta", {}, page)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from typing import Any, Callable, TypeVar
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(order=True)
|
|
41
|
+
class HookHandler:
|
|
42
|
+
"""A registered hook handler with priority."""
|
|
43
|
+
|
|
44
|
+
priority: int
|
|
45
|
+
callback: Callable = field(compare=False)
|
|
46
|
+
|
|
47
|
+
async def call(self, *args: Any, **kwargs: Any) -> Any:
|
|
48
|
+
"""Call the handler, handling both sync and async callbacks."""
|
|
49
|
+
result = self.callback(*args, **kwargs)
|
|
50
|
+
if asyncio.iscoroutine(result):
|
|
51
|
+
return await result
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HookRegistry:
|
|
56
|
+
"""Central registry for all hooks (actions and filters)."""
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self._actions: dict[str, list[HookHandler]] = defaultdict(list)
|
|
60
|
+
self._filters: dict[str, list[HookHandler]] = defaultdict(list)
|
|
61
|
+
|
|
62
|
+
def add_action(
|
|
63
|
+
self,
|
|
64
|
+
hook_name: str,
|
|
65
|
+
callback: Callable[..., Any],
|
|
66
|
+
priority: int = 10,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Register an action callback.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
hook_name: Name of the action hook
|
|
72
|
+
callback: Function to call when action is triggered
|
|
73
|
+
priority: Lower numbers execute first (default: 10)
|
|
74
|
+
"""
|
|
75
|
+
handler = HookHandler(priority=priority, callback=callback)
|
|
76
|
+
self._actions[hook_name].append(handler)
|
|
77
|
+
self._actions[hook_name].sort()
|
|
78
|
+
|
|
79
|
+
def add_filter(
|
|
80
|
+
self,
|
|
81
|
+
hook_name: str,
|
|
82
|
+
callback: Callable[..., T],
|
|
83
|
+
priority: int = 10,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Register a filter callback.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
hook_name: Name of the filter hook
|
|
89
|
+
callback: Function to call to modify value
|
|
90
|
+
priority: Lower numbers execute first (default: 10)
|
|
91
|
+
"""
|
|
92
|
+
handler = HookHandler(priority=priority, callback=callback)
|
|
93
|
+
self._filters[hook_name].append(handler)
|
|
94
|
+
self._filters[hook_name].sort()
|
|
95
|
+
|
|
96
|
+
def remove_action(
|
|
97
|
+
self,
|
|
98
|
+
hook_name: str,
|
|
99
|
+
callback: Callable[..., Any],
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Remove an action callback.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
hook_name: Name of the action hook
|
|
105
|
+
callback: Function to remove
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if callback was found and removed
|
|
109
|
+
"""
|
|
110
|
+
handlers = self._actions.get(hook_name, [])
|
|
111
|
+
for i, handler in enumerate(handlers):
|
|
112
|
+
if handler.callback is callback:
|
|
113
|
+
handlers.pop(i)
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def remove_filter(
|
|
118
|
+
self,
|
|
119
|
+
hook_name: str,
|
|
120
|
+
callback: Callable[..., Any],
|
|
121
|
+
) -> bool:
|
|
122
|
+
"""Remove a filter callback.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
hook_name: Name of the filter hook
|
|
126
|
+
callback: Function to remove
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if callback was found and removed
|
|
130
|
+
"""
|
|
131
|
+
handlers = self._filters.get(hook_name, [])
|
|
132
|
+
for i, handler in enumerate(handlers):
|
|
133
|
+
if handler.callback is callback:
|
|
134
|
+
handlers.pop(i)
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def has_action(self, hook_name: str) -> bool:
|
|
139
|
+
"""Check if any actions are registered for a hook."""
|
|
140
|
+
return bool(self._actions.get(hook_name))
|
|
141
|
+
|
|
142
|
+
def has_filter(self, hook_name: str) -> bool:
|
|
143
|
+
"""Check if any filters are registered for a hook."""
|
|
144
|
+
return bool(self._filters.get(hook_name))
|
|
145
|
+
|
|
146
|
+
async def do_action(
|
|
147
|
+
self,
|
|
148
|
+
hook_name: str,
|
|
149
|
+
*args: Any,
|
|
150
|
+
**kwargs: Any,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Execute all registered action callbacks.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
hook_name: Name of the action hook
|
|
156
|
+
*args: Positional arguments to pass to callbacks
|
|
157
|
+
**kwargs: Keyword arguments to pass to callbacks
|
|
158
|
+
"""
|
|
159
|
+
handlers = self._actions.get(hook_name, [])
|
|
160
|
+
for handler in handlers:
|
|
161
|
+
await handler.call(*args, **kwargs)
|
|
162
|
+
|
|
163
|
+
async def apply_filters(
|
|
164
|
+
self,
|
|
165
|
+
hook_name: str,
|
|
166
|
+
value: T,
|
|
167
|
+
*args: Any,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> T:
|
|
170
|
+
"""Apply all registered filter callbacks to a value.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
hook_name: Name of the filter hook
|
|
174
|
+
value: Initial value to filter
|
|
175
|
+
*args: Additional positional arguments to pass to callbacks
|
|
176
|
+
**kwargs: Keyword arguments to pass to callbacks
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The filtered value after all callbacks have been applied
|
|
180
|
+
"""
|
|
181
|
+
handlers = self._filters.get(hook_name, [])
|
|
182
|
+
for handler in handlers:
|
|
183
|
+
value = await handler.call(value, *args, **kwargs)
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
def clear(self) -> None:
|
|
187
|
+
"""Clear all registered hooks. Useful for testing."""
|
|
188
|
+
self._actions.clear()
|
|
189
|
+
self._filters.clear()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Global singleton registry
|
|
193
|
+
hooks = HookRegistry()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Convenience functions that use the global registry
|
|
197
|
+
def add_action(
|
|
198
|
+
hook_name: str,
|
|
199
|
+
callback: Callable[..., Any],
|
|
200
|
+
priority: int = 10,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Register an action callback to the global registry."""
|
|
203
|
+
hooks.add_action(hook_name, callback, priority)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def add_filter(
|
|
207
|
+
hook_name: str,
|
|
208
|
+
callback: Callable[..., T],
|
|
209
|
+
priority: int = 10,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Register a filter callback to the global registry."""
|
|
212
|
+
hooks.add_filter(hook_name, callback, priority)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def remove_action(hook_name: str, callback: Callable[..., Any]) -> bool:
|
|
216
|
+
"""Remove an action callback from the global registry."""
|
|
217
|
+
return hooks.remove_action(hook_name, callback)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def remove_filter(hook_name: str, callback: Callable[..., Any]) -> bool:
|
|
221
|
+
"""Remove a filter callback from the global registry."""
|
|
222
|
+
return hooks.remove_filter(hook_name, callback)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def do_action(hook_name: str, *args: Any, **kwargs: Any) -> None:
|
|
226
|
+
"""Execute all registered action callbacks via the global registry."""
|
|
227
|
+
await hooks.do_action(hook_name, *args, **kwargs)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def apply_filters(hook_name: str, value: T, *args: Any, **kwargs: Any) -> T:
|
|
231
|
+
"""Apply all registered filter callbacks via the global registry."""
|
|
232
|
+
return await hooks.apply_filters(hook_name, value, *args, **kwargs)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Decorator factories for auto-registration
|
|
236
|
+
def action(hook_name: str, priority: int = 10) -> Callable[[Callable], Callable]:
|
|
237
|
+
"""Decorator to register a function as an action handler.
|
|
238
|
+
|
|
239
|
+
Usage:
|
|
240
|
+
@action("after_page_save", priority=5)
|
|
241
|
+
async def my_handler(page):
|
|
242
|
+
...
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def decorator(func: Callable) -> Callable:
|
|
246
|
+
hooks.add_action(hook_name, func, priority)
|
|
247
|
+
|
|
248
|
+
@wraps(func)
|
|
249
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
250
|
+
return func(*args, **kwargs)
|
|
251
|
+
|
|
252
|
+
return wrapper
|
|
253
|
+
|
|
254
|
+
return decorator
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def filter(hook_name: str, priority: int = 10) -> Callable[[Callable], Callable]:
|
|
258
|
+
"""Decorator to register a function as a filter handler.
|
|
259
|
+
|
|
260
|
+
Usage:
|
|
261
|
+
@filter("page_seo_meta", priority=5)
|
|
262
|
+
async def my_filter(meta, page):
|
|
263
|
+
meta["custom"] = "value"
|
|
264
|
+
return meta
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def decorator(func: Callable) -> Callable:
|
|
268
|
+
hooks.add_filter(hook_name, func, priority)
|
|
269
|
+
|
|
270
|
+
@wraps(func)
|
|
271
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
272
|
+
return func(*args, **kwargs)
|
|
273
|
+
|
|
274
|
+
return wrapper
|
|
275
|
+
|
|
276
|
+
return decorator
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Define standard hook names as constants for discoverability
|
|
280
|
+
# Actions
|
|
281
|
+
BEFORE_PAGE_SAVE = "before_page_save"
|
|
282
|
+
AFTER_PAGE_SAVE = "after_page_save"
|
|
283
|
+
BEFORE_PAGE_DELETE = "before_page_delete"
|
|
284
|
+
AFTER_PAGE_DELETE = "after_page_delete"
|
|
285
|
+
|
|
286
|
+
# Filters
|
|
287
|
+
PAGE_SEO_META = "page_seo_meta"
|
|
288
|
+
PAGE_OG_META = "page_og_meta"
|
|
289
|
+
SITEMAP_URLS = "sitemap_urls"
|
|
290
|
+
SITEMAP_PAGE = "sitemap_page"
|
|
291
|
+
ROBOTS_TXT = "robots_txt"
|
|
292
|
+
TEMPLATE_CONTEXT = "template_context"
|
skrift/lib/seo.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""SEO utilities for generating page metadata."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from skrift.lib.hooks import hooks, PAGE_SEO_META, PAGE_OG_META
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from skrift.db.models import Page
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SEOMeta:
|
|
14
|
+
"""Standard SEO metadata for a page."""
|
|
15
|
+
|
|
16
|
+
title: str
|
|
17
|
+
description: str | None
|
|
18
|
+
canonical_url: str
|
|
19
|
+
robots: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class OpenGraphMeta:
|
|
24
|
+
"""OpenGraph metadata for social sharing."""
|
|
25
|
+
|
|
26
|
+
title: str
|
|
27
|
+
description: str | None
|
|
28
|
+
image: str | None
|
|
29
|
+
url: str
|
|
30
|
+
site_name: str
|
|
31
|
+
type: str = "website"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def get_page_seo_meta(
|
|
35
|
+
page: "Page",
|
|
36
|
+
site_name: str,
|
|
37
|
+
base_url: str,
|
|
38
|
+
) -> SEOMeta:
|
|
39
|
+
"""Generate SEO metadata for a page.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
page: The page to generate metadata for
|
|
43
|
+
site_name: The site name for title suffix
|
|
44
|
+
base_url: Base URL for canonical URL generation
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
SEOMeta dataclass with the metadata
|
|
48
|
+
"""
|
|
49
|
+
# Build canonical URL
|
|
50
|
+
slug = page.slug.strip("/")
|
|
51
|
+
canonical_url = f"{base_url.rstrip('/')}/{slug}" if slug else base_url.rstrip("/")
|
|
52
|
+
|
|
53
|
+
# Build title with site name suffix
|
|
54
|
+
title = f"{page.title} | {site_name}" if site_name else page.title
|
|
55
|
+
|
|
56
|
+
meta = SEOMeta(
|
|
57
|
+
title=title,
|
|
58
|
+
description=page.meta_description,
|
|
59
|
+
canonical_url=canonical_url,
|
|
60
|
+
robots=page.meta_robots,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Apply filter for extensibility
|
|
64
|
+
meta = await hooks.apply_filters(PAGE_SEO_META, meta, page, site_name, base_url)
|
|
65
|
+
|
|
66
|
+
return meta
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def get_page_og_meta(
|
|
70
|
+
page: "Page",
|
|
71
|
+
site_name: str,
|
|
72
|
+
base_url: str,
|
|
73
|
+
) -> OpenGraphMeta:
|
|
74
|
+
"""Generate OpenGraph metadata for a page.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
page: The page to generate metadata for
|
|
78
|
+
site_name: The site name
|
|
79
|
+
base_url: Base URL for URL generation
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
OpenGraphMeta dataclass with the metadata
|
|
83
|
+
"""
|
|
84
|
+
# Build URL
|
|
85
|
+
slug = page.slug.strip("/")
|
|
86
|
+
url = f"{base_url.rstrip('/')}/{slug}" if slug else base_url.rstrip("/")
|
|
87
|
+
|
|
88
|
+
# Use og_* fields if set, otherwise fall back to page fields
|
|
89
|
+
og_title = page.og_title or page.title
|
|
90
|
+
og_description = page.og_description or page.meta_description
|
|
91
|
+
|
|
92
|
+
meta = OpenGraphMeta(
|
|
93
|
+
title=og_title,
|
|
94
|
+
description=og_description,
|
|
95
|
+
image=page.og_image,
|
|
96
|
+
url=url,
|
|
97
|
+
site_name=site_name,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Apply filter for extensibility
|
|
101
|
+
meta = await hooks.apply_filters(PAGE_OG_META, meta, page, site_name, base_url)
|
|
102
|
+
|
|
103
|
+
return meta
|
skrift/static/css/style.css
CHANGED
|
@@ -630,11 +630,96 @@ article footer {
|
|
|
630
630
|
|
|
631
631
|
/* Flash messages */
|
|
632
632
|
.flash-messages {
|
|
633
|
-
|
|
633
|
+
position: fixed;
|
|
634
|
+
top: var(--spacing-md);
|
|
635
|
+
right: var(--spacing-md);
|
|
636
|
+
z-index: 1000;
|
|
637
|
+
max-width: 400px;
|
|
634
638
|
}
|
|
635
639
|
|
|
636
|
-
.flash
|
|
640
|
+
.flash {
|
|
641
|
+
display: flex;
|
|
642
|
+
align-items: center;
|
|
643
|
+
justify-content: space-between;
|
|
644
|
+
padding: 0.75rem 1rem;
|
|
637
645
|
margin-bottom: var(--spacing-sm);
|
|
646
|
+
border-radius: var(--radius-md);
|
|
647
|
+
box-shadow: var(--shadow-md);
|
|
648
|
+
animation: flash-slide-in 0.3s ease;
|
|
649
|
+
transition: opacity 0.3s ease;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.flash-success {
|
|
653
|
+
background-color: #d4edda;
|
|
654
|
+
border-left: 4px solid #28a745;
|
|
655
|
+
color: #155724;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.flash-error {
|
|
659
|
+
background-color: #f8d7da;
|
|
660
|
+
border-left: 4px solid #dc3545;
|
|
661
|
+
color: #721c24;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.flash-warning {
|
|
665
|
+
background-color: #fff3cd;
|
|
666
|
+
border-left: 4px solid #ffc107;
|
|
667
|
+
color: #856404;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.flash-info {
|
|
671
|
+
background-color: #d1ecf1;
|
|
672
|
+
border-left: 4px solid #17a2b8;
|
|
673
|
+
color: #0c5460;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/* Dark mode flash colors */
|
|
677
|
+
@media (prefers-color-scheme: dark) {
|
|
678
|
+
.flash-success { background-color: #1a3d24; color: #9ae6b4; }
|
|
679
|
+
.flash-error { background-color: #3d1a1a; color: #feb2b2; }
|
|
680
|
+
.flash-warning { background-color: #3d3d1a; color: #faf089; }
|
|
681
|
+
.flash-info { background-color: #1a3d3d; color: #90cdf4; }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
[data-theme="dark"] .flash-success { background-color: #1a3d24; color: #9ae6b4; }
|
|
685
|
+
[data-theme="dark"] .flash-error { background-color: #3d1a1a; color: #feb2b2; }
|
|
686
|
+
[data-theme="dark"] .flash-warning { background-color: #3d3d1a; color: #faf089; }
|
|
687
|
+
[data-theme="dark"] .flash-info { background-color: #1a3d3d; color: #90cdf4; }
|
|
688
|
+
|
|
689
|
+
.flash-message {
|
|
690
|
+
flex: 1;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.flash-dismiss {
|
|
694
|
+
background: none;
|
|
695
|
+
border: none;
|
|
696
|
+
font-size: 1.25rem;
|
|
697
|
+
cursor: pointer;
|
|
698
|
+
opacity: 0.5;
|
|
699
|
+
margin-left: var(--spacing-sm);
|
|
700
|
+
padding: 0;
|
|
701
|
+
line-height: 1;
|
|
702
|
+
color: inherit;
|
|
703
|
+
box-shadow: none;
|
|
704
|
+
border-radius: 0;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.flash-dismiss:hover {
|
|
708
|
+
opacity: 1;
|
|
709
|
+
background: none;
|
|
710
|
+
box-shadow: none;
|
|
711
|
+
transform: none;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
@keyframes flash-slide-in {
|
|
715
|
+
from {
|
|
716
|
+
transform: translateX(100%);
|
|
717
|
+
opacity: 0;
|
|
718
|
+
}
|
|
719
|
+
to {
|
|
720
|
+
transform: translateX(0);
|
|
721
|
+
opacity: 1;
|
|
722
|
+
}
|
|
638
723
|
}
|
|
639
724
|
|
|
640
725
|
/* ========================================
|
|
@@ -936,6 +1021,11 @@ main.container.admin-layout {
|
|
|
936
1021
|
color: var(--color-text-muted);
|
|
937
1022
|
}
|
|
938
1023
|
|
|
1024
|
+
.status-scheduled {
|
|
1025
|
+
background-color: #ffc107;
|
|
1026
|
+
color: #856404;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
939
1029
|
.type-badge {
|
|
940
1030
|
display: inline-block;
|
|
941
1031
|
padding: 0.125rem 0.5rem;
|
|
@@ -19,14 +19,54 @@
|
|
|
19
19
|
<label for="content">Content</label>
|
|
20
20
|
<textarea id="content" name="content" rows="15">{{ page.content if page else '' }}</textarea>
|
|
21
21
|
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
<fieldset class="form-group">
|
|
23
|
+
<legend>Publication</legend>
|
|
24
|
+
|
|
25
|
+
<label>
|
|
26
|
+
<input type="checkbox" name="is_published" {% if page and page.is_published %}checked{% endif %}>
|
|
27
|
+
Published
|
|
28
|
+
</label>
|
|
29
|
+
|
|
30
|
+
<label for="order">Display Order</label>
|
|
31
|
+
<input type="number" id="order" name="order" value="{{ page.order if page else 0 }}" min="0">
|
|
32
|
+
<small class="text-muted">Lower numbers appear first (default: 0)</small>
|
|
33
|
+
|
|
34
|
+
<label for="publish_at">Schedule Publish</label>
|
|
35
|
+
<input type="datetime-local" id="publish_at" name="publish_at" value="{{ page.publish_at.strftime('%Y-%m-%dT%H:%M') if page and page.publish_at else '' }}">
|
|
36
|
+
<small class="text-muted">Leave empty to publish immediately when published is checked</small>
|
|
37
|
+
</fieldset>
|
|
38
|
+
|
|
39
|
+
<details class="seo-settings">
|
|
40
|
+
<summary>SEO Settings</summary>
|
|
41
|
+
<fieldset>
|
|
42
|
+
<label for="meta_description">Meta Description</label>
|
|
43
|
+
<textarea id="meta_description" name="meta_description" rows="3" maxlength="320" placeholder="Brief description for search engines (max 320 chars)">{{ page.meta_description if page else '' }}</textarea>
|
|
44
|
+
|
|
45
|
+
<label for="meta_robots">Meta Robots</label>
|
|
46
|
+
<select id="meta_robots" name="meta_robots">
|
|
47
|
+
<option value="">Default (index, follow)</option>
|
|
48
|
+
<option value="noindex" {% if page and page.meta_robots == 'noindex' %}selected{% endif %}>noindex</option>
|
|
49
|
+
<option value="nofollow" {% if page and page.meta_robots == 'nofollow' %}selected{% endif %}>nofollow</option>
|
|
50
|
+
<option value="noindex, nofollow" {% if page and page.meta_robots == 'noindex, nofollow' %}selected{% endif %}>noindex, nofollow</option>
|
|
51
|
+
</select>
|
|
52
|
+
|
|
53
|
+
<label for="og_title">OpenGraph Title</label>
|
|
54
|
+
<input type="text" id="og_title" name="og_title" value="{{ page.og_title if page else '' }}" placeholder="Custom title for social sharing (uses page title if empty)">
|
|
55
|
+
|
|
56
|
+
<label for="og_description">OpenGraph Description</label>
|
|
57
|
+
<textarea id="og_description" name="og_description" rows="2" placeholder="Custom description for social sharing">{{ page.og_description if page else '' }}</textarea>
|
|
58
|
+
|
|
59
|
+
<label for="og_image">OpenGraph Image URL</label>
|
|
60
|
+
<input type="url" id="og_image" name="og_image" value="{{ page.og_image if page else '' }}" placeholder="https://example.com/image.jpg">
|
|
61
|
+
</fieldset>
|
|
62
|
+
</details>
|
|
26
63
|
|
|
27
64
|
<div class="form-actions">
|
|
28
65
|
<button type="submit">{{ "Update" if page else "Create" }} Page</button>
|
|
29
66
|
<a href="/admin/pages" role="button" class="outline">Cancel</a>
|
|
67
|
+
{% if page %}
|
|
68
|
+
<a href="/admin/pages/{{ page.id }}/revisions" role="button" class="outline secondary">View History</a>
|
|
69
|
+
{% endif %}
|
|
30
70
|
</div>
|
|
31
71
|
</form>
|
|
32
72
|
{% endblock %}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<table class="admin-table">
|
|
15
15
|
<thead>
|
|
16
16
|
<tr>
|
|
17
|
+
<th>Order</th>
|
|
17
18
|
<th>Title</th>
|
|
18
19
|
<th>Slug</th>
|
|
19
20
|
<th>Status</th>
|
|
@@ -24,13 +25,19 @@
|
|
|
24
25
|
<tbody>
|
|
25
26
|
{% for page in pages %}
|
|
26
27
|
<tr>
|
|
28
|
+
<td class="text-muted">{{ page.order }}</td>
|
|
27
29
|
<td>
|
|
28
30
|
<a href="/{{ page.slug }}">{{ page.title }}</a>
|
|
29
31
|
</td>
|
|
30
32
|
<td class="text-muted">/{{ page.slug }}</td>
|
|
31
33
|
<td>
|
|
32
34
|
{% if page.is_published %}
|
|
33
|
-
|
|
35
|
+
{% if page.publish_at and page.publish_at > now() %}
|
|
36
|
+
<span class="status-badge status-scheduled">Scheduled</span>
|
|
37
|
+
<small class="text-muted">{{ page.publish_at.strftime('%b %d, %Y %H:%M') }}</small>
|
|
38
|
+
{% else %}
|
|
39
|
+
<span class="status-badge status-published">Published</span>
|
|
40
|
+
{% endif %}
|
|
34
41
|
{% else %}
|
|
35
42
|
<span class="status-badge status-draft">Draft</span>
|
|
36
43
|
{% endif %}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{% extends "admin/base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Revision History - {{ page.title }} - Admin - {{ site_name() }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block admin_content %}
|
|
6
|
+
<hgroup>
|
|
7
|
+
<h1>Revision History</h1>
|
|
8
|
+
<p>{{ page.title }}</p>
|
|
9
|
+
</hgroup>
|
|
10
|
+
|
|
11
|
+
<div class="form-actions" style="margin-bottom: 1rem;">
|
|
12
|
+
<a href="/admin/pages/{{ page.id }}/edit" role="button" class="outline">← Back to Edit</a>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
{% if revisions %}
|
|
16
|
+
<table class="admin-table">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr>
|
|
19
|
+
<th>#</th>
|
|
20
|
+
<th>Title</th>
|
|
21
|
+
<th>Changed By</th>
|
|
22
|
+
<th>Date</th>
|
|
23
|
+
<th>Actions</th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
{% for revision in revisions %}
|
|
28
|
+
<tr>
|
|
29
|
+
<td>{{ revision.revision_number }}</td>
|
|
30
|
+
<td>{{ revision.title }}</td>
|
|
31
|
+
<td>
|
|
32
|
+
{% if revision.user %}
|
|
33
|
+
{{ revision.user.name or revision.user.email }}
|
|
34
|
+
{% else %}
|
|
35
|
+
<span class="text-muted">System</span>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</td>
|
|
38
|
+
<td>{{ revision.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
39
|
+
<td class="actions-cell">
|
|
40
|
+
<form method="post" action="/admin/pages/{{ page.id }}/revisions/{{ revision.id }}/restore" class="inline-form" onsubmit="return confirm('Restore to revision #{{ revision.revision_number }}? This will create a new revision of the current state first.');">
|
|
41
|
+
<button type="submit" class="outline small">Restore</button>
|
|
42
|
+
</form>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
45
|
+
{% endfor %}
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
{% else %}
|
|
49
|
+
<p class="no-items">No revisions yet. Revisions are created when you save changes to a page.</p>
|
|
50
|
+
{% endif %}
|
|
51
|
+
|
|
52
|
+
<details style="margin-top: 2rem;">
|
|
53
|
+
<summary>Current Content Preview</summary>
|
|
54
|
+
<div class="revision-preview" style="background: var(--surface-color); padding: 1rem; border-radius: 4px; margin-top: 0.5rem;">
|
|
55
|
+
<h3>{{ page.title }}</h3>
|
|
56
|
+
<div>{{ page.content | markdown | safe }}</div>
|
|
57
|
+
</div>
|
|
58
|
+
</details>
|
|
59
|
+
{% endblock %}
|