Created
October 13, 2025 03:44
-
-
Save swdevbali/4e3754d5c0c748113d3ca710d07ec664 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| '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