Skip to content

Instantly share code, notes, and snippets.

@swdevbali
Created October 13, 2025 03:44
Show Gist options
  • Save swdevbali/4e3754d5c0c748113d3ca710d07ec664 to your computer and use it in GitHub Desktop.
Save swdevbali/4e3754d5c0c748113d3ca710d07ec664 to your computer and use it in GitHub Desktop.
'use client'
import { useState, useRef, useEffect } from 'react'
import {
Layout,
LayoutElement,
ContentType,
Resolution,
STANDARD_RESOLUTIONS,
DragState,
RESIZE_HANDLES
} from '@/types/layout'
import {
Plus,
Video,
Image,
Globe,
Type,
Trash2,
Save,
Settings,
Move,
Maximize2,
Layers,
ChevronUp,
ChevronDown,
Eye,
EyeOff,
Loader2,
Check,
Search,
Expand,
AlertCircle,
Clock,
Cloud,
Fullscreen,
X
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { ConfirmationModal } from '@/components/ui/confirmation-modal'
import VideoSearchModal from './VideoSearchModal'
import { getInitials, stringToColor } from '@/lib/utils/text'
import LocalInteractiveBrowser from './LocalInteractiveBrowser'
import { ClockElementPreview } from './ClockElementPreview'
import { WeatherElementPreview } from './WeatherElementPreview'
import CitySelect from './CitySelect'
// Clock Component
function ClockElement({ element }: { element: LayoutElement }) {
const [time, setTime] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(timer)
}, [])
const formatTime = () => {
const hours = element.content.clockFormat === '12h'
? time.getHours() % 12 || 12
: time.getHours()
const minutes = time.getMinutes().toString().padStart(2, '0')
const seconds = time.getSeconds().toString().padStart(2, '0')
const ampm = element.content.clockFormat === '12h'
? (time.getHours() >= 12 ? ' PM' : ' AM')
: ''
const timeString = element.content.showSeconds
? `${hours}:${minutes}:${seconds}${ampm}`
: `${hours}:${minutes}${ampm}`
return timeString
}
const formatDate = () => {
if (!element.content.showDate) return ''
const options: Intl.DateTimeFormatOptions =
element.content.dateFormat === 'long'
? { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
: element.content.dateFormat === 'full'
? { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
: { year: 'numeric', month: '2-digit', day: '2-digit' }
return time.toLocaleDateString('en-US', options)
}
return (
<div
className="w-full h-full flex flex-col items-center justify-center"
style={{
backgroundColor: element.content.backgroundColor || '#000000',
color: element.content.fontColor || '#ffffff',
fontFamily: element.content.fontFamily || 'monospace'
}}
>
<div
className="font-bold tabular-nums"
style={{
fontSize: `${(element.content.fontSize || 48) * 0.5}px`
}}
>
{formatTime()}
</div>
{element.content.showDate && (
<div
className="mt-1"
style={{
fontSize: `${(element.content.fontSize || 48) * 0.25}px`
}}
>
{formatDate()}
</div>
)}
</div>
)
}
// Weather Component
function WeatherElement({ element }: { element: LayoutElement }) {
const [weather, setWeather] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchWeather()
// Refresh weather every 10 minutes
const interval = setInterval(fetchWeather, 600000)
return () => clearInterval(interval)
}, [element.content.weatherLatitude, element.content.weatherLongitude, element.content.temperatureUnit])
const fetchWeather = async () => {
try {
const lat = element.content.weatherLatitude || 0
const lon = element.content.weatherLongitude || 0
// Skip if coordinates are not set
if (lat === 0 && lon === 0) {
setLoading(false)
return
}
const tempUnit = element.content.temperatureUnit === 'fahrenheit' ? 'fahrenheit' : 'celsius'
const url = `/api/weather?lat=${lat}&lon=${lon}&unit=${tempUnit}`
console.log('[Weather Builder] Fetching from proxy:', url)
const response = await fetch(url)
if (!response.ok) {
console.error('[Weather Builder] API error:', response.status)
setLoading(false)
return
}
const data = await response.json()
console.log('[Weather Builder] Received data:', data)
setWeather(data)
setLoading(false)
} catch (error) {
console.error('[Weather Builder] Error:', error)
setLoading(false)
}
}
const getWeatherIcon = (code: number) => {
// WMO Weather interpretation codes
if (code === 0) return '☀️'
if (code <= 3) return '⛅'
if (code <= 48) return '🌫️'
if (code <= 67) return '🌧️'
if (code <= 77) return '🌨️'
if (code <= 82) return '🌧️'
if (code <= 86) return '🌨️'
if (code <= 99) return '⛈️'
return '🌤️'
}
const layout = element.content.weatherLayout || 'compact'
if (loading) {
return (
<div className="w-full h-full flex items-center justify-center" style={{ backgroundColor: element.content.backgroundColor || '#1e3a8a', color: element.content.fontColor || '#ffffff' }}>
<Loader2 className="w-6 h-6 animate-spin" />
</div>
)
}
if (!weather || !weather.current) {
return (
<div className="w-full h-full flex flex-col items-center justify-center p-4" style={{ backgroundColor: element.content.backgroundColor || '#1e3a8a', color: element.content.fontColor || '#ffffff' }}>
<Cloud className="w-8 h-8 mb-2 opacity-50" />
<span className="text-xs opacity-75">Set location in properties</span>
</div>
)
}
const temp = Math.round(weather.current.temperature_2m)
const unit = element.content.temperatureUnit === 'fahrenheit' ? '°F' : '°C'
const humidity = weather.current.relative_humidity_2m
const windSpeed = Math.round(weather.current.wind_speed_10m)
const weatherIcon = getWeatherIcon(weather.current.weather_code)
if (layout === 'minimal') {
return (
<div className="w-full h-full flex items-center justify-center gap-2 px-4" style={{ backgroundColor: element.content.backgroundColor || '#1e3a8a', color: element.content.fontColor || '#ffffff', fontSize: `${(element.content.fontSize || 32) * 0.5}px` }}>
<span className="text-4xl">{weatherIcon}</span>
<span className="font-bold">{temp}{unit}</span>
</div>
)
}
if (layout === 'detailed') {
return (
<div className="w-full h-full flex flex-col items-center justify-center p-4 gap-2" style={{ backgroundColor: element.content.backgroundColor || '#1e3a8a', color: element.content.fontColor || '#ffffff' }}>
<div className="text-5xl">{weatherIcon}</div>
<div className="text-3xl font-bold">{temp}{unit}</div>
<div className="text-sm opacity-90">{element.content.weatherLocation || 'Unknown Location'}</div>
<div className="grid grid-cols-2 gap-4 mt-2 text-xs">
{element.content.showHumidity && <div>💧 {humidity}%</div>}
{element.content.showWindSpeed && <div>💨 {windSpeed} km/h</div>}
</div>
</div>
)
}
// Compact layout (default)
return (
<div className="w-full h-full flex items-center justify-between p-4" style={{ backgroundColor: element.content.backgroundColor || '#1e3a8a', color: element.content.fontColor || '#ffffff' }}>
<div className="flex items-center gap-3">
<div className="text-4xl">{weatherIcon}</div>
<div>
<div className="text-2xl font-bold">{temp}{unit}</div>
<div className="text-xs opacity-75">{element.content.weatherLocation || 'Set location'}</div>
</div>
</div>
{(element.content.showHumidity || element.content.showWindSpeed) && (
<div className="text-xs space-y-1">
{element.content.showHumidity && <div>💧 {humidity}%</div>}
{element.content.showWindSpeed && <div>💨 {windSpeed} km/h</div>}
</div>
)}
</div>
)
}
interface Video {
id: string
title: string
video_url: string
thumbnail_url?: string | null
}
interface ImagePlaylist {
id: string
name: string
description?: string
images: Array<{
id: string
title: string
image_url: string
thumbnail_url: string | null
}>
duration?: number
transition?: 'none' | 'fade' | 'slide'
}
interface LayoutBuilderProps {
layout?: Layout
onSave?: (layout: Layout) => void
savedLayouts?: Layout[]
onLoadLayout?: () => void
availableVideos?: Video[]
availablePlaylists?: ImagePlaylist[]
}
export default function LayoutBuilder({ layout: initialLayout, onSave, savedLayouts = [], onLoadLayout, availableVideos = [], availablePlaylists = [] }: LayoutBuilderProps) {
const canvasRef = useRef<HTMLDivElement>(null)
const [selectedResolution, setSelectedResolution] = useState<Resolution>(
initialLayout?.resolution || STANDARD_RESOLUTIONS[0]
)
const [elements, setElements] = useState<LayoutElement[]>(
initialLayout?.elements || []
)
const [selectedElement, setSelectedElement] = useState<string | null>(null)
const [isLayoutActive, setIsLayoutActive] = useState(false)
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
elementId: null,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0
})
const [resizeState, setResizeState] = useState<{
isResizing: boolean
elementId: string | null
handle: string | null
startX: number
startY: number
startWidth: number
startHeight: number
startLeft: number
startTop: number
}>({
isResizing: false,
elementId: null,
handle: null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
startLeft: 0,
startTop: 0
})
const [showGrid, setShowGrid] = useState(true)
const [snapToGrid, setSnapToGrid] = useState(true)
const [selectedLayoutId, setSelectedLayoutId] = useState<string>('')
const [layoutName, setLayoutName] = useState(initialLayout?.name || 'Untitled Layout')
const [isSaving, setIsSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const [publishSuccess, setPublishSuccess] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [showVideoSearchModal, setShowVideoSearchModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [showCustomResolutionModal, setShowCustomResolutionModal] = useState(false)
const [customWidth, setCustomWidth] = useState('1920')
const [customHeight, setCustomHeight] = useState('1080')
const [videoSuggestions, setVideoSuggestions] = useState<Video[]>([])
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
elementId: string
} | null>(null)
const [editingTextId, setEditingTextId] = useState<string | null>(null)
const [editingTextValue, setEditingTextValue] = useState('')
const textEditRef = useRef<HTMLTextAreaElement>(null)
const [iframeLoadErrors, setIframeLoadErrors] = useState<Record<string, boolean>>({})
const [websiteScreenshots, setWebsiteScreenshots] = useState<Record<string, string>>({})
const [loadingScreenshots, setLoadingScreenshots] = useState<Record<string, boolean>>({})
const gridSize = 5 // 5% grid
// Close context menu when clicking outside
useEffect(() => {
const handleClick = () => setContextMenu(null)
if (contextMenu) {
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}
}, [contextMenu])
// Handle text editing
useEffect(() => {
if (editingTextId && textEditRef.current) {
textEditRef.current.focus()
textEditRef.current.select()
}
}, [editingTextId])
const handleTextEditComplete = () => {
if (editingTextId) {
setElements(elements.map(el =>
el.id === editingTextId
? { ...el, content: { ...el.content, text: editingTextValue } }
: el
))
setEditingTextId(null)
setEditingTextValue('')
}
}
const handleTextEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setEditingTextId(null)
setEditingTextValue('')
} else if (e.key === 'Enter' && e.ctrlKey) {
handleTextEditComplete()
}
}
const addElement = (type: ContentType) => {
const newElement: LayoutElement = {
id: `element-${Date.now()}`,
type,
x: 10,
y: 10,
width: 30,
height: 30,
zIndex: elements.length,
content: type === 'text' ? { text: 'Double-click to edit' } : {},
settings: {
opacity: 100,
borderRadius: 0,
padding: 0
}
}
// Set default content based on type
switch (type) {
case 'text':
newElement.content.text = 'Sample Text'
newElement.content.fontSize = 24
newElement.content.fontColor = '#000000'
newElement.content.backgroundColor = '#ffffff'
newElement.height = 10
break
case 'website':
newElement.content.websiteUrl = 'https://example.com'
newElement.width = 40
newElement.height = 40
break
case 'image':
// No placeholder; user should pick an image playlist
newElement.content.imageUrl = ''
break
case 'video':
newElement.content.videoUrl = ''
break
case 'clock':
newElement.content.clockFormat = '24h'
newElement.content.showSeconds = true
newElement.content.showDate = false
newElement.content.fontSize = 48
newElement.content.fontColor = '#ffffff'
newElement.content.backgroundColor = '#000000'
newElement.width = 30
newElement.height = 15
break
case 'weather':
newElement.content.weatherLocation = 'Jakarta'
newElement.content.weatherLatitude = -6.2088
newElement.content.weatherLongitude = 106.8456
newElement.content.temperatureUnit = 'celsius'
newElement.content.showHumidity = true
newElement.content.showWindSpeed = true
newElement.content.weatherLayout = 'compact'
newElement.content.fontSize = 32
newElement.content.fontColor = '#ffffff'
newElement.content.backgroundColor = '#1e3a8a'
newElement.width = 35
newElement.height = 20
break
}
setElements([...elements, newElement])
setSelectedElement(newElement.id)
}
const deleteElement = (elementId: string) => {
setElements(elements.filter(el => el.id !== elementId))
if (selectedElement === elementId) {
setSelectedElement(null)
}
}
const updateElementZIndex = (elementId: string, direction: 'up' | 'down') => {
const element = elements.find(el => el.id === elementId)
if (!element) return
const sortedElements = [...elements].sort((a, b) => a.zIndex - b.zIndex)
const currentIndex = sortedElements.findIndex(el => el.id === elementId)
if (direction === 'up' && currentIndex < sortedElements.length - 1) {
const temp = sortedElements[currentIndex].zIndex
sortedElements[currentIndex].zIndex = sortedElements[currentIndex + 1].zIndex
sortedElements[currentIndex + 1].zIndex = temp
} else if (direction === 'down' && currentIndex > 0) {
const temp = sortedElements[currentIndex].zIndex
sortedElements[currentIndex].zIndex = sortedElements[currentIndex - 1].zIndex
sortedElements[currentIndex - 1].zIndex = temp
}
setElements(sortedElements)
}
const snapToGridValue = (value: number) => {
if (!snapToGrid) return value
return Math.round(value / gridSize) * gridSize
}
// Mouse event handlers for drag
const handleMouseDown = (e: React.MouseEvent, elementId: string) => {
e.preventDefault()
e.stopPropagation()
// Don't start dragging if we're editing text
if (editingTextId) {
return
}
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const element = elements.find(el => el.id === elementId)
if (!element) return
setDragState({
isDragging: true,
elementId,
startX: e.clientX,
startY: e.clientY,
offsetX: element.x,
offsetY: element.y
})
setSelectedElement(elementId)
}
const handleMouseMove = (e: MouseEvent) => {
if (dragState.isDragging && dragState.elementId) {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const deltaX = ((e.clientX - dragState.startX) / rect.width) * 100
const deltaY = ((e.clientY - dragState.startY) / rect.height) * 100
let newX = dragState.offsetX + deltaX
let newY = dragState.offsetY + deltaY
// Apply grid snapping
newX = snapToGridValue(newX)
newY = snapToGridValue(newY)
// Constrain to canvas bounds
const element = elements.find(el => el.id === dragState.elementId)
if (element) {
newX = Math.max(0, Math.min(100 - element.width, newX))
newY = Math.max(0, Math.min(100 - element.height, newY))
setElements(elements.map(el =>
el.id === dragState.elementId
? { ...el, x: newX, y: newY }
: el
))
}
}
if (resizeState.isResizing && resizeState.elementId) {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const deltaX = ((e.clientX - resizeState.startX) / rect.width) * 100
const deltaY = ((e.clientY - resizeState.startY) / rect.height) * 100
setElements(elements.map(el => {
if (el.id !== resizeState.elementId) return el
let newX = el.x
let newY = el.y
let newWidth = el.width
let newHeight = el.height
switch (resizeState.handle) {
case 'right':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth + deltaX))
break
case 'left':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth - deltaX))
newX = snapToGridValue(resizeState.startLeft + deltaX)
break
case 'bottom':
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight + deltaY))
break
case 'top':
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight - deltaY))
newY = snapToGridValue(resizeState.startTop + deltaY)
break
case 'bottom-right':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth + deltaX))
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight + deltaY))
break
case 'bottom-left':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth - deltaX))
newX = snapToGridValue(resizeState.startLeft + deltaX)
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight + deltaY))
break
case 'top-right':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth + deltaX))
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight - deltaY))
newY = snapToGridValue(resizeState.startTop + deltaY)
break
case 'top-left':
newWidth = snapToGridValue(Math.max(10, resizeState.startWidth - deltaX))
newX = snapToGridValue(resizeState.startLeft + deltaX)
newHeight = snapToGridValue(Math.max(10, resizeState.startHeight - deltaY))
newY = snapToGridValue(resizeState.startTop + deltaY)
break
}
// Constrain to canvas bounds
newX = Math.max(0, newX)
newY = Math.max(0, newY)
newWidth = Math.min(100 - newX, newWidth)
newHeight = Math.min(100 - newY, newHeight)
return { ...el, x: newX, y: newY, width: newWidth, height: newHeight }
}))
}
}
const handleMouseUp = () => {
setDragState({
isDragging: false,
elementId: null,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0
})
setResizeState({
isResizing: false,
elementId: null,
handle: null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
startLeft: 0,
startTop: 0
})
}
const handleResizeMouseDown = (e: React.MouseEvent, elementId: string, handle: string) => {
e.preventDefault()
e.stopPropagation()
const element = elements.find(el => el.id === elementId)
if (!element) return
setResizeState({
isResizing: true,
elementId,
handle,
startX: e.clientX,
startY: e.clientY,
startWidth: element.width,
startHeight: element.height,
startLeft: element.x,
startTop: element.y
})
setSelectedElement(elementId)
}
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [dragState, resizeState, elements])
const renderElement = (element: LayoutElement) => {
const isSelected = selectedElement === element.id
return (
<div
key={element.id}
className={`absolute border-2 ${
isSelected ? 'border-blue-500' : 'border-gray-300'
} bg-white overflow-hidden group cursor-move`}
style={{
left: `${element.x}%`,
top: `${element.y}%`,
width: `${element.width}%`,
height: `${element.height}%`,
zIndex: element.zIndex,
opacity: (element.settings?.opacity || 100) / 100,
borderRadius: `${element.settings?.borderRadius || 0}px`,
padding: `${element.settings?.padding || 0}px`
}}
onMouseDown={(e) => handleMouseDown(e, element.id)}
onContextMenu={(e) => {
e.preventDefault()
if (element.type === 'video') {
setContextMenu({
x: e.clientX,
y: e.clientY,
elementId: element.id
})
setSelectedElement(element.id)
}
}}
>
{/* Content based on type */}
<div className="w-full h-full flex items-center justify-center bg-gray-50">
{element.type === 'video' && (
<div className="w-full h-full">
{(() => {
const selectedVideo = availableVideos.find(v =>
v.video_url === element.content.videoUrl ||
v.id === element.content.videoId
)
if (selectedVideo) {
return (
<div className="w-full h-full relative">
{selectedVideo.thumbnail_url ? (
<>
<img
src={selectedVideo.thumbnail_url}
alt={selectedVideo.title}
className="w-full h-full"
style={{ objectFit: element.content?.objectFit || 'cover' }}
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
<div
className="hidden w-full h-full flex items-center justify-center text-white font-bold text-2xl"
style={{ backgroundColor: stringToColor(selectedVideo.title) }}
>
{getInitials(selectedVideo.title)}
</div>
</>
) : (
<div
className="w-full h-full flex items-center justify-center text-white font-bold text-2xl"
style={{ backgroundColor: stringToColor(selectedVideo.title) }}
>
{getInitials(selectedVideo.title)}
</div>
)}
{/* Title overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium truncate">{selectedVideo.title}</p>
</div>
</div>
)
} else {
return (
<div className="text-center p-2">
<Video className="w-12 h-12 mx-auto mb-2 text-gray-400" />
<p className="text-xs text-gray-600">Video Content</p>
{element.content.videoUrl && (
<p className="text-xs text-gray-500 mt-1 truncate px-2">
{element.content.videoUrl}
</p>
)}
</div>
)
}
})()}
</div>
)}
{element.type === 'image' && (
<div className="w-full h-full relative bg-gray-100">
{(() => {
const currentPlaylist = availablePlaylists.find(p => p.id === element.content.playlistId)
const images = currentPlaylist?.images || element.content.images || []
const firstImage = images[0]
if (firstImage) {
return (
<>
{/* Display first image from playlist */}
<img
src={firstImage.thumbnail_url || firstImage.image_url}
alt={firstImage.title}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
<div className="hidden w-full h-full flex items-center justify-center">
<Image className="w-12 h-12 text-gray-400" />
</div>
{/* Playlist info overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium truncate">
{currentPlaylist?.name || element.content.playlistName || 'Image Playlist'}
</p>
{images.length > 1 && (
<p className="text-white/80 text-xs">
{images.length} images • {(currentPlaylist?.duration || element.content.duration || 5)}s each
</p>
)}
</div>
</>
)
}
return (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-400">
<Image className="w-12 h-12 mb-2" />
<p className="text-xs text-gray-600">Image Playlist</p>
<p className="text-xs text-gray-500 mt-1">Select playlist in properties</p>
</div>
)
})()}
</div>
)}
{element.type === 'website' && (
<div className="w-full h-full relative bg-white overflow-hidden">
{element.content.websiteUrl ? (
<>
{/* For editor mode - try direct embed first, with interactive browser fallback */}
{element.content.useInteractiveBrowser ? (
// Use local interactive browser (no Docker required)
<LocalInteractiveBrowser
url={element.content.websiteUrl}
className="w-full h-full"
/>
) : (
// Standard iframe embed
<iframe
key={`${element.content.websiteUrl}-${element.content._timestamp || 0}`}
src={element.content.websiteUrl}
className="w-full h-full border-0 pointer-events-none"
title="Website"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-orientation-lock"
loading="lazy"
allowFullScreen
onError={async () => {
setIframeLoadErrors(prev => ({ ...prev, [element.id]: true }))
// Try to get a screenshot instead
if (!websiteScreenshots[element.id] && !loadingScreenshots[element.id]) {
setLoadingScreenshots(prev => ({ ...prev, [element.id]: true }))
try {
// Use screenshot service
const screenshotUrl = `https://image.thum.io/get/width/1920/crop/1080/${element.content.websiteUrl}`
setWebsiteScreenshots(prev => ({ ...prev, [element.id]: screenshotUrl }))
} catch (error) {
console.error('Failed to get screenshot:', error)
} finally {
setLoadingScreenshots(prev => ({ ...prev, [element.id]: false }))
}
}
}}
onLoad={() => {
setIframeLoadErrors(prev => ({ ...prev, [element.id]: false }))
setWebsiteScreenshots(prev => {
const newState = { ...prev }
delete newState[element.id]
return newState
})
}}
/>
)}
{/* Show screenshot if iframe fails to load */}
{websiteScreenshots[element.id] && (
<div className="absolute inset-0 bg-white">
<img
src={websiteScreenshots[element.id]}
alt="Website screenshot"
className="w-full h-full object-cover"
onError={(e) => {
// Hide broken image
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div className="absolute top-2 right-2 bg-orange-500/90 text-white px-2 py-1 rounded text-xs flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Screenshot Mode
</div>
</div>
)}
{/* Invisible overlay to capture clicks for selection */}
<div className="absolute inset-0" style={{ pointerEvents: 'auto' }} />
{/* Fallback message overlay - will be hidden by iframe if it loads */}
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-100 -z-10">
<Globe className="w-12 h-12 text-gray-400 mb-2" />
<p className="text-xs text-gray-600 font-medium">Website Preview</p>
<p className="text-xs text-gray-500 mt-1 px-4 text-center">
{element.content.websiteUrl}
</p>
{iframeLoadErrors[element.id] && !websiteScreenshots[element.id] && (
<div className="mt-3 px-4 text-center">
<p className="text-[10px] text-orange-600 font-medium mb-2">
{loadingScreenshots[element.id] ? 'Getting screenshot...' : 'Site blocks embedding'}
</p>
{!loadingScreenshots[element.id] && (
<div className="text-[9px] text-gray-500 space-y-1">
<p className="font-medium">Embeddable alternatives:</p>
<p className="text-gray-600">• YouTube: youtube.com/embed/VIDEO_ID</p>
<p className="text-gray-600">• Vimeo: player.vimeo.com/video/VIDEO_ID</p>
<p className="text-gray-600">• Google Maps embed</p>
<p className="text-gray-600">• CodePen embeds</p>
</div>
)}
</div>
)}
{!iframeLoadErrors[element.id] && (
<p className="text-[10px] text-gray-400 mt-2">
Loading website...
</p>
)}
</div>
{/* URL indicator */}
<div className="absolute top-0 left-0 right-0 bg-black/50 backdrop-blur-sm px-2 py-1 pointer-events-none">
<p className="text-white text-[10px] font-medium truncate flex items-center gap-1">
<Globe className="w-3 h-3" />
{element.content.websiteUrl.replace(/^https?:\/\//, '')}
</p>
</div>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-400">
<Globe className="w-12 h-12 mb-2" />
<p className="text-xs text-gray-600">Website Element</p>
<p className="text-xs text-gray-500 mt-1">Enter URL in properties</p>
</div>
)}
</div>
)}
{element.type === 'text' && (
<div
className="text-center w-full h-full flex items-center justify-center relative cursor-text hover:bg-gray-50/50 transition-colors"
style={{
backgroundColor: element.content.backgroundColor || '#ffffff',
color: element.content.fontColor || '#000000',
fontSize: `${(element.content.fontSize || 24) * 0.5}px`
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingTextId(element.id)
setEditingTextValue(element.content.text || '')
}}
title="Double-click to edit text"
>
{editingTextId === element.id ? (
<textarea
ref={textEditRef}
value={editingTextValue}
onChange={(e) => setEditingTextValue(e.target.value)}
onBlur={handleTextEditComplete}
onKeyDown={handleTextEditKeyDown}
className="w-full h-full resize-none border-none outline-none p-2 text-center bg-transparent"
style={{
color: element.content.fontColor || '#000000',
fontSize: `${(element.content.fontSize || 24) * 0.5}px`,
fontFamily: element.content.fontFamily || 'inherit'
}}
placeholder="Enter text..."
/>
) : (
<>
{element.content.scrollDirection ? (
<div className="w-full overflow-hidden">
<div
className="whitespace-nowrap inline-block animate-scroll"
style={{
animationDuration: `${(element.content.scrollSpeed || 5) * 3}s`,
animationDirection: element.content.scrollDirection === 'right' ? 'reverse' : 'normal'
}}
>
{element.content.text || 'Double-click to edit'}
{/* Duplicate text for seamless loop */}
<span className="ml-20">{element.content.text || 'Double-click to edit'}</span>
</div>
</div>
) : (
<div className="w-full px-2">
{element.content.text || 'Double-click to edit'}
</div>
)}
</>
)}
</div>
)}
{element.type === 'clock' && (
<ClockElement element={element} />
)}
{element.type === 'weather' && (
<WeatherElement element={element} />
)}
</div>
{/* Resize handles when selected */}
{isSelected && (
<>
{RESIZE_HANDLES.map(handle => (
<div
key={handle.position}
className="absolute w-3 h-3 bg-blue-500 border border-white"
style={{
cursor: handle.cursor,
...getResizeHandlePosition(handle.position)
}}
onMouseDown={(e) => handleResizeMouseDown(e, element.id, handle.position)}
/>
))}
</>
)}
{/* Delete button */}
{isSelected && (
<button
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
deleteElement(element.id)
}}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)
}
const getResizeHandlePosition = (position: string) => {
const size = '-6px'
switch (position) {
case 'top':
return { top: size, left: '50%', transform: 'translateX(-50%)' }
case 'right':
return { right: size, top: '50%', transform: 'translateY(-50%)' }
case 'bottom':
return { bottom: size, left: '50%', transform: 'translateX(-50%)' }
case 'left':
return { left: size, top: '50%', transform: 'translateY(-50%)' }
case 'top-left':
return { top: size, left: size }
case 'top-right':
return { top: size, right: size }
case 'bottom-left':
return { bottom: size, left: size }
case 'bottom-right':
return { bottom: size, right: size }
default:
return {}
}
}
const handleSave = async (status: 'draft' | 'published' = 'draft') => {
if (status === 'published') {
setIsPublishing(true)
setPublishSuccess(false)
} else {
setIsSaving(true)
setSaveSuccess(false)
}
const layoutData: Layout = {
id: selectedLayoutId || `layout-${Date.now()}`,
name: layoutName,
resolution: selectedResolution,
elements,
createdAt: initialLayout?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: '', // Will be set by API
status: status
}
try {
await onSave?.(layoutData)
if (status === 'published') {
setPublishSuccess(true)
setTimeout(() => setPublishSuccess(false), 3000)
} else {
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000)
}
// Refresh the layouts list after saving
onLoadLayout?.()
} catch (error) {
console.error('Failed to save layout:', error)
} finally {
setIsSaving(false)
setIsPublishing(false)
}
}
const handlePublish = async () => {
await handleSave('published')
}
const currentElement = elements.find(el => el.id === selectedElement)
return (
<>
{/* Header with Save Button */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-gray-900">Layout Builder</h2>
{isLayoutActive && layoutName && (
<span className="text-sm text-gray-500">
Editing: {layoutName}
</span>
)}
</div>
<div className="flex gap-2">
{isLayoutActive && (
<>
<Button
variant="outline"
onClick={() => {
setSelectedLayoutId('')
setElements([])
setLayoutName('Untitled Layout')
setSelectedResolution(STANDARD_RESOLUTIONS[0])
setIsLayoutActive(true)
}}
className="border-gray-300"
>
<Plus className="w-4 h-4 mr-2" />
New Layout
</Button>
<Button
onClick={() => handleSave('draft')}
disabled={isSaving || !layoutName.trim()}
className={`transition-all duration-300 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
} text-white disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4 mr-2" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save
</>
)}
</Button>
<Button
onClick={() => setShowPreview(true)}
disabled={elements.length === 0}
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
<Fullscreen className="w-4 h-4 mr-2" />
Preview
</Button>
<Button
onClick={handlePublish}
disabled={isPublishing || !layoutName.trim() || !selectedLayoutId}
className={`transition-all duration-300 ${
publishSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-purple-600 hover:bg-purple-700'
} text-white disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isPublishing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Publishing...
</>
) : publishSuccess ? (
<>
<Check className="w-4 h-4 mr-2" />
Published!
</>
) : (
<>
<Globe className="w-4 h-4 mr-2" />
Publish
</>
)}
</Button>
</>
)}
</div>
</div>
<div className="flex gap-6 h-[calc(100vh-240px)] relative">
{/* Left Toolbar */}
<div className="w-64 space-y-4 overflow-y-auto flex-shrink-0">
{/* Layout Selector */}
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm text-gray-900">Layout</h3>
<select
value={selectedLayoutId}
onChange={(e) => {
const layoutId = e.target.value
if (layoutId) {
const layout = savedLayouts.find(l => l.id === layoutId)
if (layout) {
setSelectedLayoutId(layoutId)
setElements(layout.elements || [])
setSelectedResolution(layout.resolution)
setLayoutName(layout.name || 'Untitled Layout')
setIsLayoutActive(true)
}
} else {
// Clear selection when "Select saved layout..." is chosen
setSelectedLayoutId('')
setElements([])
setLayoutName('Untitled Layout')
setIsLayoutActive(false)
}
}}
className="w-full p-2 border rounded-lg text-sm mb-2 text-gray-900 bg-white"
>
<option value="">Select saved layout...</option>
{savedLayouts.map(layout => {
const dateStr = layout.updated_at || layout.updatedAt || layout.created_at || layout.createdAt
const formattedDate = dateStr ? new Date(dateStr).toLocaleDateString() : ''
return (
<option key={layout.id} value={layout.id}>
{layout.name} {formattedDate && `- ${formattedDate}`}
</option>
)
})}
</select>
{isLayoutActive && (
<div className="flex gap-2">
<input
type="text"
value={layoutName}
onChange={(e) => setLayoutName(e.target.value)}
placeholder="Layout name"
className="flex-1 p-2 border rounded-lg text-sm text-gray-900 bg-white"
/>
{selectedLayoutId && (
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteModal(true)}
className="px-3 hover:bg-red-50 hover:text-red-600 hover:border-red-300"
title="Delete Layout"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
)}
</Card>
{/* Resolution Selector */}
{isLayoutActive && (
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm text-gray-900">Canvas Size</h3>
<select
value={`${selectedResolution.width}x${selectedResolution.height}`}
onChange={(e) => {
if (e.target.value === 'custom') {
setShowCustomResolutionModal(true)
} else {
const [width, height] = e.target.value.split('x').map(Number)
const resolution = STANDARD_RESOLUTIONS.find(
r => r.width === width && r.height === height
)
if (resolution) setSelectedResolution(resolution)
}
}}
className="w-full p-2 border rounded-lg text-sm text-gray-900 bg-white"
>
{STANDARD_RESOLUTIONS.map(res => (
<option key={`${res.width}x${res.height}`} value={`${res.width}x${res.height}`}>
{res.label}
</option>
))}
<option value="custom">Custom Size...</option>
</select>
{!STANDARD_RESOLUTIONS.find(r => r.width === selectedResolution.width && r.height === selectedResolution.height) && (
<div className="mt-2 text-sm text-gray-600">
Current: {selectedResolution.label}
</div>
)}
</Card>
)}
{/* Orientation Toggle */}
{isLayoutActive && (
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm text-gray-900">Orientation</h3>
<select
value={selectedResolution.height > selectedResolution.width ? 'portrait' : 'landscape'}
onChange={(e) => {
if (e.target.value === 'portrait') {
setSelectedResolution({ width: 1080, height: 1920, label: '1080x1920' })
} else {
setSelectedResolution({ width: 1920, height: 1080, label: '1080p' })
}
}}
className="w-full p-2 border rounded-lg text-sm text-gray-900 bg-white"
>
<option value="landscape">Landscape</option>
<option value="portrait">Portrait</option>
</select>
</Card>
)}
{/* Layers */}
{isLayoutActive && (
<Card className="p-4 flex-1 overflow-auto">
<h3 className="font-semibold mb-3 text-sm flex items-center gap-2 text-gray-900">
<Layers className="w-4 h-4 text-gray-600" />
Layers
</h3>
<div className="space-y-1">
{[...elements]
.sort((a, b) => b.zIndex - a.zIndex)
.map(element => (
<div
key={element.id}
className={`p-2 rounded cursor-pointer text-xs flex items-center justify-between ${
selectedElement === element.id
? 'bg-blue-100 border border-blue-300'
: 'bg-gray-50 hover:bg-gray-100'
}`}
onClick={() => setSelectedElement(element.id)}
>
<div className="flex items-center gap-2">
{element.type === 'video' && <Video className="w-3 h-3" />}
{element.type === 'image' && <Image className="w-3 h-3" />}
{element.type === 'website' && <Globe className="w-3 h-3" />}
{element.type === 'text' && <Type className="w-3 h-3" />}
{element.type === 'clock' && <Clock className="w-3 h-3" />}
{element.type === 'weather' && <Cloud className="w-3 h-3" />}
<span className="truncate">
{element.type === 'video' && element.content.videoTitle
? element.content.videoTitle
: element.type === 'website' && element.content.websiteUrl
? element.content.websiteUrl.replace(/^https?:\/\//, '').split('/')[0]
: element.type === 'image' && element.content.playlistName
? element.content.playlistName
: element.type === 'clock'
? `Clock (${element.content.clockFormat || '24h'})`
: element.type === 'weather'
? `Weather - ${element.content.weatherLocation || 'Set location'}`
: element.type}
</span>
</div>
<div className="flex gap-1 relative z-20">
<button
onClick={(e) => {
e.stopPropagation()
updateElementZIndex(element.id, 'up')
}}
className="p-0.5 hover:bg-gray-200 rounded relative"
title="Move up"
>
<ChevronUp className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
updateElementZIndex(element.id, 'down')
}}
className="p-0.5 hover:bg-gray-200 rounded relative"
title="Move down"
>
<ChevronDown className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
deleteElement(element.id)
}}
className="p-0.5 hover:bg-red-200 rounded text-red-600 relative"
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
</Card>
)}
{/* Settings */}
{isLayoutActive && (
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm text-gray-900">Canvas Settings</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 text-xs text-gray-700">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
className="rounded"
/>
Show Grid
</label>
<label className="flex items-center gap-2 text-xs text-gray-700">
<input
type="checkbox"
checked={snapToGrid}
onChange={(e) => setSnapToGrid(e.target.checked)}
className="rounded"
/>
Snap to Grid
</label>
</div>
</Card>
)}
</div>
{/* Canvas or Empty State */}
{isLayoutActive ? (
<div className="flex-1 bg-gray-100 rounded-lg p-6 relative overflow-hidden">
<div className="h-full flex items-center justify-center">
<div
ref={canvasRef}
className="relative bg-black shadow-2xl isolate"
style={{
aspectRatio: `${selectedResolution.width} / ${selectedResolution.height}`,
maxWidth: '100%',
maxHeight: '100%',
width: selectedResolution.width > selectedResolution.height ? '100%' : 'auto',
height: selectedResolution.width > selectedResolution.height ? 'auto' : '100%',
backgroundImage: showGrid
? `repeating-linear-gradient(0deg, transparent, transparent ${gridSize - 0.1}%, rgba(255,255,255,0.1) ${gridSize}%),
repeating-linear-gradient(90deg, transparent, transparent ${gridSize - 0.1}%, rgba(255,255,255,0.1) ${gridSize}%)`
: undefined
}}
>
{elements
.sort((a, b) => a.zIndex - b.zIndex)
.map(element => renderElement(element))}
</div>
</div>
</div>
) : (
// Empty state when no layout is active
<div className="flex-1 bg-gray-100 rounded-lg p-6">
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Layers className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Layout Selected</h3>
<p className="text-gray-500 mb-6">Create a new layout or select an existing one to get started</p>
<div className="flex gap-3 justify-center">
<Button
onClick={() => {
setSelectedLayoutId('')
setElements([])
setLayoutName('Untitled Layout')
setSelectedResolution(STANDARD_RESOLUTIONS[0])
setIsLayoutActive(true)
}}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Plus className="w-4 h-4 mr-2" />
Create New Layout
</Button>
</div>
</div>
</div>
</div>
)}
{/* Right Panel */}
{isLayoutActive && (
<div className="w-64 space-y-4 overflow-y-auto">
{/* Add Elements */}
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm text-gray-900">Add Elements</h3>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => addElement('video')}
className="flex flex-col gap-1 h-auto py-3"
>
<Video className="w-5 h-5" />
<span className="text-xs text-gray-700">Video</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addElement('image')}
className="flex flex-col gap-1 h-auto py-3"
>
<Image className="w-5 h-5" />
<span className="text-xs text-gray-700">Image</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addElement('website')}
className="flex flex-col gap-1 h-auto py-3"
>
<Globe className="w-5 h-5" />
<span className="text-xs text-gray-700">Website</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addElement('text')}
className="flex flex-col gap-1 h-auto py-3"
>
<Type className="w-5 h-5" />
<span className="text-xs text-gray-700">Text</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addElement('clock')}
className="flex flex-col gap-1 h-auto py-3"
>
<Clock className="w-5 h-5" />
<span className="text-xs text-gray-700">Clock</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addElement('weather')}
className="flex flex-col gap-1 h-auto py-3"
>
<Cloud className="w-5 h-5" />
<span className="text-xs text-gray-700">Weather</span>
</Button>
</div>
</Card>
{/* Element Properties */}
{selectedElement && currentElement && (
<Card className="p-4">
<h3 className="font-semibold mb-3 text-sm flex items-center gap-2 text-gray-900">
<Settings className="w-4 h-4 text-gray-600" />
Element Properties
</h3>
<div className="space-y-3">
{currentElement.type === 'video' && (
<div>
<label className="text-xs font-medium text-gray-700">Video Selection</label>
<div className="mt-1 space-y-2">
{/* Search Input with Suggestions */}
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => {
const query = e.target.value
setSearchQuery(query)
// Filter suggestions
if (query) {
const suggestions = availableVideos.filter(v =>
v.title.toLowerCase().includes(query.toLowerCase())
).slice(0, 5)
setVideoSuggestions(suggestions)
} else {
setVideoSuggestions([])
}
}}
placeholder="Search for video..."
className="w-full px-2 py-1 pr-8 text-xs border rounded text-gray-900 bg-white"
/>
<button
onClick={() => setShowVideoSearchModal(true)}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
title="Browse videos"
>
<Search className="w-3 h-3 text-gray-500" />
</button>
{/* Autosuggest Dropdown */}
{videoSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-40 overflow-y-auto">
{videoSuggestions.map(video => (
<button
key={video.id}
onClick={() => {
setElements(elements.map(el =>
el.id === selectedElement
? {
...el,
content: {
...el.content,
videoUrl: video.video_url,
videoId: video.id,
videoTitle: video.title,
thumbnailUrl: video.thumbnail_url || undefined
}
}
: el
))
setSearchQuery(video.title)
setVideoSuggestions([])
}}
className="w-full text-left px-2 py-1 hover:bg-gray-50 text-xs"
>
{video.title}
</button>
))}
</div>
)}
</div>
{/* Browse Button */}
<Button
variant="outline"
size="sm"
onClick={() => setShowVideoSearchModal(true)}
className="w-full text-xs"
>
<Video className="w-3 h-3 mr-1" />
Browse All Videos
</Button>
{/* Selected Video Preview */}
{currentElement.content.videoUrl && (
<div className="p-2 bg-gray-50 rounded">
{(() => {
const selectedVideo = availableVideos.find(v => v.video_url === currentElement.content.videoUrl)
return selectedVideo ? (
<div className="flex items-center gap-2">
{selectedVideo.thumbnail_url ? (
<>
<img
src={selectedVideo.thumbnail_url}
alt={selectedVideo.title}
className="w-16 h-10 object-cover rounded"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
<div
className="hidden w-16 h-10 rounded flex items-center justify-center text-white font-bold"
style={{ backgroundColor: stringToColor(selectedVideo.title) }}
>
{getInitials(selectedVideo.title)}
</div>
</>
) : (
<div
className="w-16 h-10 rounded flex items-center justify-center text-white font-bold"
style={{ backgroundColor: stringToColor(selectedVideo.title) }}
>
{getInitials(selectedVideo.title)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{selectedVideo.title}</p>
<p className="text-xs text-gray-500">Selected</p>
</div>
</div>
) : (
<p className="text-xs text-gray-500">Custom URL: {currentElement.content.videoUrl}</p>
)
})()}
</div>
)}
</div>
</div>
)}
{currentElement.type === 'image' && (
<div>
<label className="text-xs font-medium text-gray-700">Image Playlist</label>
<select
value={currentElement.content.playlistId || ''}
onChange={(e) => {
const playlist = availablePlaylists.find(p => p.id === e.target.value)
setElements(elements.map(el =>
el.id === selectedElement
? {
...el,
content: {
...el.content,
playlistId: e.target.value,
playlistName: playlist?.name,
images: playlist?.images || [],
duration: playlist?.duration || 5,
transition: playlist?.transition || 'none'
}
}
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="">Select a playlist...</option>
{availablePlaylists.map(playlist => (
<option key={playlist.id} value={playlist.id}>
{playlist.name} ({playlist.images.length} {playlist.images.length === 1 ? 'image' : 'images'})
</option>
))}
</select>
{currentElement.content.playlistId && (
<div className="mt-2 p-2 bg-gray-50 rounded text-xs">
<p className="font-medium">{currentElement.content.playlistName}</p>
<p className="text-gray-500 mt-1">
{currentElement.content.images?.length || 0} images
</p>
{currentElement.content.duration && (
<p className="text-gray-500">
{currentElement.content.duration}s per image
</p>
)}
</div>
)}
</div>
)}
{currentElement.type === 'website' && (
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-gray-700">Website URL</label>
<div className="flex gap-1 mt-1">
<input
type="text"
value={currentElement.content.websiteUrl || ''}
onChange={(e) => {
const inputValue = e.target.value
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, websiteUrl: inputValue } }
: el
))
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
let url = e.currentTarget.value.trim()
if (url) {
// Auto-add https:// if no protocol
if (!url.match(/^https?:\/\//)) {
url = 'https://' + url
}
// Force update with formatted URL
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, websiteUrl: url, _timestamp: Date.now() } }
: el
))
}
}
}}
placeholder="https://example.com"
className="flex-1 px-2 py-1 text-xs border rounded"
/>
<button
onClick={() => {
let url = currentElement.content.websiteUrl?.trim()
if (url) {
// Auto-add https:// if no protocol
if (!url.match(/^https?:\/\//)) {
url = 'https://' + url
}
// Force update with timestamp to reload iframe
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, websiteUrl: url, _timestamp: Date.now() } }
: el
))
}
}}
className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
>
Load
</button>
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id={`interactive-${selectedElement}`}
checked={currentElement.content.useInteractiveBrowser || false}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, useInteractiveBrowser: e.target.checked } }
: el
))
}}
className="w-3 h-3"
/>
<label htmlFor={`interactive-${selectedElement}`} className="text-xs text-gray-700">
Use Interactive Browser (works with any website!)
</label>
</div>
<div className="text-[10px] text-gray-500 space-y-1 mt-2">
<p>Press Enter or click Load to update</p>
{currentElement.content.useInteractiveBrowser ? (
<>
<p className="text-green-600 font-medium">Interactive Browser Enabled!</p>
<p>• Click on the website to interact</p>
<p>• Scroll, type, and navigate freely</p>
<p>• Works with ALL websites (no CSP limits!)</p>
<p className="text-orange-600 mt-1">Note: Uses your local Chrome/Chromium</p>
</>
) : (
<>
<p>Note: Many sites block embedding for security</p>
<p className="text-green-600">Use embed-friendly sites or embed URLs:</p>
<ul className="text-green-600 ml-2">
<li>• YouTube: Use /embed/ URLs</li>
<li>• Maps: Use Google Maps embed</li>
<li>• Custom dashboards/widgets</li>
</ul>
</>
)}
</div>
{/* Quick examples */}
<div className="space-y-1">
<p className="text-[10px] font-medium text-gray-700">Quick examples:</p>
<div className="flex flex-wrap gap-1">
<button
onClick={() => {
const url = 'https://en.wikipedia.org/wiki/Main_Page'
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, websiteUrl: url, _timestamp: Date.now() } }
: el
))
}}
className="text-[10px] px-2 py-0.5 bg-gray-100 rounded hover:bg-gray-200"
>
Wikipedia
</button>
<button
onClick={() => {
const url = 'https://www.youtube.com/embed/dQw4w9WgXcQ'
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, websiteUrl: url, _timestamp: Date.now() } }
: el
))
}}
className="text-[10px] px-2 py-0.5 bg-gray-100 rounded hover:bg-gray-200"
>
YouTube
</button>
</div>
</div>
</div>
)}
{currentElement.type === 'text' && (
<>
<div>
<label className="text-xs font-medium text-gray-700">Text Content</label>
<textarea
value={currentElement.content.text || ''}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, text: e.target.value } }
: el
))
}}
placeholder="Enter text"
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
rows={3}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Font Size</label>
<input
type="number"
value={currentElement.content.fontSize || 24}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, fontSize: parseInt(e.target.value) } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700">Text Color</label>
<input
type="color"
value={currentElement.content.fontColor || '#000000'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, fontColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Background</label>
<input
type="color"
value={currentElement.content.backgroundColor || '#ffffff'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, backgroundColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={!!currentElement.content.scrollDirection}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? {
...el,
content: {
...el.content,
scrollDirection: e.target.checked ? 'left' : undefined,
scrollSpeed: e.target.checked ? 5 : undefined
}
}
: el
))
}}
className="rounded"
/>
Enable scrolling text
</label>
</div>
{currentElement.content.scrollDirection && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700">Direction</label>
<select
value={currentElement.content.scrollDirection}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, scrollDirection: e.target.value as 'left' | 'right' } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Speed</label>
<input
type="number"
value={currentElement.content.scrollSpeed || 5}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, scrollSpeed: parseInt(e.target.value) } }
: el
))
}}
min="1"
max="10"
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
/>
</div>
</div>
)}
</>
)}
{/* Clock Properties */}
{currentElement.type === 'clock' && (
<>
<div>
<label className="text-xs font-medium text-gray-700">Time Format</label>
<select
value={currentElement.content.clockFormat || '24h'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, clockFormat: e.target.value as '12h' | '24h' } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="24h">24 Hour</option>
<option value="12h">12 Hour (AM/PM)</option>
</select>
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={currentElement.content.showSeconds !== false}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, showSeconds: e.target.checked } }
: el
))
}}
className="rounded"
/>
Show seconds
</label>
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={currentElement.content.showDate || false}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, showDate: e.target.checked } }
: el
))
}}
className="rounded"
/>
Show date
</label>
</div>
{currentElement.content.showDate && (
<div>
<label className="text-xs font-medium text-gray-700">Date Format</label>
<select
value={currentElement.content.dateFormat || 'short'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, dateFormat: e.target.value as 'short' | 'long' | 'full' } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="short">Short (MM/DD/YYYY)</option>
<option value="full">Full (Mon, Jan 1, 2025)</option>
<option value="long">Long (Monday, January 1, 2025)</option>
</select>
</div>
)}
<div>
<label className="text-xs font-medium text-gray-700">Font Size</label>
<input
type="number"
value={currentElement.content.fontSize || 48}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, fontSize: parseInt(e.target.value) } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700">Text Color</label>
<input
type="color"
value={currentElement.content.fontColor || '#ffffff'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, fontColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Background</label>
<input
type="color"
value={currentElement.content.backgroundColor || '#000000'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, backgroundColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
</div>
</>
)}
{/* Weather Properties */}
{currentElement.type === 'weather' && (
<>
<div>
<label className="text-xs font-medium text-gray-700">Location</label>
<CitySelect
value={currentElement.content.weatherLatitude && currentElement.content.weatherLongitude
? { name: currentElement.content.weatherLocation || '', country: 'ID', lat: currentElement.content.weatherLatitude, lon: currentElement.content.weatherLongitude }
: null}
onChange={(city) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, weatherLocation: city ? `${city.name}${city.admin1 ? ', ' + city.admin1 : ''}, ${city.country}` : '', weatherLatitude: city?.lat, weatherLongitude: city?.lon } }
: el
))
}}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700">Latitude</label>
<input
type="number"
step="0.0001"
value={currentElement.content.weatherLatitude || 0}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, weatherLatitude: parseFloat(e.target.value) } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Longitude</label>
<input
type="number"
step="0.0001"
value={currentElement.content.weatherLongitude || 0}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, weatherLongitude: parseFloat(e.target.value) } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
/>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Layout Style</label>
<select
value={currentElement.content.weatherLayout || 'compact'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, weatherLayout: e.target.value as 'compact' | 'detailed' | 'minimal' } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="minimal">Minimal (Icon + Temp)</option>
<option value="compact">Compact (Default)</option>
<option value="detailed">Detailed (Full Info)</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Temperature Unit</label>
<select
value={currentElement.content.temperatureUnit || 'celsius'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, temperatureUnit: e.target.value as 'celsius' | 'fahrenheit' } }
: el
))
}}
className="w-full mt-1 px-2 py-1 text-xs border rounded text-gray-900 bg-white"
>
<option value="celsius">Celsius (°C)</option>
<option value="fahrenheit">Fahrenheit (°F)</option>
</select>
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={currentElement.content.showHumidity !== false}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, showHumidity: e.target.checked } }
: el
))
}}
className="rounded"
/>
Show humidity
</label>
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={currentElement.content.showWindSpeed !== false}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, showWindSpeed: e.target.checked } }
: el
))
}}
className="rounded"
/>
Show wind speed
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-700">Text Color</label>
<input
type="color"
value={currentElement.content.fontColor || '#ffffff'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, fontColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700">Background</label>
<input
type="color"
value={currentElement.content.backgroundColor || '#1e3a8a'}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, content: { ...el.content, backgroundColor: e.target.value } }
: el
))
}}
className="w-full mt-1 h-8"
/>
</div>
</div>
</>
)}
{/* Common properties */}
<div>
<label className="text-xs font-medium text-gray-700">Opacity (%)</label>
<input
type="range"
min="0"
max="100"
value={currentElement.settings?.opacity || 100}
onChange={(e) => {
setElements(elements.map(el =>
el.id === selectedElement
? { ...el, settings: { ...el.settings, opacity: parseInt(e.target.value) } }
: el
))
}}
className="w-full mt-1"
/>
<span className="text-xs text-gray-500">{currentElement.settings?.opacity || 100}%</span>
</div>
</div>
</Card>
)}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
<ConfirmationModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={async () => {
setIsDeleting(true)
try {
const response = await fetch(`/api/layouts/delete?id=${selectedLayoutId}`, {
method: 'DELETE'
})
if (response.ok) {
setSelectedLayoutId('')
setElements([])
setLayoutName('Untitled Layout')
onLoadLayout?.() // Refresh the layouts list
setShowDeleteModal(false)
} else {
console.error('Failed to delete layout')
}
} catch (error) {
console.error('Failed to delete layout:', error)
} finally {
setIsDeleting(false)
}
}}
title="Delete Layout"
message={`Are you sure you want to delete "${layoutName}"? This action cannot be undone.`}
confirmText="Delete Layout"
cancelText="Cancel"
isDestructive={true}
isLoading={isDeleting}
/>
{/* Custom Resolution Modal */}
{showCustomResolutionModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-96 shadow-xl">
<h3 className="text-lg font-semibold mb-4">Custom Canvas Size</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Width (pixels)
</label>
<input
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. 2960"
min="100"
max="7680"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Height (pixels)
</label>
<input
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. 1440"
min="100"
max="4320"
/>
</div>
<div className="text-sm text-gray-600">
Aspect Ratio: {customWidth && customHeight ?
`${(parseInt(customWidth) / parseInt(customHeight)).toFixed(2)}:1` :
'N/A'
}
</div>
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
💡 Common resolutions:<br/>
• Galaxy Note 9: 2960×1440<br/>
• Full HD: 1920×1080<br/>
• 4K: 3840×2160
</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowCustomResolutionModal(false)
setCustomWidth('1920')
setCustomHeight('1080')
}}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
const width = parseInt(customWidth)
const height = parseInt(customHeight)
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
setSelectedResolution({
width,
height,
label: `Custom (${width}×${height})`
})
setShowCustomResolutionModal(false)
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Apply
</button>
</div>
</div>
</div>
)}
{/* Video Search Modal */}
<VideoSearchModal
isOpen={showVideoSearchModal}
onClose={() => setShowVideoSearchModal(false)}
onSelect={(video) => {
if (selectedElement && currentElement?.type === 'video') {
setElements(elements.map(el =>
el.id === selectedElement
? {
...el,
content: {
...el.content,
videoUrl: video.video_url,
videoId: video.id,
videoTitle: video.title,
thumbnailUrl: video.thumbnail_url || undefined
}
}
: el
))
setSearchQuery(video.title)
}
}}
availableVideos={availableVideos}
currentVideoUrl={currentElement?.type === 'video' ? currentElement.content.videoUrl : undefined}
/>
{/* Context Menu */}
{contextMenu && (
<div
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-[9999] min-w-[180px]"
style={{
top: `${contextMenu.y}px`,
left: `${contextMenu.x}px`,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-2 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600">Video Display</p>
</div>
<button
onClick={() => {
setElements(elements.map(el =>
el.id === contextMenu.elementId
? {
...el,
x: 0,
y: 0,
width: 100,
height: 100,
content: { ...el.content, objectFit: 'cover' }
}
: el
))
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 font-medium text-blue-600 flex items-center gap-2"
>
<Expand className="w-4 h-4" />
<span>Fill Layout Area</span>
</button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={() => {
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, objectFit: 'cover' } }
: el
))
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Zoom to Fill</span>
{(elements.find(el => el.id === contextMenu.elementId)?.content?.objectFit || 'cover') === 'cover' && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
<button
onClick={() => {
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, objectFit: 'contain' } }
: el
))
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Fit to Frame</span>
{elements.find(el => el.id === contextMenu.elementId)?.content?.objectFit === 'contain' && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
<button
onClick={() => {
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, objectFit: 'fill' } }
: el
))
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Stretch to Fill</span>
{elements.find(el => el.id === contextMenu.elementId)?.content?.objectFit === 'fill' && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
<button
onClick={() => {
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, objectFit: 'none' } }
: el
))
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Original Size</span>
{elements.find(el => el.id === contextMenu.elementId)?.content?.objectFit === 'none' && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
<div className="border-t border-gray-100 mt-1 pt-1">
<button
onClick={() => {
const element = elements.find(el => el.id === contextMenu.elementId)
if (element) {
const isMuted = element.content?.muted !== false
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, muted: !isMuted } }
: el
))
}
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Mute Audio</span>
{elements.find(el => el.id === contextMenu.elementId)?.content?.muted !== false && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
<button
onClick={() => {
const element = elements.find(el => el.id === contextMenu.elementId)
if (element) {
const isLooping = element.content?.loop !== false
setElements(elements.map(el =>
el.id === contextMenu.elementId
? { ...el, content: { ...el.content, loop: !isLooping } }
: el
))
}
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between"
>
<span>Loop Video</span>
{elements.find(el => el.id === contextMenu.elementId)?.content?.loop !== false && (
<Check className="w-4 h-4 text-blue-600" />
)}
</button>
</div>
<div className="border-t border-gray-100 mt-1 pt-1">
<button
onClick={() => {
deleteElement(contextMenu.elementId)
setContextMenu(null)
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-red-50 text-red-600 flex items-center gap-2"
>
<Trash2 className="w-3 h-3" />
<span>Delete</span>
</button>
</div>
</div>
)}
{/* Fullscreen Preview Modal */}
{showPreview && (
<div
className="fixed inset-0 z-[9999] bg-black"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowPreview(false)
}
}}
tabIndex={0}
>
{/* Exit button */}
<button
onClick={() => setShowPreview(false)}
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white p-3 rounded-lg backdrop-blur-sm transition-all"
title="Press ESC or click to exit preview"
>
<X className="w-6 h-6" />
</button>
{/* Layout Preview */}
<div
className="w-full h-full relative"
style={{
width: `${selectedResolution.width}px`,
height: `${selectedResolution.height}px`,
margin: '0 auto',
maxWidth: '100vw',
maxHeight: '100vh'
}}
>
{elements.map((element) => (
<div
key={element.id}
className="absolute overflow-hidden"
style={{
left: `${element.x}%`,
top: `${element.y}%`,
width: `${element.width}%`,
height: `${element.height}%`,
zIndex: element.zIndex,
opacity: (element.settings?.opacity || 100) / 100,
borderRadius: `${element.settings?.borderRadius || 0}px`,
padding: `${element.settings?.padding || 0}px`
}}
>
{/* Render content based on type - LIVE VERSION */}
<div className="w-full h-full">
{element.type === 'video' && element.content.videoUrl && (
<video
src={element.content.videoUrl}
className="w-full h-full object-cover"
autoPlay
loop
muted={element.content.muted !== false}
/>
)}
{element.type === 'image' && (() => {
const currentPlaylist = availablePlaylists.find(p => p.id === element.content.playlistId)
const firstImage = currentPlaylist?.images?.[0]
const src = firstImage?.thumbnail_url || firstImage?.image_url || element.content.imageUrl
if (!src) return null
return (
<img
src={src}
alt=""
className="w-full h-full"
style={{ objectFit: element.content.objectFit || 'cover' }}
/>
)
})()}
{element.type === 'website' && element.content.websiteUrl && (
<iframe
src={element.content.websiteUrl}
className="w-full h-full border-0"
title="Website preview"
/>
)}
{element.type === 'text' && (
<div
className="w-full h-full flex items-center justify-center"
style={{
backgroundColor: element.content.backgroundColor || '#ffffff',
color: element.content.fontColor || '#000000',
fontSize: `${element.content.fontSize || 24}px`,
fontFamily: element.content.fontFamily || 'inherit'
}}
>
{element.content.scrollDirection ? (
<div className="w-full overflow-hidden">
<div
className="whitespace-nowrap inline-block animate-scroll"
style={{
animationDuration: `${(element.content.scrollSpeed || 5) * 3}s`,
animationDirection: element.content.scrollDirection === 'right' ? 'reverse' : 'normal'
}}
>
{element.content.text || ''}
<span className="ml-20">{element.content.text || ''}</span>
</div>
</div>
) : (
<div className="w-full px-4 text-center">
{element.content.text || ''}
</div>
)}
</div>
)}
{element.type === 'clock' && (
<ClockElementPreview element={element} />
)}
{element.type === 'weather' && (
<WeatherElementPreview element={element} />
)}
</div>
</div>
))}
</div>
</div>
)}
</>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment