diff --git a/examples/views/file_upload/file_upload.css b/examples/views/file_upload/file_upload.css new file mode 100644 index 0000000..97601b8 --- /dev/null +++ b/examples/views/file_upload/file_upload.css @@ -0,0 +1,145 @@ +.upload-wrapper { + width: 500px; + margin: 0 auto; +} + +.file-item { + display: flex; + padding: 8px; + border: 1px solid #ddd; + border-radius: 8px; + margin-bottom: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.file-thumbnail { + width: 64px; + height: 64px; + border-radius: 4px; + margin-right: 10px; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +.file-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.file-info { + flex-grow: 1; +} + +.file-info p { + margin: 0; + font-size: 14px; + color: #333; +} + +.file-item.error { + background-color: #f8d7da; +} + +.file-progress { + height: 6px; + background-color: #e0e0e0; + border-radius: 3px; + overflow: hidden; + margin: 4px 0; +} + +.file-progress-bar { + height: 100%; + background-color: #4caf50; + border-radius: 3px; +} + +.file-cancel { + background-color: transparent; + border: none; + cursor: pointer; + font-size: 18px; + color: #999; + margin-left: 10px; +} + +.uploaded-files { + margin-top: 20px; +} + +.upload-button-container { + text-align: right; +} + +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; +} + +.photo-grid .photo-item { + background: white; + padding: 10px; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; + position: relative; + overflow: hidden; +} + +.photo-grid .photo-item::before { + content: ""; + display: block; + padding-bottom: 100%; +} + +.photo-grid .photo-item img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; +} + +.photo-caption { + margin-top: auto; + padding: 5px 0; + font-size: 12px; + color: #333; + background-color: white; + width: 100%; + text-align: center; + border-top: 1px solid #ddd; + position: absolute; + bottom: 0; +} + +.error-text { + color: #721c24; + font-size: 12px; +} + +.error-message { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; +} + +button { + margin-right: 0; +} + +.upload-instructions { + font-size: 0.9em; + font-style: oblique; + color: #999; +} \ No newline at end of file diff --git a/examples/views/file_upload/file_upload.html b/examples/views/file_upload/file_upload.html index c17996d..b1a1832 100644 --- a/examples/views/file_upload/file_upload.html +++ b/examples/views/file_upload/file_upload.html @@ -1,151 +1,3 @@ - -

📁 File Upload with PyView

@@ -154,7 +6,8 @@

📁 File Upload with PyView

style="border: 2px dashed #ccc; padding: 10px; margin: 10px 0; width: 500px">

Add up to {{upload_config.constraints.max_files}} photos (max - {{upload_config.constraints.max_file_size | readable_size}})

+ {{upload_config.constraints.max_file_size | readable_size}}) +

{{upload_config | live_file_input}} ... or drag and drop files here diff --git a/examples/views/kanban/kanban.css b/examples/views/kanban/kanban.css new file mode 100644 index 0000000..c8e2b9f --- /dev/null +++ b/examples/views/kanban/kanban.css @@ -0,0 +1,169 @@ +main { + margin: 0; + padding: 20px; + display: flex; + justify-content: center; + align-items: start; + height: 100vh; +} + +h1 { + margin-top: 12px; + text-align: center; +} + +.kanban-board { + display: flex; + gap: 20px; +} + +.kanban-list { + background-color: #f0f2f5; + border-radius: 8px; + width: 300px; + padding: 15px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; +} + +.kanban-list h3 { + margin: 0; + padding: 0; + font-weight: 700; + display: flex; + align-items: center; + color: #333; + font-size: 16px; + justify-content: space-between; +} + +.kanban-list h3 .status-container { + display: flex; + align-items: center; +} + +.kanban-list h3 .status-circle { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 8px; +} + +.kanban-list h3 .item-count { + background-color: #E0E0E0; + border-radius: 12px; + padding: 2px 6px; + font-size: 12px; + color: #555; + margin-left: 8px; +} + +.kanban-list h3 .item-count.error { + background-color: #FFE5E5; + color: #D32F2F; +} + +.kanban-list h3 .add-button { + background-color: #EDEDED; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 18px; + line-height: 18px; + color: #555; + transition: background-color 0.2s ease; + padding: 0; + box-sizing: content-box; +} + + +.kanban-list h3 .add-button:hover { + background-color: #D0D0D0; +} + + +.kanban-cards { + flex-grow: 1; + margin-top: 10px; +} + +.kanban-card { + background-color: #fff; + border-radius: 10px; + margin-bottom: 15px; + padding: 15px; + display: flex; + flex-direction: column; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: box-shadow 0.2s; +} + +.kanban-card:hover { + box-shadow: 0 3px 15px rgba(0, 0, 0, 0.2); +} + +.kanban-card .card-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.kanban-card .card-header span { + font-size: 14px; + font-weight: 700; + color: #757575; +} + +.kanban-card .card-title { + font-size: 18px; + font-weight: 700; + margin: 0 0 10px; + color: #333; +} + +.kanban-card .card-description { + font-size: 14px; + color: #757575; + margin: 0 0 15px; +} + +.kanban-card .card-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.kanban-card img { + border-radius: 50%; + width: 32px; + height: 32px; +} + +.kanban-card .priority { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #fff; +} + +.kanban-card .priority.high { + background-color: rgba(161, 39, 39, 0.2); +} + +.kanban-card .priority.mid { + background-color: rgba(243, 168, 59, 0.2); +} + +.kanban-card .priority.low { + background-color: rgba(54, 114, 184, 0.2); +} \ No newline at end of file diff --git a/examples/views/kanban/kanban.html b/examples/views/kanban/kanban.html index 80a436d..2bb7f75 100644 --- a/examples/views/kanban/kanban.html +++ b/examples/views/kanban/kanban.html @@ -1,175 +1,3 @@ - -

📋 Kanban Board

diff --git a/examples/views/maps/map.css b/examples/views/maps/map.css new file mode 100644 index 0000000..7da3625 --- /dev/null +++ b/examples/views/maps/map.css @@ -0,0 +1,47 @@ +body { + max-width: max-content; + padding-left: 24px; +} + +.map_wrapper { + display: grid; + grid-template-columns: 300px 1fr; + gap: 12px; + padding: 12px; +} + +.sidebar ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +.sidebar ul li { + margin-bottom: 4px; +} + +.sidebar ul li a { + display: inline-block; + padding: 4px; + color: inherit; +} + +.sidebar ul li a .icon { + transition: transform 0.3s ease; + display: inline-block; + padding-right: 4px; +} + +.sidebar ul li a:hover .name { + text-decoration: underline; + text-decoration-color: green; +} + +.sidebar ul li a:hover .icon { + transform: scale(1.5); +} + +#map { + height: 640px; + width: 800px; +} \ No newline at end of file diff --git a/examples/views/maps/map.html b/examples/views/maps/map.html index 85b51ab..f1058e3 100644 --- a/examples/views/maps/map.html +++ b/examples/views/maps/map.html @@ -1,53 +1,3 @@ - -

🌲 National Park Planner

diff --git a/examples/views/presence/presence.css b/examples/views/presence/presence.css new file mode 100644 index 0000000..5467b17 --- /dev/null +++ b/examples/views/presence/presence.css @@ -0,0 +1,57 @@ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.5); + opacity: 0.7; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +.user-list { + display: flex; + flex-wrap: wrap; +} + +.user-item { + display: flex; + align-items: center; + margin: 5px; + width: 45%; +} + +.color-circle { + width: 15px; + height: 15px; + border-radius: 50%; + margin-right: 10px; +} + +.flash-message { + padding: 15px; + position: fixed; + top: 10px; + right: 10px; + z-index: 1000; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + display: block; + animation: fadeInOut 5s ease-in-out forwards; +} + +.flash-message.joined { + background-color: #A5D6A7; + color: #1B5E20; +} + +.flash-message.left { + background-color: #FFCDD2; + color: #B71C1C; +} \ No newline at end of file diff --git a/examples/views/presence/presence.html b/examples/views/presence/presence.html index e6a0e31..caada55 100644 --- a/examples/views/presence/presence.html +++ b/examples/views/presence/presence.html @@ -1,63 +1,3 @@ - -
Open this example in multiple windows to see the connected list diff --git a/pyview/live_view.py b/pyview/live_view.py index 14a34a6..5f2e091 100644 --- a/pyview/live_view.py +++ b/pyview/live_view.py @@ -1,7 +1,12 @@ from typing import TypeVar, Generic, Optional, Union, Any from .live_socket import LiveViewSocket, UnconnectedSocket -from pyview.template import LiveTemplate, template_file, RenderedContent, LiveRender -import inspect +from pyview.template import ( + LiveTemplate, + template_file, + RenderedContent, + LiveRender, + find_associated_file, +) from pyview.events import InfoEvent from urllib.parse import ParseResult @@ -43,11 +48,6 @@ async def render(self, assigns: T) -> RenderedContent: def _find_render(m: LiveView) -> Optional[LiveTemplate]: - cf = inspect.getfile(m.__class__) - return _find_template(cf) - - -def _find_template(cf: str) -> Optional[LiveTemplate]: - if cf.endswith(".py"): - cf = cf[:-3] - return template_file(cf + ".html") + html = find_associated_file(m, ".html") + if html is not None: + return template_file(html) diff --git a/pyview/pyview.py b/pyview/pyview.py index 2f68fa6..c17ca64 100644 --- a/pyview/pyview.py +++ b/pyview/pyview.py @@ -14,7 +14,12 @@ from .ws_handler import LiveSocketHandler from .live_view import LiveView from .live_routes import LiveViewLookup -from .template import RootTemplate, RootTemplateContext, defaultRootTemplate +from .template import ( + RootTemplate, + RootTemplateContext, + defaultRootTemplate, + find_associated_css, +) class PyView(Starlette): @@ -57,6 +62,8 @@ async def liveview_container( await lv.handle_params(urlparse(url._url), parse_qs(url.query), s) r = await lv.render(s.context) + liveview_css = find_associated_css(lv) + id = str(uuid.uuid4()) context: RootTemplateContext = { @@ -65,6 +72,7 @@ async def liveview_container( "title": s.live_title, "csrf_token": generate_csrf_token("lv:phx-" + id), "session": serialize_session(session), + "additional_head_elements": liveview_css, } return HTMLResponse(template(context)) diff --git a/pyview/template/__init__.py b/pyview/template/__init__.py index f7db6e3..cae3a98 100644 --- a/pyview/template/__init__.py +++ b/pyview/template/__init__.py @@ -1,3 +1,17 @@ from pyview.vendor.ibis import Template from .live_template import LiveTemplate, template_file, RenderedContent, LiveRender from .root_template import RootTemplate, RootTemplateContext, defaultRootTemplate +from .utils import find_associated_css, find_associated_file + +__all__ = [ + "Template", + "LiveTemplate", + "template_file", + "RenderedContent", + "LiveRender", + "RootTemplate", + "RootTemplateContext", + "defaultRootTemplate", + "find_associated_css", + "find_associated_file", +] diff --git a/pyview/template/root_template.py b/pyview/template/root_template.py index 4385d02..7ecf578 100644 --- a/pyview/template/root_template.py +++ b/pyview/template/root_template.py @@ -8,6 +8,7 @@ class RootTemplateContext(TypedDict): title: Optional[str] csrf_token: str session: Optional[str] + additional_head_elements: list[Markup] RootTemplate = Callable[[RootTemplateContext], str] @@ -45,6 +46,8 @@ def _defaultRootTemplate( ), ) + additional_head_elements = "\n".join(context["additional_head_elements"]) + return ( Markup( f""" @@ -56,8 +59,9 @@ def _defaultRootTemplate( - {css} + {css} + {additional_head_elements} """ ) diff --git a/pyview/template/utils.py b/pyview/template/utils.py new file mode 100644 index 0000000..9ce6780 --- /dev/null +++ b/pyview/template/utils.py @@ -0,0 +1,24 @@ +from typing import Optional +import inspect +import os +from markupsafe import Markup + + +def find_associated_file(o: object, extension: str) -> Optional[str]: + object_file = inspect.getfile(o.__class__) + + if object_file.endswith(".py"): + object_file = object_file[:-3] + + associated_file = object_file + extension + if os.path.isfile(associated_file): + return associated_file + + +def find_associated_css(o: object) -> list[Markup]: + css_file = find_associated_file(o, ".css") + if css_file: + with open(css_file, "r") as css: + return [Markup(f"")] + + return []