Multi-page app index

I’m using the standard panel serve *.py for serving multiple apps. I’ve seen examples such as Modify homepage for multipage - #2 by Marc where an index.html is provided which lists the available apps via jinja-injected items.

I was wondering, instead of .html, if there’s a way to have a “normal” .py panel page that lists the apps instead. I see two benefits with .py approach:

  • You don’t have to venture outside of the standard panel support (widgets, panes, etc.)
  • You can auto-reload updates to the index page when designing it

Is that possible somehow?

I guess the gist is - is it possible to get an equivalent of items - i.e. page titles / URL pairs - that is injected via jinja from within a panel app somehow?

2 Likes

Hi @cebaa

Welcome to the community.

You can replace the default index.html page with any .html, .py, .ipynb or .md file via the --index <path-to-index-file> flag.

To illustrate I vibe coded the index page below

index.py
"""
Panel App Gallery

A Panel-based index page that serves as a gallery of Panel applications with search and pagination functionality.
This demonstrates how to use `panel serve *.py --index index.py` to create a custom Python-based index page
instead of relying on HTML/Jinja templates.

Features:

- Real-time search functionality that filters apps by name and description
- Responsive thumbnails and app metadata display
- Pagination support when there are more than APPS_PER_PAGE results
- Modern Design styling with hover effects
- Auto-discovery of Panel apps in the current directory

Usage:
    panel serve *.py --index index.py --dev --show

This app provides an interactive way to browse and search through a collection of Panel applications,
making it easy to find specific apps in larger collections. Each app displays its name, description,
and thumbnail image with direct links to launch the application.
"""

import math
import panel as pn
import param
from pathlib import Path
from typing import List

# Configuration
APPS_PER_PAGE = 6
ACCENT_COLOR = "#4099da"
ACCENT_FILL_HOVER = "#0072b5"

# Initialize Panel with required extensions
pn.extension("tabulator", sizing_mode="stretch_width")

class AppInfo:
    """Data class to hold information about a Panel app"""
    
    def __init__(self, name: str, filename: str, description: str = "", thumbnail: str = ""):
        self.name = name
        self.filename = filename
        self.description = description or f"Panel application: {name}"
        self.thumbnail = thumbnail or self._get_default_thumbnail()
    
    def _get_default_thumbnail(self) -> str:
        """Generate a default thumbnail placeholder"""
        return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgdmlld0JveD0iMCAwIDIwMCAxNTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTUwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04NyA2NUg5M1Y1OUg4N1Y2NVoiIGZpbGw9IiM5Q0E4QjciLz4KPHA5YXRoIGQ9Ik05NyA2MUgxMTNWODdIOTdWNjFaIiBmaWxsPSIjOUNBOEI3Ii8+CjxwYXRoIGQ9Ik04MyA3NEgxMTdWNzlIODNWNzRaIiBmaWxsPSIjOUNBOEI3Ii8+CjxwYXRoIGQ9Ik04MyA4Mkg5OFY4N0g4M1Y4MloiIGZpbGw9IiM5Q0E4QjciLz4KPC9zdmc+Cg=="
    
    def url(self) -> str:
        """Generate the URL to launch the app"""
        return f"./{self.filename.replace('.py', '')}"

class AppGallery(pn.viewable.Viewer):
    """
    A Panel-based gallery for browsing and searching Panel applications.
    
    This component provides:

    - Search functionality with real-time filtering
    - Pagination for large app collections
    - Thumbnail previews and app metadata
    - Direct links to launch applications
    """
    
    # Parameters for state management
    search_term = param.String(default="", doc="Current search term")
    current_page = param.Integer(default=1, bounds=(1, None), doc="Current page number")
    apps_per_page = param.Integer(default=APPS_PER_PAGE, bounds=(1, 50), doc="Number of apps per page")
    
    def __init__(self, **params):
        super().__init__(**params)
        
        # Discover available Panel apps
        self.all_apps = self._discover_apps()
        
        # Create UI components
        self._create_ui()
        
    def _discover_apps(self) -> List[AppInfo]:
        """Discover Panel apps in the current directory"""
        apps = []
        
        # Add some demo apps for illustration
        demo_apps = [
            AppInfo("Environmental Dashboard", "panel_environmental_dashboard.py", 
                   "Interactive dashboard for environmental data analysis with real-time charts and filters"),
            AppInfo("Sales Analytics", "sales_dashboard.py", 
                   "Comprehensive sales performance dashboard with KPI tracking and trend analysis"),
            AppInfo("Material UI Demo", "material_ui_button_demo.py", 
                   "Showcase of Panel's Material UI components and styling options"),
            AppInfo("Data Explorer", "data_explorer.py", 
                   "Interactive data exploration tool with filtering, sorting, and visualization capabilities"),
            AppInfo("ML Model Comparison", "ml_comparison.py", 
                   "Compare machine learning model performance with interactive metrics and plots"),
            AppInfo("Time Series Analysis", "timeseries_analyzer.py", 
                   "Advanced time series analysis with forecasting and anomaly detection"),
            AppInfo("Portfolio Tracker", "portfolio_tracker.py", 
                   "Track investment portfolio performance with real-time updates and analytics"),
            AppInfo("Weather Forecast", "weather_app.py", 
                   "Interactive weather forecast application with maps and detailed predictions")
        ]
        
        apps.extend(demo_apps)
        
        return sorted(apps, key=lambda x: x.name)
    
    def _create_ui(self):
        """Create the user interface components"""
        with pn.config.set(sizing_mode="stretch_width"):
            # Header
            self._header = pn.pane.HTML("""
            <div style="text-align: center; padding: 20px 0;">
                <h1 style="color: #2E86AB; margin-bottom: 10px;">App Gallery</h1>
                <p style="color: #666; font-size: 18px;">Discover and explore applications</p>
            </div>
            """)
            
            # Search bar
            self._search_input = pn.widgets.TextInput.from_param(
                self.param.search_term,
                placeholder="Search apps by name or description...",
                margin=(10, 20), 
                sizing_mode="stretch_width",
            )
            
            # Apps grid container
            self._apps_container = pn.FlexBox(
                sizing_mode="stretch_width",
                align="center",
            )
            
            # Pagination controls
            self._pagination = pn.Row(
                sizing_mode="stretch_width",
                styles = {"margin": "0 auto"}
            )
            
            # Main layout
            self._layout = pn.Column(
                self._header,
                self._search_input,
                self._apps_container,
                pn.Spacer(sizing_mode="stretch_height"),
                self._pagination,
                max_width=975,
                styles={"margin": "0 auto"},
                align="center",
                sizing_mode="stretch_both"
            )
        
        # Initial update will be triggered automatically by parameter reactivity
        self._update_display()
    
    @param.depends('search_term', watch=True)
    def _on_search_change(self):
        """Handle search term changes by resetting to first page"""
        self.current_page = 1
    
    def _get_filtered_apps(self) -> List[AppInfo]:
        """Get filtered apps based on search term"""
        # Filter apps based on search term
        search_term = str(self.search_term or "")
        if search_term:
            filtered_apps = [
                app for app in self.all_apps
                if (search_term.lower() in app.name.lower() or 
                    search_term.lower() in app.description.lower())
            ]
        else:
            filtered_apps = self.all_apps
        
        return filtered_apps
    
    def _get_paginated_apps(self, apps: List[AppInfo]) -> List[AppInfo]:
        """Get apps for current page"""
        # Safely get parameter values with defaults
        current_page = max(1, getattr(self, 'current_page', 1) or 1)
        apps_per_page = max(1, getattr(self, 'apps_per_page', APPS_PER_PAGE) or APPS_PER_PAGE)
        
        start_idx = (current_page - 1) * apps_per_page
        end_idx = start_idx + apps_per_page
        return apps[start_idx:end_idx]
    
    def _create_app_card(self, app: AppInfo) -> pn.pane.HTML:
        """Create a card for a single app"""
        card_html = f"""
        <div style="
            border: 1px solid var(--neutral-stroke-rest);
            border-radius: 12px;
            padding: 20px;
            margin: 10px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            transition: all 0.3s ease;
            height: 320px;
            display: flex;
            flex-direction: column;
        " onmouseover="this.style.transform='translateY(-5px)'; this.style.boxShadow='0 8px 25px rgba(0,0,0,0.15)'"
           onmouseout="this.style.transform='translateY(0px)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)'">
            
            <div style="text-align: center; margin-bottom: 15px;">
                <img src="{app.thumbnail}" 
                     style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; background: #f5f5f5;" 
                     alt="App thumbnail"/>
            </div>
            
            <h3 style="color: {ACCENT_COLOR}; margin: 0 0 10px 0; font-size: 18px; text-align: center;">
                {app.name}
            </h3>
            
            <p style="color: #666; margin: 0 0 15px 0; font-size: 14px; line-height: 1.4; flex-grow: 1; overflow: hidden;">
                {app.description[:120]}{'...' if len(app.description) > 120 else ''}
            </p>
            
            <div style="text-align: center; margin-top: auto;">
                <a href="{app.url()}" 
                   style="
                       background: {ACCENT_COLOR};
                       color: white;
                       padding: 8px 16px;
                       text-decoration: none;
                       border-radius: 6px;
                       font-weight: 500;
                       transition: background-color 0.2s;
                   "
                   onmouseover="this.style.backgroundColor='{ACCENT_FILL_HOVER}'"
                   onmouseout="this.style.backgroundColor='{ACCENT_COLOR}'">
                    Open App →
                </a>
            </div>
        </div>
        """
        return pn.pane.HTML(card_html, width=300, height=320)
    
    def _create_pagination_controls(self, total_apps: int) -> pn.Row:
        """Create pagination controls"""
        # Safely get parameter values with defaults
        current_page = max(1, getattr(self, 'current_page', 1) or 1)
        apps_per_page = max(1, getattr(self, 'apps_per_page', APPS_PER_PAGE) or APPS_PER_PAGE)
        total_pages = math.ceil(total_apps / apps_per_page)
        
        if total_pages <= 1:
            return pn.Row()
        
        controls = []
        
        # Previous button
        if current_page > 1:
            prev_btn = pn.widgets.Button(
                name="← Previous",
                button_style="outline",
            )
            prev_btn.on_click(lambda event: self._go_to_page(current_page - 1))
            controls.append(prev_btn)
        
        # Page numbers
        start_page = max(1, current_page - 2)
        end_page = min(total_pages, current_page + 2)
        
        for page_num in range(start_page, end_page + 1):
            if page_num == current_page:
                # Current page (highlighted)
                page_btn = pn.widgets.Button(
                    name=str(page_num),
                    button_type="primary",
                    margin=(5, 5),
                    width=40
                )
            else:
                # Other pages
                page_btn = pn.widgets.Button(
                    name=str(page_num),
                    button_style="outline",
                    margin=(5, 5),
                    width=40
                )
                page_btn.on_click(lambda event, p=page_num: self._go_to_page(p))
            
            controls.append(page_btn)
        
        # Next button
        if current_page < total_pages:
            next_btn = pn.widgets.Button(
                name="Next →",
                button_style="outline",
                margin=(5, 10)
            )
            next_btn.on_click(lambda event: self._go_to_page(current_page + 1))
            controls.append(next_btn)
        
        return pn.Row(*controls)
    
    def _go_to_page(self, page_num: int):
        """Navigate to a specific page"""
        self.current_page = page_num  # Reactivity will handle the update automatically
    
    @param.depends('search_term', 'current_page', watch=True)
    def _update_display(self):
        """Update the apps display based on current filters and page"""
        # Get filtered apps
        filtered_apps = self._get_filtered_apps()
        
        # Get paginated apps
        paginated_apps = self._get_paginated_apps(filtered_apps)
        
        # Clear current display
        self._apps_container.clear()
        self._pagination.clear()
        
        if not paginated_apps:
            # No apps found
            no_apps_msg = pn.pane.HTML(
                "<div style='text-align: center; padding: 40px; color: #666;'>"
                "<h3>No apps found</h3>"
                "<p>Try adjusting your search terms.</p>"
                "</div>",
                sizing_mode="stretch_width"
            )
            self._apps_container.append(no_apps_msg)
        else:
            # Create grid of app cards
            self._apps_container[:] = [self._create_app_card(app) for app in paginated_apps]
        
        # Update pagination
        pagination_controls = self._create_pagination_controls(len(filtered_apps))
        if len(pagination_controls):
            self._pagination[:] = pagination_controls
    
    def __panel__(self):
        """Return the panel for notebook display"""
        return self._layout
    
    @classmethod
    def create_app(cls, **params):
        """Create a servable app instance"""
        instance = cls(**params)
        
        return pn.template.FastListTemplate(
            title="Holoviz Panel",
            sidebar_width=0,  # No sidebar needed
            header_background=ACCENT_COLOR,
            main_layout=None,
            main = instance
        )
        
        

# Serve the app
if __name__ == "__main__":
    # Run with: python index.py
    AppGallery.create_app().show(port=5007, autoreload=True, open=True)
elif pn.state.served:
    # Run with: panel serve *.py --index index.py --dev --show
    AppGallery.create_app().servable()

Run with: panel serve *.py --index index.py --dev

1 Like