Enhance public access and error handling in site-related views and loaders
- Updated `DebugScopedRateThrottle` to allow public access for blueprint list requests with site filters. - Modified `SiteViewSet` and `SiteBlueprintViewSet` to permit public read access for list requests. - Enhanced `loadSiteDefinition` to resolve site slugs to IDs, improving the loading process for site definitions. - Improved error handling in `SiteDefinitionView` and `loadSiteDefinition` for better user feedback. - Adjusted CSS styles for better layout and alignment in shared components.
This commit is contained in:
Binary file not shown.
@@ -28,11 +28,19 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
|||||||
- IGNY8_DEBUG_THROTTLE environment variable is True
|
- IGNY8_DEBUG_THROTTLE environment variable is True
|
||||||
- User belongs to aws-admin or other system accounts
|
- User belongs to aws-admin or other system accounts
|
||||||
- User is admin/developer role
|
- User is admin/developer role
|
||||||
|
- Public blueprint list request with site filter (for Sites Renderer)
|
||||||
"""
|
"""
|
||||||
# Check if throttling should be bypassed
|
# Check if throttling should be bypassed
|
||||||
debug_bypass = getattr(settings, 'DEBUG', False)
|
debug_bypass = getattr(settings, 'DEBUG', False)
|
||||||
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
||||||
|
|
||||||
|
# Bypass for public blueprint list requests (Sites Renderer fallback)
|
||||||
|
public_blueprint_bypass = False
|
||||||
|
if hasattr(view, 'action') and view.action == 'list':
|
||||||
|
if hasattr(request, 'query_params') and request.query_params.get('site'):
|
||||||
|
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
|
||||||
|
public_blueprint_bypass = True
|
||||||
|
|
||||||
# Bypass for system account users (aws-admin, default-account, etc.)
|
# Bypass for system account users (aws-admin, default-account, etc.)
|
||||||
system_account_bypass = False
|
system_account_bypass = False
|
||||||
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||||
@@ -47,7 +55,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
|||||||
# If checking fails, continue with normal throttling
|
# If checking fails, continue with normal throttling
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if debug_bypass or env_bypass or system_account_bypass:
|
if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass:
|
||||||
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
|
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
|
||||||
# This allows testing throttle headers without blocking requests
|
# This allows testing throttle headers without blocking requests
|
||||||
if hasattr(self, 'get_rate'):
|
if hasattr(self, 'get_rate'):
|
||||||
|
|||||||
@@ -478,16 +478,26 @@ class SiteViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
|
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
|
||||||
|
# Allow public read access for list requests with slug filter (used by Sites Renderer)
|
||||||
|
if self.action == 'list' and self.request.query_params.get('slug'):
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
return [AllowAny()]
|
||||||
if self.action == 'create':
|
if self.action == 'create':
|
||||||
return [permissions.IsAuthenticated()]
|
return [permissions.IsAuthenticated()]
|
||||||
return [IsEditorOrAbove()]
|
return [IsEditorOrAbove()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return sites accessible to the current user."""
|
"""Return sites accessible to the current user."""
|
||||||
user = self.request.user
|
# If this is a public request (no auth) with slug filter, return site by slug
|
||||||
if not user or not user.is_authenticated:
|
if not self.request.user or not self.request.user.is_authenticated:
|
||||||
|
slug = self.request.query_params.get('slug')
|
||||||
|
if slug:
|
||||||
|
# Return queryset directly from model (bypassing base class account filtering)
|
||||||
|
return Site.objects.filter(slug=slug, is_active=True)
|
||||||
return Site.objects.none()
|
return Site.objects.none()
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
|
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
|
||||||
if user.is_admin_or_developer():
|
if user.is_admin_or_developer():
|
||||||
return Site.objects.all().distinct()
|
return Site.objects.all().distinct()
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class PageBlueprint(SiteSectorBaseModel):
|
|||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
('generating', 'Generating'),
|
('generating', 'Generating'),
|
||||||
('ready', 'Ready'),
|
('ready', 'Ready'),
|
||||||
|
('published', 'Published'),
|
||||||
]
|
]
|
||||||
|
|
||||||
site_blueprint = models.ForeignKey(
|
site_blueprint = models.ForeignKey(
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
'credits_remaining': credits_remaining,
|
'credits_remaining': credits_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer = CreditBalanceSerializer(data)
|
# Validate and serialize data
|
||||||
return success_response(data=serializer.data, request=request)
|
serializer = CreditBalanceSerializer(data=data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return success_response(data=serializer.validated_data, request=request)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
|
|||||||
@@ -219,61 +219,104 @@ class SiteDefinitionView(APIView):
|
|||||||
|
|
||||||
GET /api/v1/publisher/sites/{site_id}/definition/
|
GET /api/v1/publisher/sites/{site_id}/definition/
|
||||||
"""
|
"""
|
||||||
sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data')
|
try:
|
||||||
|
sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data')
|
||||||
# Try to find latest deployed version
|
|
||||||
site_dir = Path(sites_data_path) / 'clients' / str(site_id)
|
# Try to find latest deployed version
|
||||||
|
site_dir = Path(sites_data_path) / 'clients' / str(site_id)
|
||||||
if not site_dir.exists():
|
|
||||||
return error_response(
|
if not site_dir.exists():
|
||||||
f'Site {site_id} not found',
|
return error_response(
|
||||||
status.HTTP_404_NOT_FOUND,
|
error=f'Site {site_id} not found at {site_dir}. Site may not be deployed yet.',
|
||||||
request
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
request=request
|
||||||
|
)
|
||||||
# Look for latest version (check for 'latest' symlink or highest version number)
|
|
||||||
latest_path = site_dir / 'latest' / 'site.json'
|
# Look for latest version (check for 'latest' symlink or highest version number)
|
||||||
if latest_path.exists():
|
latest_path = site_dir / 'latest' / 'site.json'
|
||||||
|
if latest_path.exists():
|
||||||
|
try:
|
||||||
|
with open(latest_path, 'r', encoding='utf-8') as f:
|
||||||
|
definition = json.load(f)
|
||||||
|
return Response(definition, status=status.HTTP_200_OK)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Invalid JSON in site definition file: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to load site definition from {latest_path}: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: find highest version number
|
||||||
try:
|
try:
|
||||||
with open(latest_path, 'r', encoding='utf-8') as f:
|
version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')]
|
||||||
definition = json.load(f)
|
except PermissionError as e:
|
||||||
return Response(definition, status=status.HTTP_200_OK)
|
return error_response(
|
||||||
|
error=f'Permission denied accessing site directory: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return error_response(
|
return error_response(
|
||||||
f'Failed to load site definition: {str(e)}',
|
error=f'Error reading site directory: {str(e)}',
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
request
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
if not version_dirs:
|
||||||
|
return error_response(
|
||||||
|
error=f'No deployed versions found for site {site_id}. Site may not be deployed yet.',
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by version number (extract number from 'v1', 'v2', etc.)
|
||||||
|
try:
|
||||||
|
version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True)
|
||||||
|
latest_version_dir = version_dirs[0]
|
||||||
|
site_json_path = latest_version_dir / 'site.json'
|
||||||
|
|
||||||
|
if site_json_path.exists():
|
||||||
|
with open(site_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
definition = json.load(f)
|
||||||
|
return Response(definition, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
return error_response(
|
||||||
|
error=f'Site definition file not found at {site_json_path}',
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Invalid JSON in site definition file: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to load site definition: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: find highest version number
|
|
||||||
version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')]
|
|
||||||
if not version_dirs:
|
|
||||||
return error_response(
|
|
||||||
f'No deployed versions found for site {site_id}',
|
|
||||||
status.HTTP_404_NOT_FOUND,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by version number (extract number from 'v1', 'v2', etc.)
|
|
||||||
try:
|
|
||||||
version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True)
|
|
||||||
latest_version_dir = version_dirs[0]
|
|
||||||
site_json_path = latest_version_dir / 'site.json'
|
|
||||||
|
|
||||||
if site_json_path.exists():
|
|
||||||
with open(site_json_path, 'r', encoding='utf-8') as f:
|
|
||||||
definition = json.load(f)
|
|
||||||
return Response(definition, status=status.HTTP_200_OK)
|
|
||||||
except Exception as e:
|
|
||||||
return error_response(
|
return error_response(
|
||||||
f'Failed to load site definition: {str(e)}',
|
error=f'Site definition not found for site {site_id}',
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
request
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Catch any unhandled exceptions
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f'Unhandled exception in SiteDefinitionView: {str(e)}', exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error=f'Internal server error: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
return error_response(
|
|
||||||
f'Site definition not found for site {site_id}',
|
|
||||||
status.HTTP_404_NOT_FOUND,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,47 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||||
throttle_scope = 'site_builder'
|
throttle_scope = 'site_builder'
|
||||||
throttle_classes = [DebugScopedRateThrottle]
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""
|
||||||
|
Allow public read access for list requests with site filter (used by Sites Renderer fallback).
|
||||||
|
This allows the Sites Renderer to load blueprint data for deployed sites without authentication.
|
||||||
|
"""
|
||||||
|
# Allow public access for list requests with site filter (used by Sites Renderer)
|
||||||
|
if self.action == 'list' and self.request.query_params.get('site'):
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
return [AllowAny()]
|
||||||
|
# Otherwise use default permissions
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
|
def get_throttles(self):
|
||||||
|
"""
|
||||||
|
Bypass throttling for public list requests with site filter (used by Sites Renderer).
|
||||||
|
"""
|
||||||
|
# Bypass throttling for public requests (no auth) with site filter
|
||||||
|
if self.action == 'list' and self.request.query_params.get('site'):
|
||||||
|
if not self.request.user or not self.request.user.is_authenticated:
|
||||||
|
return [] # No throttling for public blueprint access
|
||||||
|
return super().get_throttles()
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Override to allow public access when filtering by site_id.
|
||||||
|
"""
|
||||||
|
# If this is a public request (no auth) with site filter, bypass base class filtering
|
||||||
|
# and return deployed blueprints for that site
|
||||||
|
if not self.request.user or not self.request.user.is_authenticated:
|
||||||
|
site_id = self.request.query_params.get('site')
|
||||||
|
if site_id:
|
||||||
|
# Return queryset directly from model (bypassing base class account/site filtering)
|
||||||
|
from igny8_core.business.site_building.models import SiteBlueprint
|
||||||
|
return SiteBlueprint.objects.filter(
|
||||||
|
site_id=site_id,
|
||||||
|
status='deployed'
|
||||||
|
).prefetch_related('pages').order_by('-version')
|
||||||
|
|
||||||
|
# For authenticated users, use base class filtering
|
||||||
|
return super().get_queryset()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
from igny8_core.auth.models import Site, Sector
|
from igny8_core.auth.models import Site, Sector
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-hero__eyebrow {
|
.shared-hero__eyebrow {
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
align-self: flex-start;
|
align-self: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-layout__hero {
|
.shared-layout__hero {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-layout__body {
|
.shared-layout__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -22,6 +25,18 @@
|
|||||||
|
|
||||||
.shared-layout__section {
|
.shared-layout__section {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-layout__section > * {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-layout__section h2,
|
||||||
|
.shared-layout__section h3,
|
||||||
|
.shared-layout__section h4 {
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-layout__sidebar {
|
.shared-layout__sidebar {
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ function App() {
|
|||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Site Renderer Routes (No Auth) */}
|
{/* Public Site Renderer Routes (No Auth) */}
|
||||||
<Route path="/:siteId/*" element={<SiteRenderer />} />
|
<Route path="/:siteSlug/:pageSlug?" element={<SiteRenderer />} />
|
||||||
|
<Route path="/:siteSlug" element={<SiteRenderer />} />
|
||||||
<Route path="/" element={<div>IGNY8 Sites Renderer</div>} />
|
<Route path="/" element={<div>IGNY8 Sites Renderer</div>} />
|
||||||
|
|
||||||
{/* Builder Routes (Auth Required) */}
|
{/* Builder Routes (Auth Required) */}
|
||||||
|
|||||||
@@ -41,11 +41,53 @@ function getApiBaseUrl(): string {
|
|||||||
const API_URL = getApiBaseUrl();
|
const API_URL = getApiBaseUrl();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load site definition by site ID.
|
* Resolve site slug to site ID.
|
||||||
* First tries to load from filesystem (deployed sites),
|
* Queries the Site API to get the site ID from the slug.
|
||||||
|
*/
|
||||||
|
async function resolveSiteIdFromSlug(siteSlug: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Query sites by slug - slug is unique per account, but we need to search across all accounts for public sites
|
||||||
|
const response = await axios.get(`${API_URL}/v1/auth/sites/`, {
|
||||||
|
params: { slug: siteSlug },
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sites = Array.isArray(response.data?.results) ? response.data.results :
|
||||||
|
Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
|
if (sites.length > 0) {
|
||||||
|
return sites[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Site with slug "${siteSlug}" not found`);
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
throw new Error(`Site with slug "${siteSlug}" not found`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to resolve site slug: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load site definition by site slug.
|
||||||
|
* First resolves slug to ID, then tries to load from filesystem (deployed sites),
|
||||||
* then falls back to API.
|
* then falls back to API.
|
||||||
*/
|
*/
|
||||||
export async function loadSiteDefinition(siteId: string): Promise<SiteDefinition> {
|
export async function loadSiteDefinition(siteSlug: string): Promise<SiteDefinition> {
|
||||||
|
// First, resolve slug to site ID
|
||||||
|
let siteId: number;
|
||||||
|
try {
|
||||||
|
siteId = await resolveSiteIdFromSlug(siteSlug);
|
||||||
|
} catch (error) {
|
||||||
|
throw error; // Re-throw slug resolution errors
|
||||||
|
}
|
||||||
|
|
||||||
// Try API endpoint for deployed site definition first
|
// Try API endpoint for deployed site definition first
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/v1/publisher/sites/${siteId}/definition/`, {
|
const response = await axios.get(`${API_URL}/v1/publisher/sites/${siteId}/definition/`, {
|
||||||
@@ -82,7 +124,7 @@ export async function loadSiteDefinition(siteId: string): Promise<SiteDefinition
|
|||||||
return transformBlueprintToSiteDefinition(blueprint);
|
return transformBlueprintToSiteDefinition(blueprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`No blueprint found for site ${siteId}`);
|
throw new Error(`No blueprint found for site ${siteSlug}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
throw new Error(`Failed to load site: ${error.message}`);
|
throw new Error(`Failed to load site: ${error.message}`);
|
||||||
@@ -110,6 +152,7 @@ function transformBlueprintToSiteDefinition(blueprint: any): SiteDefinition {
|
|||||||
type: page.type,
|
type: page.type,
|
||||||
blocks: page.blocks_json || [],
|
blocks: page.blocks_json || [],
|
||||||
status: page.status,
|
status: page.status,
|
||||||
|
order: page.order || 0,
|
||||||
})) || [],
|
})) || [],
|
||||||
config: blueprint.config_json || {},
|
config: blueprint.config_json || {},
|
||||||
created_at: blueprint.created_at,
|
created_at: blueprint.created_at,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { loadSiteDefinition } from '../loaders/loadSiteDefinition';
|
import { loadSiteDefinition } from '../loaders/loadSiteDefinition';
|
||||||
import { renderLayout } from '../utils/layoutRenderer';
|
import { renderLayout } from '../utils/layoutRenderer';
|
||||||
import type { SiteDefinition } from '../types';
|
import type { SiteDefinition } from '../types';
|
||||||
|
|
||||||
function SiteRenderer() {
|
function SiteRenderer() {
|
||||||
const { siteId } = useParams<{ siteId: string }>();
|
const { siteSlug, pageSlug } = useParams<{ siteSlug: string; pageSlug?: string }>();
|
||||||
const [siteDefinition, setSiteDefinition] = useState<SiteDefinition | null>(null);
|
const [siteDefinition, setSiteDefinition] = useState<SiteDefinition | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!siteId) {
|
if (!siteSlug) {
|
||||||
setError('Site ID is required');
|
setError('Site slug is required');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSiteDefinition(siteId)
|
loadSiteDefinition(siteSlug)
|
||||||
.then((definition) => {
|
.then((definition) => {
|
||||||
setSiteDefinition(definition);
|
setSiteDefinition(definition);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -26,7 +26,7 @@ function SiteRenderer() {
|
|||||||
setError(err.message || 'Failed to load site');
|
setError(err.message || 'Failed to load site');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [siteId]);
|
}, [siteSlug, pageSlug]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Loading site...</div>;
|
return <div>Loading site...</div>;
|
||||||
@@ -40,9 +40,122 @@ function SiteRenderer() {
|
|||||||
return <div>Site not found</div>;
|
return <div>Site not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build navigation from site definition
|
||||||
|
// Show pages that are published, ready, or in navigation (excluding home and draft/generating)
|
||||||
|
const navigation = siteDefinition.navigation || siteDefinition.pages
|
||||||
|
.filter(p =>
|
||||||
|
p.slug !== 'home' &&
|
||||||
|
(p.status === 'published' || p.status === 'ready' || siteDefinition.navigation?.some(n => n.slug === p.slug))
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Try to get order from navigation or use page order
|
||||||
|
const navA = siteDefinition.navigation?.find(n => n.slug === a.slug);
|
||||||
|
const navB = siteDefinition.navigation?.find(n => n.slug === b.slug);
|
||||||
|
return (navA?.order ?? a.order ?? 0) - (navB?.order ?? b.order ?? 0);
|
||||||
|
})
|
||||||
|
.map(page => ({
|
||||||
|
label: page.title,
|
||||||
|
slug: page.slug,
|
||||||
|
order: page.order || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter pages based on current route
|
||||||
|
const currentPageSlug = pageSlug || 'home';
|
||||||
|
const currentPage = siteDefinition.pages.find(p => p.slug === currentPageSlug);
|
||||||
|
|
||||||
|
// If specific page requested, show only that page; otherwise show all published/ready pages
|
||||||
|
const pagesToRender = currentPageSlug && currentPageSlug !== 'home' && currentPage
|
||||||
|
? [currentPage]
|
||||||
|
: siteDefinition.pages.filter(p =>
|
||||||
|
p.status === 'published' ||
|
||||||
|
p.status === 'ready' ||
|
||||||
|
(p.slug === 'home' && p.status !== 'draft' && p.status !== 'generating')
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site-renderer">
|
<div className="site-renderer" style={{
|
||||||
{renderLayout(siteDefinition)}
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0 1rem',
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
{/* Navigation Menu */}
|
||||||
|
<nav style={{
|
||||||
|
padding: '1rem 0',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
marginBottom: '2rem'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '1rem'
|
||||||
|
}}>
|
||||||
|
<Link
|
||||||
|
to={`/${siteSlug}`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#0f172a'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siteDefinition.name}
|
||||||
|
</Link>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<Link
|
||||||
|
to={`/${siteSlug}`}
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: currentPageSlug === 'home' ? '#4c1d95' : '#64748b',
|
||||||
|
fontWeight: currentPageSlug === 'home' ? 600 : 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.slug}
|
||||||
|
to={`/${siteSlug}/${item.slug}`}
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: currentPageSlug === item.slug ? '#4c1d95' : '#64748b',
|
||||||
|
fontWeight: currentPageSlug === item.slug ? 600 : 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
{renderLayout({ ...siteDefinition, pages: pagesToRender })}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer style={{
|
||||||
|
marginTop: '4rem',
|
||||||
|
padding: '2rem 0',
|
||||||
|
borderTop: '1px solid #e5e7eb',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
© {new Date().getFullYear()} {siteDefinition.name}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
{siteDefinition.description && (
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.75rem' }}>
|
||||||
|
{siteDefinition.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface PageDefinition {
|
|||||||
type: string;
|
type: string;
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
status: string;
|
status: string;
|
||||||
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Block {
|
export interface Block {
|
||||||
|
|||||||
@@ -56,26 +56,31 @@ export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement
|
|||||||
* Uses shared DefaultLayout component with fully styled modern design.
|
* Uses shared DefaultLayout component with fully styled modern design.
|
||||||
*/
|
*/
|
||||||
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
// Find home page for hero
|
// Find home page for hero (only show hero on home page or when showing all pages)
|
||||||
const homePage = siteDefinition.pages.find(p => p.slug === 'home');
|
const homePage = siteDefinition.pages.find(p => p.slug === 'home');
|
||||||
const heroBlock = homePage?.blocks?.find(b => b.type === 'hero');
|
const heroBlock = homePage?.blocks?.find(b => b.type === 'hero');
|
||||||
const hero: React.ReactNode = heroBlock ? (renderTemplate(heroBlock) as React.ReactNode) : undefined;
|
|
||||||
|
// Only show hero if we're on home page or showing all pages
|
||||||
|
const isHomePage = siteDefinition.pages.length === 1 && siteDefinition.pages[0]?.slug === 'home';
|
||||||
|
const showHero = isHomePage || (homePage && siteDefinition.pages.length > 1);
|
||||||
|
const hero: React.ReactNode = (showHero && heroBlock) ? (renderTemplate(heroBlock) as React.ReactNode) : undefined;
|
||||||
|
|
||||||
// Render all pages as sections (excluding hero from home page if it exists)
|
// Render all pages as sections (excluding hero from home page if it exists)
|
||||||
const sections = siteDefinition.pages
|
const sections = siteDefinition.pages
|
||||||
.filter((page) => page.status !== 'draft')
|
.filter((page) => page.status !== 'draft' && page.status !== 'generating')
|
||||||
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||||
.map((page) => {
|
.map((page) => {
|
||||||
// Filter out hero block if it's the home page (already rendered as hero)
|
// Filter out hero block if it's the home page (already rendered as hero)
|
||||||
const blocksToRender = page.slug === 'home' && heroBlock
|
const blocksToRender = page.slug === 'home' && heroBlock && showHero
|
||||||
? page.blocks?.filter(b => b.type !== 'hero') || []
|
? page.blocks?.filter(b => b.type !== 'hero') || []
|
||||||
: page.blocks || [];
|
: page.blocks || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
<div key={page.id} className="page" data-page-slug={page.slug} style={{ textAlign: 'center' }}>
|
||||||
{page.slug !== 'home' && <h2>{page.title}</h2>}
|
{page.slug !== 'home' && <h2 style={{ textAlign: 'center', marginBottom: '1.5rem' }}>{page.title}</h2>}
|
||||||
{blocksToRender.length > 0 ? (
|
{blocksToRender.length > 0 ? (
|
||||||
blocksToRender.map((block, index) => (
|
blocksToRender.map((block, index) => (
|
||||||
<div key={index} className="block" data-block-type={block.type}>
|
<div key={index} className="block" data-block-type={block.type} style={{ textAlign: 'center' }}>
|
||||||
{renderTemplate(block)}
|
{renderTemplate(block)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user