This commit is contained in:
babayaga 2026-01-29 17:57:27 +01:00
parent c8d84b64cd
commit 8ec419b87e
88 changed files with 17070 additions and 739 deletions

207
packages/ui/src/App.tsx Normal file
View File

@ -0,0 +1,207 @@
import React from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { PostNavigationProvider } from "@/contexts/PostNavigationContext";
import { OrganizationProvider } from "@/contexts/OrganizationContext";
import { LogProvider, useLog } from "@/contexts/LogContext";
import { LayoutProvider } from "@/contexts/LayoutContext";
import { MediaRefreshProvider } from "@/contexts/MediaRefreshContext";
import { ProfilesProvider } from "@/contexts/ProfilesContext";
import { WebSocketProvider } from "@/contexts/WS_Socket";
import { registerAllWidgets } from "@/lib/registerWidgets";
import TopNavigation from "@/components/TopNavigation";
import GlobalDragDrop from "@/components/GlobalDragDrop";
// Register all widgets on app boot
registerAllWidgets();
import Index from "./pages/Index";
import Auth from "./pages/Auth";
import Profile from "./pages/Profile";
import Post from "./pages/Post";
import UserProfile from "./pages/UserProfile";
import UserCollections from "./pages/UserCollections";
import Collections from "./pages/Collections";
import NewCollection from "./pages/NewCollection";
import UserPage from "./pages/UserPage";
import NewPage from "./pages/NewPage";
import TagPage from "./pages/TagPage";
import SearchResults from "./pages/SearchResults";
import Wizard from "./pages/Wizard";
import NewPost from "./pages/NewPost";
import Organizations from "./pages/Organizations";
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM"));
const VideoPlayerPlayground = React.lazy(() => import("./pages/VideoPlayerPlayground"));
const VideoFeedPlayground = React.lazy(() => import("./pages/VideoFeedPlayground"));
const VideoPlayerPlaygroundIntern = React.lazy(() => import("./pages/VideoPlayerPlaygroundIntern"));
const NotFound = React.lazy(() => import("./pages/NotFound"));
const AdminPage = React.lazy(() => import("./pages/AdminPage"));
const PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages"));
const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor"));
const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground"));
const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas"));
import LogsPage from "./components/logging/LogsPage";
const queryClient = new QueryClient();
const VersionMap = React.lazy(() => import("./pages/VersionMap"));
//<GlobalDebug />
const AppWrapper = () => {
const location = useLocation();
const isFullScreenPage = location.pathname.startsWith('/video-feed');
const containerClassName = isFullScreenPage
? "flex flex-col min-h-svh transition-colors duration-200 bg-background h-full"
: "mx-auto 2xl:max-w-7xl flex flex-col min-h-svh transition-colors duration-200 bg-background h-full";
return (
<div className={containerClassName}>
{!isFullScreenPage && <TopNavigation />}
<GlobalDragDrop />
<Routes>
{/* Top-level routes (no organization context) */}
<Route path="/" element={<Index />} />
<Route path="/auth" element={<Auth />} />
<Route path="/profile" element={<Profile />} />
<Route path="/post/:id" element={<Post />} />
<Route path="/video/:id" element={<Post />} />
<Route path="/user/:userId" element={<UserProfile />} />
<Route path="/user/:userId/collections" element={<UserCollections />} />
<Route path="/user/:userId/pages/new" element={<NewPage />} />
<Route path="/user/:userId/pages/:slug" element={<UserPage />} />
<Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/tags/:tag" element={<TagPage />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/wizard" element={<Wizard />} />
<Route path="/new" element={<NewPost />} />
<Route path="/version-map/:id" element={
<React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}>
<VersionMap />
</React.Suspense>
} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} />
<Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} />
<Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
{/* Admin Routes */}
<Route path="/admin/users" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
{/* Organization-scoped routes */}
<Route path="/org/:orgSlug" element={<Index />} />
<Route path="/org/:orgSlug/auth" element={<Auth />} />
<Route path="/org/:orgSlug/post/:id" element={<Post />} />
<Route path="/org/:orgSlug/video/:id" element={<Post />} />
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
<Route path="/org/:orgSlug/user/:userId/pages/:slug" element={<UserPage />} />
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
<Route path="/org/:orgSlug/search" element={<SearchResults />} />
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
<Route path="/org/:orgSlug/new" element={<NewPost />} />
<Route path="/org/:orgSlug/version-map/:id" element={
<React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}>
<VersionMap />
</React.Suspense>
} />
<Route path="/org/:orgSlug/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
{/* Playground Routes */}
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
<Route path="/test-cache/:id" element={<CacheTest />} />
{/* Logs */}
<Route path="/logs" element={<LogsPage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<React.Suspense fallback={<div>Loading...</div>}><NotFound /></React.Suspense>} />
</Routes >
</div >
);
};
import { initFormatDetection } from '@/utils/formatDetection';
import { SWRConfig } from 'swr';
import CacheTest from "./pages/CacheTest";
// ... (imports)
import { FeedCacheProvider } from "@/contexts/FeedCacheContext";
// ... (imports)
const App = () => {
React.useEffect(() => {
initFormatDetection();
}, []);
return (
<SWRConfig value={{ provider: () => new Map() }}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<LogProvider>
<PostNavigationProvider>
<MediaRefreshProvider>
<LayoutProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<OrganizationProvider>
<ProfilesProvider>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<FeedCacheProvider>
<AppWrapper />
</FeedCacheProvider>
</WebSocketProvider>
</ProfilesProvider>
</OrganizationProvider>
</BrowserRouter>
</TooltipProvider>
</LayoutProvider>
</MediaRefreshProvider>
</PostNavigationProvider>
</LogProvider>
</AuthProvider>
</QueryClientProvider>
</SWRConfig>
);
};
// Update Routes in AppWrapper to include /test-cache/:id
// ...
// We need to inject the route inside AppWrapper component defined above
// Since ReplaceFileContent works on blocks, I'll target the Routes block in a separate call or try to merge.
// Merging is safer if I can target the App component specifically.
// But the Routes are in AppWrapper.
// Let's split this into two edits.
export default App;

View File

@ -0,0 +1,117 @@
import React from 'react';
import { EmbedRenderer } from './pages/Post/renderers/EmbedRenderer';
import UserPage from './pages/UserPage';
import { Toaster } from "@/components/ui/sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from 'react-router-dom';
import { LayoutProvider } from './contexts/LayoutContext';
import { AuthProvider } from '@/hooks/useAuth';
import { LogProvider } from '@/contexts/LogContext';
interface EmbedAppProps {
initialState: any;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
const EmbedApp: React.FC<EmbedAppProps> = ({ initialState }) => {
const { post, mediaItems, authorProfile, page } = initialState;
if (!post && !mediaItems && !page) {
return (
<div className="flex items-center justify-center h-full w-full bg-background text-muted-foreground p-4 text-center">
<p>Content not found or failed to load.</p>
</div>
);
}
if (page) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<LogProvider>
<MemoryRouter>
<LayoutProvider>
<UserPage
initialPage={page}
embedded={true}
userId={page.owner}
slug={page.slug}
/>
<Toaster />
</LayoutProvider>
</MemoryRouter>
</LogProvider>
</AuthProvider>
</QueryClientProvider>
);
}
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<LogProvider>
<div className="w-full h-full bg-background overflow-hidden relative">
<EmbedRenderer
post={post}
mediaItems={mediaItems || []}
authorProfile={authorProfile}
// Pass simplified props
mediaItem={mediaItems?.[0]}
user={null}
isOwner={false}
isLiked={false}
likesCount={post?.likes_count || 0}
// No-ops for actions
onEditPost={() => { }}
onViewModeChange={() => { }}
onExportMarkdown={() => { }}
onDeletePost={() => { }}
onDeletePicture={() => { }}
onLike={() => { }}
onEditPicture={() => { }}
onMediaSelect={() => { }}
onExpand={() => {
// Open post in new tab
window.open(`/post/${post?.id || mediaItems?.[0]?.id}`, '_blank');
}}
onDownload={() => { }}
currentImageIndex={0}
videoPlaybackUrl=""
videoPosterUrl=""
versionImages={[]}
handlePrevImage={() => { }}
handleNavigate={(dir) => { }}
navigationData={null}
isEditMode={false}
localPost={null}
setLocalPost={() => { }}
localMediaItems={[]}
setLocalMediaItems={() => { }}
onMoveItem={() => { }}
onEditModeToggle={() => { }}
onSaveChanges={() => { }}
onYouTubeAdd={() => { }}
onTikTokAdd={() => { }}
onAIWizardOpen={() => { }}
onInlineUpload={async () => { }}
onRemoveFromPost={() => { }}
onGalleryPickerOpen={() => { }}
onUnlinkImage={() => { }}
/>
<Toaster />
</div>
</LogProvider>
</AuthProvider>
</QueryClientProvider>
);
};
export default EmbedApp;

181
packages/ui/src/Logger.ts Normal file
View File

@ -0,0 +1,181 @@
import { toast } from 'sonner';
import React from 'react';
export type LogLevel = 'info' | 'warn' | 'error' | 'success' | 'debug';
export interface LogEntry {
level: LogLevel;
message: string;
timestamp: number;
data?: any;
}
export interface LoggerConfig {
enableConsole: boolean;
enableToaster: boolean;
deduplicationWindow: number; // ms
}
class Logger {
private static instance: Logger;
private config: LoggerConfig;
private recentLogs: Map<string, number> = new Map();
private constructor() {
this.config = {
enableConsole: true,
enableToaster: true,
deduplicationWindow: 5000, // 5 seconds
};
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public configure(config: Partial<LoggerConfig>): void {
this.config = { ...this.config, ...config };
}
private isDuplicate(message: string): boolean {
const now = Date.now();
const lastLogTime = this.recentLogs.get(message);
if (lastLogTime && (now - lastLogTime) < this.config.deduplicationWindow) {
return true;
}
this.recentLogs.set(message, now);
// Clean up old entries
for (const [msg, timestamp] of this.recentLogs.entries()) {
if (now - timestamp > this.config.deduplicationWindow) {
this.recentLogs.delete(msg);
}
}
return false;
}
private logToConsole(level: LogLevel, message: string, data?: any): void {
if (!this.config.enableConsole) return;
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case 'error':
console.error(prefix, message, data || '');
break;
case 'warn':
console.warn(prefix, message, data || '');
break;
case 'debug':
console.debug(prefix, message, data || '');
break;
case 'info':
case 'success':
default:
console.log(prefix, message, data || '');
break;
}
}
private logToToaster(level: LogLevel, message: string): void {
if (!this.config.enableToaster) return;
const toastId = toast(
React.createElement(
'div',
{
onClick: () => toast.dismiss(toastId),
style: { cursor: 'pointer', width: '100%' },
},
message
)
);
switch (level) {
case 'error':
toast.error(message, { id: toastId });
break;
case 'warn':
toast.warning(message, { id: toastId });
break;
case 'success':
toast.success(message, { id: toastId });
break;
case 'info':
toast.info(message, { id: toastId });
break;
case 'debug':
// Don't show debug messages in toaster by default
toast.dismiss(toastId);
break;
}
}
public log(level: LogLevel, message: string, data?: any): void {
if (this.isDuplicate(message)) {
return;
}
this.logToConsole(level, message, data);
this.logToToaster(level, message);
}
public info(message: string, data?: any): void {
this.log('info', message, data);
}
public warn(message: string, data?: any): void {
this.log('warn', message, data);
}
public error(message: string, data?: any): void {
this.log('error', message, data);
}
public success(message: string, data?: any): void {
this.log('success', message, data);
}
public debug(message: string, data?: any): void {
this.log('debug', message, data);
}
// Convenience methods for common patterns
public logError(error: unknown, context?: string): void {
const message = context
? `${context}: ${error instanceof Error ? error.message : String(error)}`
: error instanceof Error ? error.message : String(error);
this.error(message, error instanceof Error ? error.stack : undefined);
}
public logApiError(operation: string, error: unknown): void {
this.logError(error, `Error during ${operation}`);
}
public logWebSocketError(operation: string, error: unknown): void {
this.logError(error, `WebSocket error during ${operation}`);
}
// Method to temporarily disable toaster (useful for batch operations)
public withoutToaster<T>(fn: () => T): T {
const originalToasterState = this.config.enableToaster;
this.config.enableToaster = false;
try {
return fn();
} finally {
this.config.enableToaster = originalToasterState;
}
}
}
// Export singleton instance
export const logger = Logger.getInstance();
export default logger;

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -16,8 +16,8 @@ const GlobalDragDrop = () => {
const isValidDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types || [];
if (types.includes('polymech/internal')) return false;
// Allow Files or Links (text/uri-list)
return types.includes('Files') || types.includes('text/uri-list');
// Allow Files or Links (text/uri-list) or text/plain (often used for links on mobile)
return types.includes('Files') || types.includes('text/uri-list') || types.includes('text/plain');
};
const handleDragEnter = (e: DragEvent) => {

View File

@ -1,7 +1,6 @@
# ImageWizard Component Structure
## Overview
The ImageWizard is split into focused modules for better maintainability.
## File Structure
@ -30,25 +29,21 @@ ImageWizard/
## Recent Refactorings
### 1. ✅ Prompt Splitter Extracted (`promptHandlers.ts`)
- `generateImageSplit` - Sequential multi-prompt generation
- Uses `splitPromptByLines` from `@constants.ts`
### 2. ✅ Logger Refactored (`utils/logger.ts`)
- Changed from `addLog('debug', ...)` to `logger.debug(...)`
- Created `Logger` interface with clean methods: `debug()`, `info()`, `warning()`, `error()`, `success()`, `verbose()`
- All handlers now accept `Logger` instead of raw `addLog` function
- Component name auto-prefixed in logs
### 3. ✅ DEFAULT_QUICK_ACTIONS Moved to `@constants.ts`
- `QuickAction` interface now exported from constants
- Available throughout the codebase
- Removed duplicate from `types.ts`
### 4. ✅ Drag and Drop Extracted (`dropHandlers.ts`)
- `handleDragEnter` - Show drop overlay
- `handleDragOver` - Keep overlay active
- `handleDragLeave` - Debounced hide (prevents flicker)
@ -57,7 +52,6 @@ ImageWizard/
## Handler Categories
### 📸 imageHandlers.ts
- `handleFileUpload` - Upload files
- `toggleImageSelection` - Select/deselect images
- `removeImageRequest` - Request image deletion
@ -66,40 +60,32 @@ ImageWizard/
- `handleDownloadImage` - Download image
### 🎨 generationHandlers.ts
- `handleOptimizePrompt` - Optimize prompt with AI
- `buildFullPrompt` - Build prompt with preset context
- `abortGeneration` - Cancel generation
### 📝 promptHandlers.ts
- `generateImageSplit` - Sequential multi-prompt generation
### 🎤 voiceHandlers.ts
- `handleMicrophone` - Record audio
- `handleVoiceToImage` - Voice-to-image workflow with AI
### 🤖 agentHandlers.ts
- `handleAgentGeneration` - AI Agent mode with tool calling
### 📤 publishHandlers.ts
- `publishImage` - Standard publish
- `quickPublishAsNew` - Quick publish with prompt as description
### 💾 dataHandlers.ts
- `loadFamilyVersions` - Load image version families
- `loadAvailableImages` - Load gallery images
### ⚙️ settingsHandlers.ts
- Template, preset, workflow, history management
### 🎯 dropHandlers.ts
- Drag and drop file handling
## Usage Example
@ -119,7 +105,6 @@ logger.info('File uploaded successfully');
```
## Benefits
✅ Easier to find specific logic
✅ Better code organization
✅ Simpler testing
@ -127,3 +112,4 @@ logger.info('File uploaded successfully');
✅ Clear separation of concerns
✅ Clean logging API
✅ Reusable utilities

View File

@ -14,7 +14,7 @@ import UserPage from "@/pages/UserPage";
interface ListLayoutProps {
sortBy?: FeedSortOption;
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget';
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
navigationSourceId?: string;
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
}
@ -199,7 +199,7 @@ export const ListLayout = ({
</div>
{/* Right: Detail */}
<div className="flex-1 bg-background overflow-hidden relative">
<div className="flex-1 bg-background overflow-hidden relative h-full">
{selectedId ? (
(() => {
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);

View File

@ -54,12 +54,28 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// Configure marked options for regular markdown
// Configure marked options
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub flavored markdown
breaks: true,
gfm: true,
});
// Custom renderer to add IDs to headings
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const slug = text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
return `<h${depth} id="${slug}">${text}</h${depth}>`;
};
marked.use({ renderer });
// Convert markdown to HTML
const rawHtml = marked.parse(decodedContent) as string;

View File

@ -0,0 +1,503 @@
import { useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel
} from "@/components/ui/dropdown-menu";
import { T, translate } from "@/i18n";
import { PagePickerDialog } from "./widgets/PagePickerDialog";
import { PageCreationWizard } from "./widgets/PageCreationWizard";
import { cn } from "@/lib/utils";
interface Page {
id: string;
title: string;
content: any;
visible: boolean;
is_public: boolean;
owner: string;
slug: string;
parent: string | null;
}
interface PageActionsProps {
page: Page;
isOwner: boolean;
isEditMode?: boolean;
onToggleEditMode?: () => void;
onPageUpdate: (updatedPage: Page) => void;
onDelete?: () => void;
className?: string;
showLabels?: boolean;
}
export const PageActions = ({
page,
isOwner,
isEditMode = false,
onToggleEditMode,
onPageUpdate,
onDelete,
className,
showLabels = true
}: PageActionsProps) => {
const [loading, setLoading] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
const [showCreationWizard, setShowCreationWizard] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const handleParentUpdate = async (parentId: string | null) => {
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ parent: parentId })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, parent: parentId });
toast.success(translate('Page parent updated'));
} catch (error) {
console.error('Error updating page parent:', error);
toast.error(translate('Failed to update page parent'));
} finally {
setLoading(false);
}
};
const handleToggleVisibility = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, visible: !page.visible });
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
} finally {
setLoading(false);
}
};
const handleTogglePublic = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, is_public: !page.is_public });
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));
} catch (error) {
console.error('Error toggling public status:', error);
toast.error(translate('Failed to update page status'));
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
const url = window.location.href;
const title = page.title || 'PolyMech Page';
if (navigator.share && navigator.canShare({ url, title })) {
try {
await navigator.share({ url, title });
return;
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error('Share failed', e);
}
}
try {
await navigator.clipboard.writeText(url);
toast.success("Link copied to clipboard");
} catch (e) {
console.error('Clipboard failed', e);
toast.error("Failed to copy link");
}
};
const processPageContent = (content: any): string => {
if (!content) return '';
if (typeof content === 'string') return content;
let markdown = '';
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
try {
// Determine content root
// Some versions might have content directly, others wrapped in { pages: { [id]: { containers: ... } } }
let root = content;
if (content.pages) {
// Try to find the page by ID or take the first one
const pageIdKey = `page-${page.id}`;
if (content.pages[pageIdKey]) {
root = content.pages[pageIdKey];
} else {
// Fallback: take first key
const keys = Object.keys(content.pages);
if (keys.length > 0) root = content.pages[keys[0]];
}
}
// Traverse containers
if (root.containers && Array.isArray(root.containers)) {
root.containers.forEach((container: any) => {
if (container.widgets && Array.isArray(container.widgets)) {
container.widgets.forEach((widget: any) => {
if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) {
markdown += widget.props.content + '\n\n';
}
// Future: Handle other widgets if needed
});
}
});
} else if (root.widgets && Array.isArray(root.widgets)) { // Fallback for simple structure
root.widgets.forEach((widget: any) => {
if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) {
markdown += widget.props.content + '\n\n';
}
});
}
} catch (e) {
console.error('Error parsing page content:', e);
return JSON.stringify(content, null, 2); // Fallback to raw JSON
}
// URL Resolution logic is handled by markdown-text widget content usually containing relative URLs
// If we need to process them:
// markdown = markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => { ... });
// For now returning raw markdown as user requested "mind url" likely meant for PDF which runs server side.
// Client side export usually keeps links as is unless they are purely internal IDs.
return markdown;
};
const getSlug = (text: string) => text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
const handleExportMarkdown = () => {
try {
let content = processPageContent(page.content);
// Generate TOC
const lines = content.split('\n');
let toc = '# Table of Contents\n\n';
let hasHeadings = false;
lines.forEach(line => {
// Determine header level
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
hasHeadings = true;
const level = match[1].length;
const text = match[2];
const slug = getSlug(text);
const indent = ' '.repeat(level - 1);
toc += `${indent}- [${text}](#${slug})\n`;
}
});
if (hasHeadings) {
content = `${toc}\n---\n\n${content}`;
}
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Markdown downloaded");
} catch (e) {
console.error("Markdown export failed", e);
toast.error("Failed to export Markdown");
}
};
const handleEmbed = async () => {
// Embed logic: Copy iframe code
// Route: /embed/:id is currently for posts. But maybe it works for pages if we update the backend?
// Wait, current serving index.ts handleGetEmbed fetches from 'posts' table only.
// User asked for "embed (HTML)".
// Assuming we should point to a route that RENDERS the page.
// If we don't have a dedicated page embed route yet, we might need one.
// However, the user said "see dedicated app route ( @[src/main-embed.tsx] )".
// This suggests the frontend app handles it.
// The backend route `/embed/:id` serves the HTML that bootstraps `main-embed.tsx`.
// So we just need to use that URL. BUT `handleGetEmbed` in backend fetches from `posts`.
// We probably need `handleGetEmbedPage` or update `handleGetEmbed` to support pages.
// For now, let's assume valid URL structure is `/embed/page/:id` which we plan to add or `/embed/:id` if we unify.
// Let's use `/embed/page/${page.id}` in the snippet and ensure backend supports it.
const embedUrl = `${baseUrl}/embed/page/${page.id}`;
const iframeCode = `<iframe src="${embedUrl}" width="100%" height="600" frameborder="0" allowfullscreen></iframe>`;
try {
await navigator.clipboard.writeText(iframeCode);
toast.success("Embed code copied to clipboard");
} catch (e) {
toast.error("Failed to copy embed code");
}
};
const handleExportAstro = async () => {
try {
// Re-use markdown export logic
let content = processPageContent(page.content);
const slug = getSlug(page.title || 'page');
// Generate TOC (same as markdown export)
const lines = content.split('\n');
let toc = '# Table of Contents\n\n';
let hasHeadings = false;
lines.forEach(line => {
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
hasHeadings = true;
const level = match[1].length;
const text = match[2];
const id = getSlug(text);
const indent = ' '.repeat(level - 1);
toc += `${indent}- [${text}](#${id})\n`;
}
});
if (hasHeadings) {
content = `${toc}\n---\n\n${content}`;
}
// Construct Front Matter
const safeTitle = (page.title || 'Untitled').replace(/"/g, '\\"');
const safeSlug = (page.slug || slug).replace(/"/g, '\\"');
const dateStr = new Date().toISOString().split('T')[0];
// Note: Page interface in PageActions might need update to include tags if we want them
// For now, using standard props we have
const frontMatter = `---
title: "${safeTitle}"
slug: "${safeSlug}"
date: "${dateStr}"
author: "${page.owner}"
draft: ${!page.visible}
---
`;
const finalContent = frontMatter + content;
const blob = new Blob([finalContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${slug}.astro`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Astro export downloaded");
} catch (e) {
console.error("Astro export failed", e);
toast.error("Failed to export Astro");
}
};
const handleExportPdf = async () => {
setIsGeneratingPdf(true);
toast.info("Generating PDF...");
try {
const link = document.createElement('a');
link.href = `${baseUrl}/api/render/pdf/page/${page.id}`;
link.target = "_blank";
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
console.error(e);
toast.error("Failed to download PDF");
} finally {
setIsGeneratingPdf(false);
}
};
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Share Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Share2 className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Share</T></span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Share & Export</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="h-4 w-4 mr-2" />
<span>Copy Link</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<Download className="h-4 w-4 mr-2" />
<span>Export Markdown</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf} disabled={isGeneratingPdf}>
<FileText className="h-4 w-4 mr-2" />
<span>{isGeneratingPdf ? 'Generating PDF...' : 'Export PDF'}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportAstro}>
<FileText className="mr-2 h-4 w-4" />
<span><T>Export Astro</T> (Beta)</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleEmbed}>
<LinkIcon className="h-4 w-4 mr-2" />
<span><T>Embed (HTML)</T></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Owner Controls */}
{isOwner && (
<>
<Button
variant="outline"
size="sm"
onClick={handleToggleVisibility}
disabled={loading}
className={cn(!page.visible && "text-muted-foreground")}
>
{page.visible ? (
<>
<Eye className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Visible</T></span>}
</>
) : (
<>
<EyeOff className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Hidden</T></span>}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleTogglePublic}
disabled={loading}
className={cn(!page.is_public && "text-muted-foreground")}
>
{showLabels ? (
<>
<span className="hidden md:inline"><T>{page.is_public ? 'Public' : 'Private'}</T></span>
<span className="md:hidden"><T>{page.is_public ? 'Pub' : 'Priv'}</T></span>
</>
) : (
<span className="text-xs">{page.is_public ? 'Pub' : 'Priv'}</span>
)}
</Button>
{onToggleEditMode && (
<Button
variant={isEditMode ? "default" : "outline"}
size="sm"
onClick={(e) => { e.stopPropagation(); onToggleEditMode(); }}
className={isEditMode ? "bg-primary text-white" : ""}
>
{isEditMode ? (
<>
<Eye className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>View</T></span>}
</>
) : (
<>
<Edit3 className={cn("h-4 w-4", showLabels && "md:mr-2")} />
{showLabels && <span className="hidden md:inline"><T>Edit</T></span>}
</>
)}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowPagePicker(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Set Parent Page")}
>
<GitMerge className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Parent</T></span>}
</Button>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handleParentUpdate}
currentValue={page.parent}
forbiddenIds={[page.id]}
/>
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowCreationWizard(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Add Child Page")}
>
<FilePlus className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Add Child</T></span>}
</Button>
<PageCreationWizard
isOpen={showCreationWizard}
onClose={() => setShowCreationWizard(false)}
parentId={page.id}
/>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
);
};

View File

@ -59,7 +59,6 @@ const MediaGrid = ({
navigationSourceId,
isOwner = false,
onFilesDrop,
showVideos = true,
sortBy = 'latest',
supabaseClient,

View File

@ -26,6 +26,7 @@ export interface ResponsiveData {
srcset: string;
type: string;
}[];
stats?: any;
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
@ -33,8 +34,8 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
sizes = '(max-width: 1024px) 100vw, 50vw',
className,
imgClassName,
responsiveSizes = [180, 640, 1024, 1280, 1600],
formats = ['avif', 'webp'],
responsiveSizes = [640, 1280],
formats = ['avif'],
alt,
onDataLoaded,
rootMargin = '800px',
@ -111,7 +112,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
if (!isInView || isLoadingOrPending) {
// Use className for wrapper if provided, otherwise generic
// We attach the ref here to detect when this placeholder comes into view
return <div ref={ref} className={`animate-pulse bg-gray-200 w-full h-full ${className || ''}`} />;
return <div ref={ref} className={`animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
}
if (error || !data) {
@ -151,7 +152,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
/>
</picture>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100/50 z-10 pointer-events-none">
<div className="absolute inset-0 flex items-center justify-center dark:bg-gray-800 bg-gray-100/50 z-10 pointer-events-none">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
)}

View File

@ -84,11 +84,7 @@ const VideoCard = ({
// Stop playback on navigation & Cleanup
useEffect(() => {
console.log(`[VideoCard ${videoId}] Mounted`);
const handleNavigation = () => {
if (isPlaying) {
console.log(`[VideoCard ${videoId}] Navigation detected - stopping`);
}
setIsPlaying(false);
player.current?.pause();
};
@ -96,7 +92,6 @@ const VideoCard = ({
handleNavigation();
return () => {
console.log(`[VideoCard ${videoId}] Unmounting - pausing player`);
player.current?.pause();
};
}, [location.pathname]);
@ -383,7 +378,6 @@ const VideoCard = ({
};
const handleClick = (e: React.MouseEvent) => {
console.log('Video clicked');
e.preventDefault();
e.stopPropagation();
onClick?.(videoId);
@ -394,7 +388,6 @@ const VideoCard = ({
const handleStopVideo = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail?.sourceId !== videoId && isPlaying) {
console.log(`[VideoCard ${videoId}] Stopping due to global event`);
setIsPlaying(false);
player.current?.pause();
}
@ -405,7 +398,6 @@ const VideoCard = ({
}, [isPlaying, videoId]);
const handlePlayClick = (e: React.MouseEvent) => {
console.log('Play clicked');
e.preventDefault();
e.stopPropagation();

View File

@ -13,6 +13,8 @@ interface GenericCanvasProps {
isEditMode?: boolean;
showControls?: boolean;
className?: string;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
@ -21,6 +23,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
isEditMode = false,
showControls = true,
className = '',
selectedWidgetId,
onSelectWidget
}) => {
const {
loadedPages,
@ -211,8 +215,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
onClick={handleSaveToApi}
size="sm"
disabled={isSaving}
className={`glass-button ${
saveStatus === 'success'
className={`glass-button ${saveStatus === 'success'
? 'bg-green-500 text-white'
: saveStatus === 'error'
? 'bg-red-500 text-white'
@ -298,6 +301,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
onSelect={handleSelectContainer}
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
onRemoveWidget={async (widgetId) => {
try {
await removeWidgetFromPage(pageId, widgetId);

View File

@ -26,6 +26,8 @@ interface LayoutContainerProps {
onMoveContainer?: (containerId: string, direction: 'up' | 'down') => void;
canMoveContainerUp?: boolean;
canMoveContainerDown?: boolean;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
depth?: number;
isCompactMode?: boolean;
}
@ -46,6 +48,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onMoveContainer,
canMoveContainerUp,
canMoveContainerDown,
selectedWidgetId,
onSelectWidget,
depth = 0,
isCompactMode = false,
}) => {
@ -105,6 +109,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
widget={widget}
isEditMode={isEditMode}
pageId={pageId}
isSelected={selectedWidgetId === widget.id}
onSelect={() => onSelectWidget?.(widget.id)}
canMoveUp={index > 0}
canMoveDown={index < container.widgets.length - 1}
onRemove={onRemoveWidget}
@ -160,6 +166,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onMoveContainer={onMoveContainer}
canMoveContainerUp={canMoveContainerUp}
canMoveContainerDown={canMoveContainerDown}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
depth={depth + 1}
isCompactMode={isCompactMode}
/>
@ -437,6 +445,8 @@ interface WidgetItemProps {
canMoveDown: boolean;
onRemove?: (widgetInstanceId: string) => void;
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
isSelected?: boolean;
onSelect?: () => void;
}
const WidgetItem: React.FC<WidgetItemProps> = ({
@ -447,9 +457,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
canMoveDown,
onRemove,
onMove,
isSelected,
onSelect
}) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps } = useLayout();
const { updateWidgetProps, renameWidget } = useLayout();
const [showSettingsModal, setShowSettingsModal] = useState(false);
// pageId is now passed as a prop from the parent component
@ -489,7 +501,7 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
};
return (
<div className="relative group">
<div className="relative group" id={`widget-item-${widget.id}`}>
{/* Edit Mode Controls */}
{isEditMode && (
<>
@ -531,22 +543,47 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
</div>
</div>
{/* Move Controls - Cross Pattern */}
{/* Move Controls - Cross Pattern (Only show on hover or selection) */}
<div className={cn(
"absolute top-8 left-2 z-10 transition-opacity duration-200",
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<WidgetMovementControls
className="absolute top-8 left-2 z-10"
onMove={(direction) => onMove?.(widget.id, direction)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
</div>
</>
)}
{/* Widget Content - Always 100% width */}
<div className="w-full bg-white dark:bg-slate-800 overflow-hidden">
{/* Widget Content - With selection wrapper */}
<div
className={cn(
"w-full bg-white dark:bg-slate-800 overflow-hidden transition-all duration-200",
// Selection Visuals & Margins
isEditMode && "rounded-lg border-2",
isEditMode && isSelected ? "border-blue-500 ring-4 ring-blue-500/10 shadow-lg z-10" : "border-transparent",
isEditMode && !isSelected && "hover:border-blue-300 dark:hover:border-blue-700",
// Margin between header/content - applied via padding on this wrapper or margin on content?
// Using padding-top on wrapper to separate from title bar overlay
isEditMode && "pt-8" // Space for title bar
)}
onClick={(e) => {
if (isEditMode) {
e.preventDefault(); // Prevent focus stealing if clicking background
e.stopPropagation();
onSelect?.();
}
}}
>
<WidgetComponent
{...(widget.props || {})}
widgetInstanceId={widget.id}
widgetDefId={widget.widgetId}
isEditMode={isEditMode}
onPropsChange={async (newProps: Record<string, any>) => {
try {
@ -559,7 +596,8 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
</div>
{/* Generic Settings Modal */}
{widgetDefinition.metadata.configSchema && showSettingsModal && (
{
widgetDefinition.metadata.configSchema && showSettingsModal && (
<WidgetSettingsManager
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
@ -567,27 +605,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
currentProps={widget.props || {}}
onSave={handleSettingsSave}
/>
)}
</div>
)
}
</div >
);
};
// Conservative comparison function for React.memo
const areLayoutContainerPropsEqual = (
prevProps: LayoutContainerProps,
nextProps: LayoutContainerProps
): boolean => {
// Conservative approach: only prevent re-render for truly identical props
// Since we're using deep cloning in LayoutContext, object references will change
// when the layout data actually changes, so we can be more conservative here
return (
prevProps.isEditMode === nextProps.isEditMode &&
prevProps.selectedContainerId === nextProps.selectedContainerId &&
prevProps.depth === nextProps.depth &&
prevProps.container === nextProps.container // Reference equality check
);
};
// Export memoized component with conservative comparison
export const LayoutContainer = React.memo(LayoutContainerComponent, areLayoutContainerPropsEqual);
// Export without memoization to ensure reliable updates (Selection highlighting fix)
export const LayoutContainer = LayoutContainerComponent;

View File

@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
interface SelectionHandlerProps {
onMoveSelection: (direction: 'up' | 'down' | 'left' | 'right') => void;
onClearSelection: () => void;
enabled?: boolean;
}
export const SelectionHandler: React.FC<SelectionHandlerProps> = ({
onMoveSelection,
onClearSelection,
enabled = true
}) => {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in a form field
const active = document.activeElement;
if (active && (
active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
active.tagName === 'SELECT' ||
(active as HTMLElement).isContentEditable
)) {
return;
}
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
onMoveSelection('up');
break;
case 'ArrowDown':
e.preventDefault();
onMoveSelection('down');
break;
case 'ArrowLeft':
e.preventDefault();
onMoveSelection('left');
break;
case 'ArrowRight':
e.preventDefault();
onMoveSelection('right');
break;
case 'Escape':
e.preventDefault();
onClearSelection();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onMoveSelection, onClearSelection, enabled]);
return null; // Headless component
};

View File

@ -47,13 +47,26 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
if (!isVisible) return null;
const allWidgets = widgetRegistry.getAll();
// Use a Set to collect unique categories from all widgets
const dynamicCategories = Array.from(new Set([
'all',
'control',
'display',
'chart',
'system',
'custom',
...allWidgets.map(w => w.metadata.category)
]));
const widgets = searchQuery
? widgetRegistry.search(searchQuery)
: selectedCategory === 'all'
? widgetRegistry.getAll()
? allWidgets
: widgetRegistry.getByCategory(selectedCategory);
const categories = ['all', 'control', 'display', 'chart', 'system', 'custom'];
const categories = dynamicCategories;
const modalContent = (
<div

View File

@ -0,0 +1,183 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { T } from '@/i18n';
import { Settings, FileJson, LayoutTemplate, Save, ClipboardList, Mail } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates';
interface PlaygroundHeaderProps {
viewMode: 'design' | 'preview';
setViewMode: (mode: 'design' | 'preview') => void;
handleExportHtml: () => void;
onSendTestEmail: () => void;
htmlSize?: number;
// Template Menu
templates: ILayoutTemplate[];
handleLoadTemplate: (template: ILayoutTemplate) => void;
onSaveTemplateClick: () => void;
// Other Actions
onPasteJsonClick: () => void;
handleDumpJson: () => void;
handleLoadContext: () => void;
// Edit Mode
isEditMode: boolean;
setIsEditMode: (mode: boolean) => void;
}
export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
viewMode,
setViewMode,
handleExportHtml,
onSendTestEmail,
htmlSize,
templates,
handleLoadTemplate,
onSaveTemplateClick,
onPasteJsonClick,
handleDumpJson,
handleLoadContext,
isEditMode,
setIsEditMode
}) => {
return (
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0 p-4 flex flex-wrap gap-4 justify-between items-center">
<div>
<h1 className="text-xl font-bold">Canvas Playground</h1>
<p className="text-sm text-muted-foreground">Experiment with widgets and layout</p>
</div>
<div className="flex flex-wrap gap-2 border-l pl-2 ml-2 items-center">
<Button
variant={viewMode === 'design' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('design')}
>
Design
</Button>
<Button
variant={viewMode === 'preview' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('preview')}
>
Preview
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleExportHtml}
title="Export HTML"
>
<Save className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<LayoutTemplate className="h-4 w-4" />
<T>Layouts</T>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Predefined Layouts</DropdownMenuLabel>
<DropdownMenuGroup>
{templates.filter(t => t.isPredefined).map((t, i) => (
<DropdownMenuItem key={`pre-${i}`} onClick={() => handleLoadTemplate(t)}>
{t.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>My Layouts</DropdownMenuLabel>
<DropdownMenuGroup>
{templates.filter(t => !t.isPredefined).length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No saved layouts</div>
)}
{templates.filter(t => !t.isPredefined).map((t, i) => (
<DropdownMenuItem key={`cust-${i}`} onClick={() => handleLoadTemplate(t)}>
{t.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSaveTemplateClick}>
<Save className="mr-2 h-4 w-4" />
<span>Save Current as Template...</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={onPasteJsonClick}
className="gap-2"
>
<ClipboardList className="h-4 w-4" />
<T>Paste JSON</T>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDumpJson}
className="gap-2"
>
<FileJson className="h-4 w-4" />
<T>Dump JSON</T>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleLoadContext}
className="gap-2"
>
<Mail className="h-4 w-4" />
<T>Load Email Context</T>
</Button>
{htmlSize !== undefined && (
<div className={`text-xs px-2 py-1 rounded border gap-1 flex items-center ${htmlSize > 102000 ? 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800' :
htmlSize > 80000 ? 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800' :
'bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700'
}`} title="Gmail clips HTML larger than 102KB">
<span className="font-mono">{(htmlSize / 1024).toFixed(1)}KB</span>
<span className="opacity-50">/ 102KB</span>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={onSendTestEmail}
className="gap-2"
>
<Mail className="h-4 w-4" />
<T>Send Test Email</T>
</Button>
<Button
variant={isEditMode ? "secondary" : "ghost"}
size="sm"
onClick={() => setIsEditMode(!isEditMode)}
className="gap-2"
>
<Settings className="h-4 w-4" />
<T>{isEditMode ? 'Disable Edit Mode' : 'Enable Edit Mode'}</T>
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,96 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface TemplateDialogsProps {
// Save Dialog
isSaveDialogOpen: boolean;
setIsSaveDialogOpen: (open: boolean) => void;
newTemplateName: string;
setNewTemplateName: (name: string) => void;
handleSaveTemplate: () => void;
// Paste JSON Dialog
isPasteDialogOpen: boolean;
setIsPasteDialogOpen: (open: boolean) => void;
pasteJsonContent: string;
setPasteJsonContent: (content: string) => void;
handlePasteJson: () => void;
}
export const TemplateDialogs: React.FC<TemplateDialogsProps> = ({
isSaveDialogOpen,
setIsSaveDialogOpen,
newTemplateName,
setNewTemplateName,
handleSaveTemplate,
isPasteDialogOpen,
setIsPasteDialogOpen,
pasteJsonContent,
setPasteJsonContent,
handlePasteJson
}) => {
return (
<>
<Dialog open={isSaveDialogOpen} onOpenChange={setIsSaveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Layout Template</DialogTitle>
<DialogDescription>
Save the current layout configuration to your local browser storage.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="template-name" className="mb-2 block">Template Name</Label>
<Input
id="template-name"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder="e.g., My Dashboard Layout"
onKeyDown={(e) => e.key === 'Enter' && handleSaveTemplate()}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsSaveDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveTemplate}>Save Template</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isPasteDialogOpen} onOpenChange={setIsPasteDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Import Layout JSON</DialogTitle>
<DialogDescription>
Paste a raw JSON layout string below to import it into the playground.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="json-content" className="mb-2 block">JSON Content</Label>
<Textarea
id="json-content"
value={pasteJsonContent}
onChange={(e) => setPasteJsonContent(e.target.value)}
placeholder='{"containers": [...]}'
className="font-mono text-xs min-h-[300px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPasteDialogOpen(false)}>Cancel</Button>
<Button onClick={handlePasteJson}>Import Layout</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -0,0 +1,48 @@
import React from 'react';
import { TableOfContents } from './TableOfContents';
import { MarkdownHeading } from '@/lib/toc';
import { Button } from '@/components/ui/button';
import { List } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { T } from '@/i18n';
interface MobileTOCProps {
headings: MarkdownHeading[];
}
export function MobileTOC({ headings }: MobileTOCProps) {
if (!headings || headings.length === 0) return null;
return (
<div className="lg:hidden mb-6">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
<T>Table of Contents</T>
</span>
<span className="text-xs text-muted-foreground">
{headings.length} <T>items</T>
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<div className="max-h-[50vh] overflow-y-auto p-4">
<div className="mt-0 pt-0 border-t-0">
<TableOfContents
headings={headings}
minHeadingLevel={1}
title="Contents"
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function Sidebar({ children, className, ...props }: SidebarProps) {
return (
<aside
className={cn(
"hidden lg:block w-64 shrink-0 sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4",
className
)}
{...props}
>
{children}
</aside>
);
}

View File

@ -0,0 +1,108 @@
import React, { useMemo } from 'react';
import { MarkdownHeading, generateToC } from '@/lib/toc';
import { TableOfContentsList } from './TableOfContentsList';
import { T, translate } from '@/i18n';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
interface TableOfContentsProps {
headings: MarkdownHeading[];
title?: string;
minHeadingLevel?: number;
maxHeadingLevel?: number;
}
export function TableOfContents({
headings,
title = "On this page",
minHeadingLevel = 2,
maxHeadingLevel = 4,
className
}: TableOfContentsProps & { className?: string }) {
const [searchQuery, setSearchQuery] = React.useState('');
const [activeId, setActiveId] = React.useState<string>('');
// Generate full TOC
const fullToc = useMemo(() => {
return generateToC(headings, {
minHeadingLevel,
maxHeadingLevel,
title
});
}, [headings, minHeadingLevel, maxHeadingLevel, title]);
// Filter TOC based on search
const filteredToc = useMemo(() => {
if (!searchQuery.trim()) return fullToc;
const query = searchQuery.toLowerCase();
const filterTree = (items: typeof fullToc): typeof fullToc => {
return items.map(item => {
const updatedChildren = filterTree(item.children);
const matches = item.text.toLowerCase().includes(query) || updatedChildren.length > 0;
if (matches) {
return { ...item, children: updatedChildren };
}
return null;
}).filter(Boolean) as typeof fullToc;
};
return filterTree(fullToc);
}, [fullToc, searchQuery]);
// Scroll Spy
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: '-20% 0px -35% 0px' }
);
headings.forEach(({ slug }) => {
const element = document.getElementById(slug);
if (element) observer.observe(element);
});
// Also observe 'top' if it exists (usually mapped to title in generating TOC)
const top = document.getElementById('_top');
if (top) observer.observe(top);
return () => observer.disconnect();
}, [headings]);
if (!fullToc || fullToc.length === 0) return null;
return (
<div className={className}>
<div className="relative mb-4 px-2">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={translate("Search page or heading...")}
className="h-8 pl-8 text-xs bg-muted/50 border-input/60 focus-visible:ring-1"
autoFocus={false}
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
<nav className="text-sm">
<TableOfContentsList
toc={filteredToc}
activeId={activeId}
defaultOpen={!!searchQuery} // Expand all when searching
/>
</nav>
</div>
);
}

View File

@ -0,0 +1,145 @@
import React, { useState, useEffect } from 'react';
import { TocItem } from '@/lib/toc';
import { cn } from '@/lib/utils';
import { ChevronRight, ChevronDown, Circle } from 'lucide-react';
interface TableOfContentsListProps {
toc: TocItem[];
depth?: number;
isMobile?: boolean;
activeId?: string;
defaultOpen?: boolean;
}
export function TableOfContentsList({
toc,
depth = 0,
isMobile = false,
activeId,
defaultOpen = false
}: TableOfContentsListProps) {
if (!toc || toc.length === 0) return null;
return (
<ul className={cn(
"m-0 p-0 list-none",
isMobile ? "flex flex-col gap-1" : "",
depth > 0 && !isMobile ? "border-l border-border ml-[11px] pl-2" : "" // Vertical guide line
)}>
{toc.map((heading, index) => (
<TocItemRenderer
key={`${heading.slug}-${index}`}
heading={heading}
depth={depth}
isMobile={isMobile}
activeId={activeId}
defaultOpen={defaultOpen}
/>
))}
</ul>
);
}
function TocItemRenderer({
heading,
depth,
isMobile,
activeId,
defaultOpen
}: {
heading: TocItem,
depth: number,
isMobile: boolean,
activeId?: string,
defaultOpen: boolean
}) {
const [isOpen, setIsOpen] = useState(defaultOpen || depth < 1); // Expand root items by default
const hasChildren = heading.children.length > 0;
// Check if this item matches active ID directly
const isActive = activeId === heading.slug;
const itemRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (isActive && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isActive]);
// Check if any child is active (to auto-expand)
const isChildActive = React.useMemo(() => {
const checkActive = (items: TocItem[]): boolean => {
return items.some(item => item.slug === activeId || checkActive(item.children));
};
return checkActive(heading.children);
}, [heading.children, activeId]);
useEffect(() => {
if (isChildActive || defaultOpen) {
setIsOpen(true);
}
}, [isChildActive, defaultOpen]);
const handleToggle = (e: React.MouseEvent) => {
if (hasChildren) {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}
};
return (
<li className="m-0 p-0">
<div
ref={itemRef}
className={cn(
"group flex items-start py-1 px-2 text-sm transition-colors rounded-sm hover:bg-muted/50 my-0.5",
isActive ? "bg-muted/30 text-primary font-medium" : "text-muted-foreground"
)}
>
{/* Active Indicator Line (Left Overlay) */}
{isActive && (
<div className="absolute left-[11px] w-[2px] h-6 bg-primary -ml-[1px] rounded-full" style={{ left: '0px', position: 'absolute' }} />
)}
{isActive && !isMobile && (
<div className="absolute left-0 w-[2px] h-[28px] bg-primary rounded-r-md -ml-[1px] mt-[-2px]" />
)}
{/* Indent / Icon */}
<button
onClick={handleToggle}
className={cn(
"mt-[2px] mr-1.5 shrink-0 p-0.5 rounded-sm hover:bg-muted-foreground/10 transition-colors",
!hasChildren && "invisible pointer-events-none"
)}
aria-label={isOpen ? "Collapse" : "Expand"}
>
{hasChildren ? (
isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />
) : (
<div className="w-3.5 h-3.5" />
)}
</button>
<a
href={`#${heading.slug}`}
className="flex-1 min-w-0 break-words font-sans leading-snug pt-0.5"
onClick={(e) => isMobile && e.stopPropagation()}
>
{heading.text}
</a>
</div>
{hasChildren && isOpen && (
<TableOfContentsList
toc={heading.children}
depth={depth + 1}
isMobile={isMobile}
activeId={activeId}
defaultOpen={defaultOpen}
/>
)}
</li>
);
}

View File

@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AVAILABLE_VIDEO_MODELS, VideoGenerationOptions } from "@/lib/video-router";
import { Switch } from "@/components/ui/switch";
import { VideoModelInfo, AVAILABLE_VIDEO_MODELS, VideoGenerationOptions } from "@/lib/video-router";
interface VideoSettingsControlsProps {
modelName: string;

View File

@ -0,0 +1,267 @@
import React, { useState, useEffect, useRef } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { template } from '@/lib/variables';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { marked } from 'marked';
interface HtmlWidgetProps {
src?: string;
html?: string;
className?: string;
style?: React.CSSProperties;
isEditMode?: boolean;
onPropsChange?: (props: Record<string, any>) => void;
[key: string]: any; // Allow dynamic props for substitution
}
export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
src,
html: initialHtml,
className = '',
style,
isEditMode,
onPropsChange,
...restProps
}) => {
const [content, setContent] = useState<string | null>(initialHtml || null);
const [processedContent, setProcessedContent] = useState<string | null>(initialHtml || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Image Picker State
const [showImagePicker, setShowImagePicker] = useState(false);
const [activeImageProp, setActiveImageProp] = useState<string | null>(null);
const [currentImageValue, setCurrentImageValue] = useState<string | null>(null);
useEffect(() => {
if (initialHtml) {
setContent(initialHtml);
return;
}
if (src) {
setLoading(true);
setError(null);
fetch(src)
.then(async (res) => {
if (!res.ok) throw new Error(`Failed to load widget: ${res.statusText}`);
const text = await res.text();
setContent(text);
})
.catch((err) => {
console.error("Error loading HTML widget:", err);
setError("Failed to load content");
})
.finally(() => {
setLoading(false);
});
}
}, [src, initialHtml]);
// Apply substitutions whenever content or props change
useEffect(() => {
const processContent = async () => {
if (!content) {
setProcessedContent(null);
return;
}
try {
// Pre-process props for markdown
const finalProps = { ...restProps };
const markdownKeys = new Set<string>();
if (finalProps.widgetDefId) {
const def = widgetRegistry.get(finalProps.widgetDefId);
if (def?.metadata?.configSchema) {
for (const [key, schema] of Object.entries(def.metadata.configSchema)) {
if ((schema as any).type === 'markdown' && typeof finalProps[key] === 'string') {
try {
finalProps[key] = await marked.parse(finalProps[key]);
markdownKeys.add(key);
} catch (e) {
console.warn('Markdown parse error', e);
}
}
}
}
}
if (isEditMode) {
// Design Mode Template Logic
// 1. Wrap text placeholders in contenteditable spans: ${content} -> <span data-prop="content" ...>${content}</span>
// 2. Mark image placeholders: src="${img}" -> src="${img}" data-img-prop="img"
// Helper to inject data attributes for images
let designHtml = content.replace(/src="\$\{([^}]+)\}"/g, (match, propName) => {
// Keep the src substitution for rendering, but add a data marker
return `src="\${${propName}}" data-widget-image-prop="${propName}" style="cursor: pointer; outline: 2px solid transparent;" onmouseover="this.style.outline='2px dashed #3b82f6'" onmouseout="this.style.outline='none'"`;
});
// Process text substitutions with wrappers
const varsWithWrappers: Record<string, any> = {};
Object.keys(finalProps).forEach(key => {
const value = finalProps[key];
const k = key.toLowerCase();
// Exclude properties that are likely used in attributes (style, class, src, etc.)
// Also exclude specific known style props from library.json
const isAttributeProp =
k.includes('class') ||
k.includes('style') ||
k.includes('src') ||
k.includes('href') ||
k.includes('target') ||
k.includes('rel') ||
k.includes('id') ||
k.includes('type') ||
k.includes('value') ||
k.includes('placeholder') ||
k.includes('width') ||
k.includes('height') ||
k.includes('size') || // covers fontSize, backgroundSize
k.includes('color') || // covers color, backgroundColor
k.includes('align') || // covers textAlign, verticalAlign
k.includes('family') || // covers fontFamily
k.includes('weight') || // covers fontWeight
k.includes('height') || // covers lineHeight
k.includes('decoration') || // textDecoration
k.includes('padding') ||
k.includes('margin') ||
k.includes('border') ||
k.includes('radius') ||
k.includes('display') ||
k.includes('position') ||
k.includes('top') ||
k.includes('left') ||
k.includes('right') ||
k.includes('bottom') ||
k.includes('z-index') ||
k.includes('overflow') ||
k.includes('float') ||
k.includes('clear') ||
k.includes('image') || // handled by data-img-prop
k.includes('url');
const isMarkdown = markdownKeys.has(key);
// Allow explicit content keys or keys that don't match the exclusion list
// If simple string and not an attribute prop AND not markdown, wrap it
if (!isAttributeProp && typeof value === 'string' && !isMarkdown) {
varsWithWrappers[key] = `<span data-widget-prop="${key}" contenteditable="true" style="outline: none; transition: background 0.2s; min-width: 1em; display: inline-block;" onfocus="this.style.backgroundColor='rgba(59, 130, 246, 0.1)'" onblur="this.style.backgroundColor='transparent'">${value}</span>`;
} else {
varsWithWrappers[key] = value;
}
});
// Use template to substitute with our wrapped values
const newContent = template(designHtml, varsWithWrappers, false);
setProcessedContent(newContent);
} else {
// Standard Rendering
const newContent = template(content, finalProps, false);
setProcessedContent(newContent);
}
} catch (e) {
console.error("Template substitution failed", e);
setProcessedContent(content);
}
};
processContent();
}, [content, restProps, isEditMode]);
// Event Delegation for Inline Editing
useEffect(() => {
if (!isEditMode || !containerRef.current) return;
const container = containerRef.current;
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
const propName = target.getAttribute('data-widget-prop');
if (propName && onPropsChange) {
const newValue = target.innerText;
// Only update if changed
if (restProps[propName] !== newValue) {
onPropsChange({ [propName]: newValue });
}
}
};
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Handle Image Clicks
const imgProp = target.getAttribute('data-widget-image-prop');
if (imgProp) {
e.preventDefault();
e.stopPropagation();
setActiveImageProp(imgProp);
setCurrentImageValue(target.getAttribute('src'));
setShowImagePicker(true);
}
};
// Use capture phase for some events if needed, but bubbling is usually fine
container.addEventListener('focusout', handleFocusOut);
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('focusout', handleFocusOut);
container.removeEventListener('click', handleClick);
};
}, [isEditMode, onPropsChange, restProps]);
const handleImageSelect = (pictureId: string) => {
// This won't work directly if we need the URL, handled by onSelectPicture
};
const handlePictureSelect = (picture: any) => {
if (activeImageProp && onPropsChange) {
onPropsChange({ [activeImageProp]: picture.image_url });
}
setShowImagePicker(false);
setActiveImageProp(null);
};
if (loading) {
return <Skeleton className="w-full h-full min-h-[50px] rounded bg-slate-100 dark:bg-slate-800" />;
}
if (error) {
return <div className="p-2 text-xs text-red-500 border border-red-200 rounded bg-red-50">{error}</div>;
}
if (!processedContent) {
return <div className="p-2 text-xs text-slate-400 border border-dashed border-slate-300 rounded">No content</div>;
}
return (
<>
<div
ref={containerRef}
className={`html-widget-container ${className}`}
style={style}
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
{showImagePicker && (
<ImagePickerDialog
isOpen={showImagePicker}
onClose={() => setShowImagePicker(false)}
currentValue={activeImageProp ? restProps[activeImageProp] : undefined}
onSelectPicture={handlePictureSelect}
/>
)}
</>
);
};

View File

@ -14,6 +14,7 @@ interface ImagePickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect?: (pictureId: string) => void;
onSelectPicture?: (picture: Picture) => void;
onMultiSelect?: (pictureIds: string[]) => void;
onMultiSelectPictures?: (pictures: Picture[]) => void;
currentValue?: string | null;
@ -45,7 +46,8 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
onMultiSelectPictures,
currentValue,
currentValues = [],
multiple = false
multiple = false,
onSelectPicture
}) => {
const { user } = useAuth();
const [pictures, setPictures] = useState<Picture[]>([]);
@ -193,8 +195,12 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
onMultiSelectPictures(selectedPictures);
}
} else {
if (selectedId && onSelect) {
onSelect(selectedId);
if (selectedId) {
if (onSelect) onSelect(selectedId);
if (onSelectPicture) {
const pic = pictures.find(p => p.id === selectedId);
if (pic) onSelectPicture(pic);
}
}
}
};
@ -345,6 +351,7 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
if (!multiple) {
setSelectedId(picture.id);
if (onSelect) onSelect(picture.id);
if (onSelectPicture) onSelectPicture(picture);
} else {
toggleSelection(picture.id);
}

View File

@ -5,12 +5,10 @@ import {
Wand2,
Type,
Filter,
Maximize,
Minimize,
PanelLeftClose,
PanelLeftOpen,
LayoutTemplate
PanelLeftOpen
} from 'lucide-react';
import {
ResizableHandle,

View File

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { T } from '@/i18n';
import { usePageGenerator } from '@/hooks/usePageGenerator';
import { usePromptHistory } from '@/hooks/usePromptHistory';
import { AIPageGenerator } from '@/components/AIPageGenerator';
import VoiceRecordingPopup from '@/components/VoiceRecordingPopup';
interface PageCreationWizardProps {
isOpen: boolean;
onClose: () => void;
parentId?: string | null;
}
export const PageCreationWizard: React.FC<PageCreationWizardProps> = ({
isOpen,
onClose,
parentId
}) => {
const { generatePageFromText, isGenerating, status, cancelGeneration } = usePageGenerator();
const [showVoicePopup, setShowVoicePopup] = useState(false);
const {
prompt: textPrompt,
setPrompt: setTextPrompt,
promptHistory,
historyIndex,
navigateHistory,
addPromptToHistory,
setHistoryIndex
} = usePromptHistory();
const handleGeneratePageFromText = async (options: { useImageTools: boolean; model?: string; imageModel?: string; referenceImages?: string[] }) => {
if (!textPrompt.trim()) return;
addPromptToHistory(textPrompt);
setHistoryIndex(-1);
const result = await generatePageFromText(textPrompt, {
...options,
parentId: parentId || undefined
});
// Only close on success
if (result) {
onClose();
}
};
const handleGeneratePageFromVoice = async (transcribedText: string) => {
setShowVoicePopup(false);
// For voice, we assume the user wants the full experience, including images
const result = await generatePageFromText(transcribedText, {
useImageTools: true,
parentId: parentId || undefined
});
if (result) onClose();
};
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Create New Page</T></DialogTitle>
<DialogDescription>
<T>Describe the page you want the AI to create. It will be created as a child of the current page.</T>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<AIPageGenerator
prompt={textPrompt}
onPromptChange={setTextPrompt}
onGenerate={handleGeneratePageFromText}
isGenerating={isGenerating}
generationStatus={status}
onCancel={cancelGeneration}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
/>
</div>
</DialogContent>
</Dialog>
{showVoicePopup && (
<VoiceRecordingPopup
isOpen={showVoicePopup}
onClose={() => setShowVoicePopup(false)}
onTranscriptionComplete={handleGeneratePageFromVoice}
/>
)}
</>
);
};

View File

@ -0,0 +1,182 @@
import React, { useState, useEffect, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { T, translate } from '@/i18n';
import { Search, FileText, Check } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { cn } from '@/lib/utils';
interface PagePickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (pageId: string | null) => void;
currentValue?: string | null;
forbiddenIds?: string[]; // IDs that cannot be selected (e.g. self)
}
interface Page {
id: string;
title: string;
slug: string;
is_public: boolean;
visible: boolean;
updated_at: string;
}
export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
isOpen,
onClose,
onSelect,
currentValue,
forbiddenIds = []
}) => {
const { user } = useAuth();
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(currentValue || null);
// Initial data fetch
useEffect(() => {
if (isOpen) {
fetchPages();
// Sync local state with prop
setSelectedId(currentValue || null);
}
}, [isOpen, currentValue]);
const fetchPages = async () => {
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('pages')
.select('id, title, slug, is_public, visible, updated_at')
.eq('owner', user.id)
.order('title', { ascending: true });
if (error) throw error;
setPages(data || []);
} catch (error) {
console.error('Error fetching pages:', error);
} finally {
setLoading(false);
}
};
const filteredPages = useMemo(() => {
return pages
.filter(page => !forbiddenIds.includes(page.id))
.filter(page => {
const matchesSearch = page.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
page.slug.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [pages, searchQuery, forbiddenIds]);
const handleConfirm = () => {
onSelect(selectedId);
onClose();
};
const handleClear = () => {
setSelectedId(null);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle><T>Select Parent Page</T></DialogTitle>
<DialogDescription>
<T>Choose a parent page to organize your content hierarchy. Select 'None' to make this a top-level page.</T>
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative my-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={translate('Search pages...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Pages Grid */}
<div className="flex-1 overflow-y-auto min-h-0 border rounded-md bg-muted/10 p-2">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredPages.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
<T>{searchQuery ? 'No pages found' : 'No other pages available'}</T>
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{/* Option for No Parent */}
<div
onClick={handleClear}
className={cn(
"cursor-pointer rounded-lg border-2 p-3 flex items-center gap-3 transition-all hover:border-primary/50 bg-background",
selectedId === null ? "border-primary" : "border-transparent"
)}
>
<div className="h-10 w-10 rounded-md bg-muted flex items-center justify-center">
<span className="text-xs text-muted-foreground">/</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate"><T>No Parent (Root)</T></p>
<p className="text-xs text-muted-foreground truncate"><T>Set as top-level page</T></p>
</div>
{selectedId === null && <Check className="h-4 w-4 text-primary" />}
</div>
{filteredPages.map((page) => (
<div
key={page.id}
onClick={() => setSelectedId(page.id)}
onDoubleClick={() => {
setSelectedId(page.id);
onSelect(page.id);
onClose();
}}
className={cn(
"cursor-pointer rounded-lg border-2 p-3 flex items-center gap-3 transition-all hover:border-primary/50 bg-background",
selectedId === page.id ? "border-primary" : "border-transparent"
)}
>
<div className="h-10 w-10 rounded-md bg-primary/10 flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{page.title}</p>
<p className="text-xs text-muted-foreground truncate">/{page.slug}</p>
</div>
{selectedId === page.id && <Check className="h-4 w-4 text-primary" />}
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end items-center pt-4 gap-2">
<Button variant="outline" onClick={onClose}>
<T>Cancel</T>
</Button>
<Button onClick={handleConfirm}>
<T>Confirm Selection</T>
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,336 @@
import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { WidgetDefinition } from '@/lib/widgetRegistry';
import { ImagePickerDialog } from './ImagePickerDialog';
import { Image as ImageIcon, Maximize2 } from 'lucide-react';
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import MarkdownEditor from '@/components/MarkdownEditorEx';
export interface WidgetPropertiesFormProps {
widgetDefinition: WidgetDefinition;
widgetInstanceId?: string;
onRename?: (newId: string) => void;
currentProps: Record<string, any>;
onSettingsChange: (settings: Record<string, any>) => void;
onSave?: () => void;
onCancel?: () => void;
showActions?: boolean;
}
export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
widgetDefinition,
widgetInstanceId,
onRename,
currentProps,
onSettingsChange,
onSave,
onCancel,
showActions = false
}) => {
// Local state for immediate feedback in the form, though we also prop up changes
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerField, setImagePickerField] = useState<string | null>(null);
const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
// Sync with prop changes (e.g. selection change)
useEffect(() => {
setSettings(currentProps);
}, [currentProps]);
const updateSetting = (key: string, value: any) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
onSettingsChange(newSettings);
};
const renderField = (key: string, config: any) => {
const value = settings[key] ?? config.default;
switch (config.type) {
case 'number':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<Input
id={key}
type="number"
min={config.min}
max={config.max}
value={value}
onChange={(e) => updateSetting(key, parseInt(e.target.value) || config.default)}
className="w-full h-8 text-sm"
/>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'text':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<Input
id={key}
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
className="w-full h-8 text-sm"
placeholder={config.default}
/>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'select':
return (
<div key={key} className="space-y-2">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<Select value={value} onValueChange={(newValue) => updateSetting(key, newValue)}>
<SelectTrigger className="w-full h-8 text-sm">
<SelectValue placeholder={`Select ${config.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{config.options.map((option: any) => (
<SelectItem key={option.value} value={option.value} className="text-sm">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'boolean':
return (
<div key={key} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
<Switch
id={key}
checked={value ?? config.default}
onCheckedChange={(checked) => updateSetting(key, checked)}
className="scale-90"
/>
</div>
</div>
);
case 'imagePicker':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<div className="flex gap-2">
<Input
id={key}
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
placeholder={config.default || 'No image selected'}
className="flex-1 font-mono text-[10px] h-8"
readOnly
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => {
setImagePickerField(key);
setImagePickerOpen(true);
}}
>
<ImageIcon className="h-3 w-3 mr-1" />
<span className="text-xs"><T>Browse</T></span>
</Button>
</div>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'markdown':
return (
<div key={key} className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setActiveMarkdownField(key);
setMarkdownEditorOpen(true);
}}
>
<Maximize2 className="h-3.5 w-3.5 mr-1" />
<T>Fullscreen</T>
</Button>
</div>
<Textarea
id={key}
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
className="w-full min-h-[80px] text-sm font-mono resize-y"
placeholder={config.default}
/>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
default:
return null;
}
};
const configSchema = widgetDefinition.metadata.configSchema || {};
return (
<div className="space-y-4">
{/* Widget ID Helper (Editable) */}
{widgetInstanceId && onRename && (
<div className="space-y-1 pb-4 border-b border-border">
<Label htmlFor="widget-id-editor" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Widget ID (Variable Name)
</Label>
<div className="flex gap-2">
<Input
key={widgetInstanceId} // Force remount when widget changes to update defaultValue
id="widget-id-editor"
defaultValue={widgetInstanceId}
className="h-8 font-mono text-xs bg-muted/50"
onBlur={(e) => {
const val = e.target.value.trim();
if (val && val !== widgetInstanceId) {
onRename(val);
} else {
e.target.value = widgetInstanceId; // Reset if empty or same
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<p className="text-[10px] text-muted-foreground">
Unique identifier used for templating key reference.
</p>
</div>
)}
<div className="space-y-4">
{Object.entries(configSchema).map(([key, config]) =>
renderField(key, config)
)}
</div>
{showActions && (
<div className="flex justify-end gap-2 pt-4 border-t border-slate-200 dark:border-slate-800">
{onCancel && (
<Button variant="outline" size="sm" onClick={onCancel}>
<T>Cancel</T>
</Button>
)}
{onSave && (
<Button size="sm" onClick={onSave}>
<T>Save</T>
</Button>
)}
</div>
)}
{/* Image Picker Dialog */}
{imagePickerField && (
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
setImagePickerField(null);
}}
onSelectPicture={(picture) => {
updateSetting(imagePickerField, picture.image_url);
setImagePickerOpen(false);
setImagePickerField(null);
}}
currentValue={settings[imagePickerField]}
/>
)}
{/* Markdown Editor Modal */}
<Dialog open={markdownEditorOpen} onOpenChange={setMarkdownEditorOpen}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-4 border-b">
<DialogTitle>
{activeMarkdownField ?
(widgetDefinition.metadata.configSchema?.[activeMarkdownField]?.label || 'Markdown Editor')
: 'Markdown Editor'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden p-4 bg-muted/10">
{activeMarkdownField && (
<MarkdownEditor
value={settings[activeMarkdownField] || ''}
onChange={(newValue) => updateSetting(activeMarkdownField, newValue)}
className="h-full border rounded-md bg-background"
placeholder="Enter markdown content..."
/>
)}
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button onClick={() => setMarkdownEditorOpen(false)}>
<T>Done</T>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,107 @@
import React from 'react';
import { T } from '@/i18n';
import { toast } from 'sonner';
import { WidgetInstance } from '@/lib/unifiedLayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
import { useLayout } from '@/contexts/LayoutContext';
interface WidgetPropertyPanelProps {
pageId: string;
selectedWidgetId: string | null;
onWidgetRenamed?: (newId: string) => void;
className?: string;
}
export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
pageId,
selectedWidgetId,
onWidgetRenamed,
className = ''
}) => {
const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
const page = loadedPages.get(pageId);
// Find the selected widget instance
const findWidget = (): WidgetInstance | null => {
if (!page || !selectedWidgetId) return null;
for (const container of page.containers) {
const widget = container.widgets.find(w => w.id === selectedWidgetId);
if (widget) return widget;
// TODO: Recursive search if needed, currently assumes flat-ish structure or top-level containers
}
return null;
};
const widget = findWidget();
const widgetDefinition = widget ? widgetRegistry.get(widget.widgetId) : null;
if (!widget || !widgetDefinition) {
return (
<div className={`p-4 text-center text-slate-500 text-sm ${className}`}>
{selectedWidgetId ? <T>Widget not found</T> : <T>Select a widget to edit properties</T>}
</div>
);
}
const handleSettingsChange = (newSettings: Record<string, any>) => {
// Live update
updateWidgetProps(pageId, widget.id, newSettings).catch(console.error);
};
const handleRename = async (newId: string) => {
if (!widget) return;
try {
const success = await renameWidget(pageId, widget.id, newId);
if (success) {
toast.success('Widget renamed successfully');
if (onWidgetRenamed) {
onWidgetRenamed(newId);
}
} else {
toast.error('Failed to rename widget. ID might already exist.');
}
} catch (error) {
console.error('Failed to rename widget:', error);
toast.error('An error occurred while renaming widget');
}
};
return (
<div className={`flex flex-col h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 ${className}`}>
<div className="p-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="font-semibold text-sm truncate" title={widgetDefinition.metadata.name}>
{widgetDefinition.metadata.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<T>Properties</T>
</p>
</div>
<div className="flex-1 overflow-y-auto p-4">
{widgetDefinition.metadata.configSchema ? (
<WidgetPropertiesForm
widgetDefinition={widgetDefinition}
currentProps={widget.props || {}}
onSettingsChange={handleSettingsChange}
widgetInstanceId={widget.id}
onRename={(newId) => {
// We need to propagate this up because the selection state depends on the ID
// If `renameWidget` succeeds, we should probably tell the parent to select the new ID.
// Since I can't easily change the parent's selection state from here without a prop,
// I'll add an `onWidgetRenamed` prop to `WidgetPropertyPanel`.
// Wait, I can't add a prop without updating usage in PlaygroundCanvas.
// Let's implement the internal logic first.
handleRename(newId);
}}
/>
) : (
<div className="text-center text-slate-500 text-xs py-8">
<T>No configurable properties</T>
</div>
)}
</div>
</div>
);
};

View File

@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { WidgetDefinition } from '@/lib/widgetRegistry';
import { ImagePickerDialog } from './ImagePickerDialog';
import { Image as ImageIcon } from 'lucide-react';
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
interface WidgetSettingsManagerProps {
isOpen: boolean;
@ -16,6 +17,8 @@ interface WidgetSettingsManagerProps {
onSave: (settings: Record<string, any>) => void;
widgetDefinition: WidgetDefinition;
currentProps: Record<string, any>;
widgetInstanceId?: string;
onRename?: (newId: string) => void;
}
const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
@ -23,11 +26,11 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
onClose,
onSave,
widgetDefinition,
currentProps
currentProps,
widgetInstanceId,
onRename
}) => {
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerField, setImagePickerField] = useState<string | null>(null);
// Reset settings when modal opens
useEffect(() => {
@ -48,155 +51,7 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
onClose();
};
const updateSetting = (key: string, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const renderField = (key: string, config: any) => {
const value = settings[key] ?? config.default;
switch (config.type) {
case 'number':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>
<T>{config.label}</T>
</Label>
<Input
id={key}
type="number"
min={config.min}
max={config.max}
value={value}
onChange={(e) => updateSetting(key, parseInt(e.target.value) || config.default)}
className="w-full"
/>
{config.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'text':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>
<T>{config.label}</T>
</Label>
<Input
id={key}
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
className="w-full"
placeholder={config.default}
/>
{config.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'select':
return (
<div key={key} className="space-y-2">
<Label>
<T>{config.label}</T>
</Label>
<Select value={value} onValueChange={(newValue) => updateSetting(key, newValue)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${config.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{config.options.map((option: any) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{config.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'boolean':
return (
<div key={key} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor={key}>
<T>{config.label}</T>
</Label>
{config.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>{config.description}</T>
</p>
)}
</div>
<Switch
id={key}
checked={value ?? config.default}
onCheckedChange={(checked) => updateSetting(key, checked)}
/>
</div>
</div>
);
case 'imagePicker':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>
<T>{config.label}</T>
</Label>
<div className="flex gap-2">
<Input
id={key}
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
placeholder={config.default || 'No image selected'}
className="flex-1 font-mono text-xs"
readOnly
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setImagePickerField(key);
setImagePickerOpen(true);
}}
>
<ImageIcon className="h-4 w-4 mr-2" />
<T>Browse</T>
</Button>
</div>
{config.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>{config.description}</T>
</p>
)}
</div>
);
default:
return null;
}
};
const configSchema = widgetDefinition.metadata.configSchema || {};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-w-[90vw]">
<DialogHeader>
@ -205,12 +60,13 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{Object.entries(configSchema).map(([key, config]) =>
renderField(key, config)
)}
</div>
<WidgetPropertiesForm
widgetDefinition={widgetDefinition}
currentProps={settings}
onSettingsChange={setSettings}
widgetInstanceId={widgetInstanceId}
onRename={onRename}
/>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
@ -222,24 +78,6 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Image Picker Dialog */}
{imagePickerField && (
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
setImagePickerField(null);
}}
onSelect={(pictureId) => {
updateSetting(imagePickerField, pictureId);
setImagePickerOpen(false);
setImagePickerField(null);
}}
currentValue={settings[imagePickerField]}
/>
)}
</>
);
};

View File

@ -18,6 +18,7 @@ interface LayoutContextType {
removePageContainer: (pageId: string, containerId: string) => Promise<void>;
movePageContainer: (pageId: string, containerId: string, direction: 'up' | 'down') => Promise<void>;
updateWidgetProps: (pageId: string, widgetInstanceId: string, props: Record<string, any>) => Promise<void>;
renameWidget: (pageId: string, widgetInstanceId: string, newId: string) => Promise<boolean>;
exportPageLayout: (pageId: string) => Promise<string>;
importPageLayout: (pageId: string, jsonData: string) => Promise<PageLayout>;
@ -326,8 +327,36 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
}
};
const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise<boolean> => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
}
const success = UnifiedLayoutManager.renameWidget(currentLayout, widgetInstanceId, newId);
if (!success) {
return false;
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
return true;
} catch (error) {
console.error(`Failed to rename widget in page ${pageId}:`, error);
throw error;
}
};
const exportPageLayout = async (pageId: string): Promise<string> => {
try {
// Export directly from memory state to ensure consistency with UI
const currentLayout = loadedPages.get(pageId);
if (currentLayout) {
return JSON.stringify(currentLayout, null, 2);
}
// Fallback to ULM if not loaded (though it should be)
return await UnifiedLayoutManager.exportPageLayout(pageId);
} catch (error) {
console.error(`Failed to export page layout ${pageId}:`, error);
@ -391,6 +420,7 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
saveToApi,
isLoading,
loadedPages,
renameWidget,
};
return (

View File

@ -0,0 +1,179 @@
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import modbusService from '@/services/modbusService';
import logger from '@/Logger';
export type WsStatus = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'ERROR' | 'RECONNECTING';
interface WebSocketContextType {
isConnected: boolean;
isConnecting: boolean;
wsStatus: WsStatus;
apiUrl: string;
setApiUrl: (url: string) => void;
connectToServer: (url?: string) => Promise<boolean>;
disconnectFromServer: () => void;
abortConnectionAttempt: () => void;
}
const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined);
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (context === undefined) {
throw new Error('useWebSocket must be used within a WebSocketProvider');
}
return context;
};
interface WebSocketProviderProps {
children: ReactNode;
url?: string;
}
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children, url: initialUrl = '' }) => {
const [apiUrl, setApiUrl] = useState<string>(initialUrl);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [isConnecting, setConnecting] = useState<boolean>(false);
const [wsStatus, setWsStatus] = useState<WsStatus>('DISCONNECTED');
const [connectionAborted, setConnectionAborted] = useState<boolean>(false);
// Use a ref or simply the variable to track initialization to prevent double connect in strict mode if needed,
// but simpler logic is often better.
const handleWsStatusChange = useCallback((status: WsStatus) => {
logger.info(`Context: WS Status Changed -> ${status}`);
setWsStatus(status);
setIsConnected(status === 'CONNECTED');
if (status === 'CONNECTED') {
logger.success('WebSocket Connected');
setConnecting(false);
} else {
if (status === 'ERROR') {
logger.error('WebSocket Connection Error');
setConnecting(false);
} else if (status === 'RECONNECTING') {
logger.warn('WebSocket Reconnecting...');
setConnecting(true);
} else if (status === 'DISCONNECTED') {
setConnecting(false);
}
}
}, []);
const disconnectWebSocket = useCallback((intentional: boolean = true) => {
modbusService.disconnect(intentional);
}, []);
const connectWebSocket = useCallback(async (urlToConnect: string): Promise<boolean> => {
if (!urlToConnect) {
logger.error('API URL not set');
return false;
}
let wsUrl: string;
try {
const url = new URL(urlToConnect);
// If the providing URL is http/https, switch to ws/wss. If it's already ws/wss, use as is.
// The previous logic assumed http input. Let's be robust.
if (url.protocol === 'http:') {
url.protocol = 'ws:';
} else if (url.protocol === 'https:') {
url.protocol = 'wss:';
}
// Ensure /ws path
if (!url.pathname.endsWith('/ws')) {
url.pathname = url.pathname.replace(/\/+$/, '') + '/ws';
}
wsUrl = url.toString();
} catch (error) {
logger.logError(error, 'Invalid API URL');
return false;
}
try {
const newConnectionEstablished = await modbusService.connect(
wsUrl,
handleWsStatusChange,
);
return newConnectionEstablished;
} catch (error) {
logger.logError(error, '[ModbusContext] modbusService.connect() failed');
return false;
}
}, [handleWsStatusChange]);
const abortConnectionAttempt = useCallback(() => {
setConnectionAborted(true);
setConnecting(false);
disconnectWebSocket(true);
}, [disconnectWebSocket]);
const connectToServer = useCallback(async (urlToUse?: string): Promise<boolean> => {
const targetUrl = urlToUse || apiUrl;
if (urlToUse && urlToUse !== apiUrl) {
setApiUrl(targetUrl);
}
setConnectionAborted(false);
setConnecting(true);
try {
const didConnect = await connectWebSocket(targetUrl);
if (didConnect) {
} else {
if (modbusService.getConnectionStatus() === 'CONNECTED') {
} else {
}
}
const isNowConnected = modbusService.getConnectionStatus() === 'CONNECTED';
setConnecting(false);
return isNowConnected;
} catch (error: any) {
logger.logError(error, '[ModbusContext] Error in connectToServer');
setIsConnected(false);
setConnecting(false);
return false;
}
}, [apiUrl, connectWebSocket]);
const disconnectFromServer = useCallback((): void => {
setConnectionAborted(true);
setIsConnected(false);
disconnectWebSocket(true);
}, [disconnectWebSocket]);
// Initial connection effect
useEffect(() => {
if (initialUrl && !isConnected && !isConnecting && !connectionAborted) {
connectToServer(initialUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialUrl]);
// We strictly want this only on mount or if initialUrl changes significantly and we want to reconnect.
// Including dependencies like 'connectToServer' might cause loops if not careful.
return (
<WebSocketContext.Provider
value={{
isConnected,
isConnecting,
wsStatus,
apiUrl,
setApiUrl,
connectToServer,
disconnectFromServer,
abortConnectionAttempt,
}}
>
{children}
</WebSocketContext.Provider>
);
};

View File

@ -1,14 +1,14 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { FeedPost } from '@/lib/db';
import * as db from '@/lib/db';
import { FEED_PAGE_SIZE } from '@/constants';
import { FEED_API_ENDPOINT, FEED_PAGE_SIZE } from '@/constants';
import { useProfiles } from '@/contexts/ProfilesContext';
import { useFeedCache } from '@/contexts/FeedCacheContext';
export type FeedSortOption = 'latest' | 'top';
interface UseFeedDataProps {
source?: 'home' | 'collection' | 'tag' | 'user';
source?: 'home' | 'collection' | 'tag' | 'user' | 'widget';
sourceId?: string;
isOrgContext?: boolean;
orgSlug?: string;
@ -118,8 +118,7 @@ export const useFeedData = ({
console.log('Hydrated feed', fetchedPosts);
}
// 2. API Fetch (Home only - disabled to use client-side DB logic with pagination/pages support)
/*
// 2. API Fetch (Home only)
else if (source === 'home' && !sourceId) {
const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
const fetchUrl = SERVER_URL
@ -130,9 +129,9 @@ export const useFeedData = ({
if (!res.ok) throw new Error(`Feed fetch failed: ${res.statusText}`);
fetchedPosts = await res.json();
}
*/
// 3. Fallback DB Fetch
else {
console.log('Fetching feed from DB', source, sourceId, isOrgContext, orgSlug, currentPage);
fetchedPosts = await db.fetchFeedPostsPaginated(
source,
sourceId,
@ -166,7 +165,7 @@ export const useFeedData = ({
// Fetch profiles via context for any users in the feed
const userIds = Array.from(new Set(augmentedPosts.map((p: any) => p.user_id)));
if (userIds.length > 0) {
fetchProfiles(userIds as string[]);
await fetchProfiles(userIds as string[]);
}
} catch (err) {

View File

@ -23,7 +23,7 @@ export const usePageGenerator = () => {
toast.warning(translate('Page generation cancelled.'));
};
const generatePageFromText = async (prompt: string, options: { useImageTools: boolean; model?: string; imageModel?: string; referenceImages?: string[] }) => {
const generatePageFromText = async (prompt: string, options: { useImageTools: boolean; model?: string; imageModel?: string; referenceImages?: string[]; parentId?: string }) => {
if (!user) {
toast.error(translate('You must be logged in to create a page.'));
return;
@ -106,7 +106,8 @@ export const usePageGenerator = () => {
title,
content,
is_public: false,
visible: true
visible: true,
parent: options.parentId
}, addLog);
}

View File

@ -0,0 +1,345 @@
import { useState, useEffect } from 'react';
import { useLayout } from '@/contexts/LayoutContext';
import { toast } from 'sonner';
import { LayoutTemplateManager, LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates';
import { useWidgetLoader } from './useWidgetLoader.tsx';
export function usePlaygroundLogic() {
// UI State
const [viewMode, setViewMode] = useState<'design' | 'preview'>('design');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [htmlSize, setHtmlSize] = useState<number>(0);
const [isAppReady, setIsAppReady] = useState(false);
const [isEditMode, setIsEditMode] = useState(true);
// Page State
const pageId = 'playground-canvas-demo';
const pageName = 'Playground Canvas';
const [layoutJson, setLayoutJson] = useState<string | null>(null);
// Layout Context
const {
loadedPages,
exportPageLayout,
importPageLayout,
saveToApi,
loadPageLayout
} = useLayout();
// Template State
const [templates, setTemplates] = useState<ILayoutTemplate[]>([]);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [newTemplateName, setNewTemplateName] = useState('');
// Paste JSON State
const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false);
const [pasteJsonContent, setPasteJsonContent] = useState('');
const { loadWidgetBundle } = useWidgetLoader();
useEffect(() => {
refreshTemplates();
}, []);
const refreshTemplates = () => {
setTemplates(LayoutTemplateManager.getTemplates());
};
// Initialization Effect
useEffect(() => {
const init = async () => {
let layout = loadedPages.get(pageId);
if (!layout) {
console.log('[Playground] Loading layout...');
try {
await loadPageLayout(pageId, pageName);
} catch (e) {
console.error("Failed to load layout", e);
}
}
};
init();
}, [pageId, pageName, loadPageLayout, loadedPages]);
// Restoration Effect
useEffect(() => {
const restore = async () => {
const layout = loadedPages.get(pageId);
if (layout) {
if (!isAppReady) {
let detectedRootTemplate: string | undefined;
if (layout.loadedBundles && layout.loadedBundles.length > 0) {
console.log('[Playground] Restoring bundles:', layout.loadedBundles);
for (const bundleUrl of layout.loadedBundles) {
try {
const result = await loadWidgetBundle(bundleUrl);
// Capture root template from the first bundle that has one
if (result.rootTemplateUrl && !detectedRootTemplate) {
detectedRootTemplate = result.rootTemplateUrl;
}
} catch (e) {
console.error("Failed to restore bundle", bundleUrl);
}
}
}
// Self-repair: If layout is missing rootTemplate but we found one, update it.
if (detectedRootTemplate && !layout.rootTemplate) {
console.log('[Playground] Backfilling rootTemplate:', detectedRootTemplate);
layout.rootTemplate = detectedRootTemplate;
await saveToApi();
}
setIsAppReady(true);
}
}
};
// If layout exists and we haven't marked ready, try restore
// We depend on loadedPages map or specific entry
if (loadedPages.get(pageId) && !isAppReady) {
restore();
}
}, [loadedPages, pageId, isAppReady, saveToApi, loadWidgetBundle]);
// Preview Generation Effect
// Preview Generation Effect
useEffect(() => {
const updatePreview = async () => {
const layout = loadedPages.get(pageId);
if (layout && layout.rootTemplate) {
try {
const { generateEmailHtml } = await import('@/lib/emailExporter');
let html = await generateEmailHtml(layout, layout.rootTemplate);
// Inject Preview-Only CSS to fix "font-size: 0" visibility issues
// This ensures the preview looks correct without modifying the actual email export structure
const previewFixStyles = `
<style>
.text-block .long-text { font-size: 16px; }
</style>
`;
if (html.includes('</head>')) {
html = html.replace('</head>', `${previewFixStyles}</head>`);
} else {
html = html.replace('<body>', `<head>${previewFixStyles}</head><body>`);
}
setPreviewHtml(html);
// Approximate size of the email body (excluding images, as per Gmail limit on HTML only)
const size = new Blob([html]).size;
setHtmlSize(size);
} catch (e) {
console.error("Preview generation failed", e);
// toast.error("Failed to generate preview"); // Suppress toast on auto-update to avoid spam
}
} else if (!layout?.rootTemplate) {
setPreviewHtml("<html><body><p>No root template found. Please load the email context first.</p></body></html>");
}
};
updatePreview();
}, [loadedPages, pageId]); // Removed viewMode dependency
const handleDumpJson = async () => {
try {
const json = await exportPageLayout(pageId);
setLayoutJson(JSON.stringify(JSON.parse(json), null, 2));
await navigator.clipboard.writeText(json);
toast.success("JSON dumped to console, clipboard, and view");
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
}
};
const handleLoadTemplate = async (template: ILayoutTemplate) => {
try {
await importPageLayout(pageId, template.layoutJson);
toast.success(`Loaded template: ${template.name}`);
setLayoutJson(null);
} catch (e) {
console.error("Failed to load template", e);
toast.error("Failed to load template");
}
};
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
toast.error("Please enter a template name");
return;
}
try {
const json = await exportPageLayout(pageId);
LayoutTemplateManager.saveTemplate(newTemplateName.trim(), json);
toast.success("Template saved locally");
setIsSaveDialogOpen(false);
setNewTemplateName('');
refreshTemplates();
} catch (e) {
console.error("Failed to save template", e);
toast.error("Failed to save template");
}
};
const handlePasteJson = async () => {
if (!pasteJsonContent.trim()) {
toast.error("Please enter JSON content");
return;
}
try {
JSON.parse(pasteJsonContent);
await importPageLayout(pageId, pasteJsonContent);
toast.success("Layout imported from JSON");
setIsPasteDialogOpen(false);
setPasteJsonContent('');
setLayoutJson(null);
} catch (e) {
console.error("Failed to import JSON", e);
toast.error("Invalid JSON format");
}
};
const handleLoadContext = async () => {
const bundleUrl = '/widgets/email/library.json';
try {
const result = await loadWidgetBundle(bundleUrl);
const { count, rootTemplateUrl } = result;
toast.success(`Loaded ${count} email widgets`);
const currentLayout = loadedPages.get(pageId);
if (currentLayout) {
const bundles = new Set(currentLayout.loadedBundles || []);
let changed = false;
if (!bundles.has(bundleUrl)) {
bundles.add(bundleUrl);
currentLayout.loadedBundles = Array.from(bundles);
changed = true;
}
if (rootTemplateUrl && currentLayout.rootTemplate !== rootTemplateUrl) {
currentLayout.rootTemplate = rootTemplateUrl;
changed = true;
}
if (changed) {
await saveToApi();
toast.success("Context saved to layout");
}
}
} catch (e) {
console.error("Failed to load context", e);
toast.error("Failed to load email context");
}
};
const handleExportHtml = async () => {
const layout = loadedPages.get(pageId);
if (!layout) return;
if (!layout.rootTemplate) {
toast.error("No root template found. Please load a context first.");
return;
}
try {
const { generateEmailHtml } = await import('@/lib/emailExporter');
const html = await generateEmailHtml(layout, layout.rootTemplate);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${layout.name.toLowerCase().replace(/\s+/g, '-')}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Exported HTML");
} catch (e) {
console.error("Failed to export HTML", e);
toast.error("Export failed");
}
};
const handleSendTestEmail = async () => {
const layout = loadedPages.get(pageId);
if (!layout) {
toast.error("Layout not loaded");
return;
}
if (!layout.rootTemplate) {
toast.error("No root template found. Please load a context first.");
return;
}
try {
toast.info("Generating email...");
const { generateEmailHtml } = await import('@/lib/emailExporter');
let html = await generateEmailHtml(layout, layout.rootTemplate);
const dummyId = import.meta.env.DEFAULT_POST_TEST_ID || '00000000-0000-0000-0000-000000000000';
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
toast.info("Sending test email...");
const response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
html,
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
})
});
if (response.ok) {
toast.success("Test email sent!");
} else {
const err = await response.text();
console.error("Failed to send test email", err);
toast.error(`Failed to send: ${response.statusText}`);
}
} catch (e) {
console.error("Failed to send test email", e);
toast.error("Failed to send test email");
}
};
const currentLayout = loadedPages.get(pageId);
return {
// State
currentLayout,
viewMode, setViewMode,
previewHtml,
htmlSize,
isAppReady,
isEditMode, setIsEditMode,
pageId,
pageName,
layoutJson,
templates,
isSaveDialogOpen, setIsSaveDialogOpen,
newTemplateName, setNewTemplateName,
isPasteDialogOpen, setIsPasteDialogOpen,
pasteJsonContent, setPasteJsonContent,
// Handlers
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handlePasteJson,
handleLoadContext,
handleExportHtml,
handleSendTestEmail,
importPageLayout // Expose importPageLayout for direct external updates
};
}

View File

@ -27,7 +27,6 @@ export const useResponsiveImage = ({
useEffect(() => {
let isMounted = true;
const generateResponsiveImages = async () => {
if (!src || !enabled) {
if (isMounted) {

View File

@ -0,0 +1,90 @@
import { useState, useCallback, useEffect } from 'react';
import { useLayout } from '@/contexts/LayoutContext';
interface UseSelectionProps {
pageId: string;
}
export function useSelection({ pageId }: UseSelectionProps) {
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
const { loadedPages } = useLayout();
const selectWidget = useCallback((widgetId: string) => {
setSelectedWidgetId(widgetId);
}, []);
const clearSelection = useCallback(() => {
setSelectedWidgetId(null);
}, []);
// Flatten widgets for linear navigation
const getFlattenedWidgets = useCallback(() => {
const layout = loadedPages.get(pageId);
if (!layout) return [];
const widgets: string[] = [];
const traverse = (container: any) => {
// Widgets first (as per render order)
if (container.widgets) {
const sortedWidgets = [...container.widgets].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
sortedWidgets.forEach((w: any) => widgets.push(w.id));
}
// Then children
if (container.children) {
const sortedChildren = [...container.children].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
sortedChildren.forEach((child: any) => traverse(child));
}
};
[...layout.containers].sort((a, b) => (a.order || 0) - (b.order || 0)).forEach(c => traverse(c));
return widgets;
}, [loadedPages, pageId]);
const moveSelection = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
const widgets = getFlattenedWidgets();
if (widgets.length === 0) return;
let nextIndex = -1;
const currentIndex = selectedWidgetId ? widgets.indexOf(selectedWidgetId) : -1;
if (currentIndex === -1) {
// Select first if nothing selected
nextIndex = 0;
} else {
// Linear navigation mapping
// Right/Down = Next
// Left/Up = Prev
if (direction === 'down' || direction === 'right') {
nextIndex = currentIndex + 1;
if (nextIndex >= widgets.length) nextIndex = widgets.length - 1; // Clamp to end
} else {
nextIndex = currentIndex - 1;
if (nextIndex < 0) nextIndex = 0; // Clamp to start
}
}
if (nextIndex !== -1 && nextIndex !== currentIndex) {
const nextId = widgets[nextIndex];
selectWidget(nextId);
// Scroll into view
setTimeout(() => {
const element = document.getElementById(`widget-item-${nextId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 0);
}
}, [selectedWidgetId, getFlattenedWidgets, selectWidget]);
return {
selectedWidgetId,
selectWidget,
clearSelection,
moveSelection
};
}

View File

@ -0,0 +1,118 @@
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
import { widgetRegistry } from '@/lib/widgetRegistry';
export function useWidgetLoader() {
const loadWidgetBundle = async (url: string) => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load library definition from ${url}`);
const library = await res.json();
const { widgets, name: contextName, root } = library;
if (!widgets || !Array.isArray(widgets)) {
throw new Error('Invalid library format: widgets array missing');
}
let count = 0;
const bundleBase = url.substring(0, url.lastIndexOf('/'));
// Resolve Root Template URL
let rootTemplateUrl = '';
if (root) {
rootTemplateUrl = root.startsWith('./')
? `${bundleBase}/${root.substring(2)}`
: `${bundleBase}/${root}`;
}
// Parallel fetch of all templates to generate schemas
const widgetPromises = widgets.map(async (w: any) => {
const widgetId = `${contextName}.${w.name.toLowerCase().replace(/\s+/g, '-')}`;
const templatePath = w.template.startsWith('./')
? `${bundleBase}/${w.template.substring(2)}`
: `${bundleBase}/${w.template}`;
// Fetch template content to detect placeholders
let configSchema: Record<string, any> = {};
let defaultProps: Record<string, any> = {
__templateUrl: templatePath
};
try {
const tplRes = await fetch(templatePath);
if (tplRes.ok) {
const html = await tplRes.text();
// Support both [[variable]] and ${variable} syntax
// [[variable]] -> match[1]
// ${variable} -> match[2]
const placeholderRegex = /\[\[(.*?)\]\]|\$\{([^\s:}]+)(?::([^\s:}]+))?\}/g;
let match;
const placeholders = new Set<string>();
while ((match = placeholderRegex.exec(html)) !== null) {
// key is either match[1] (for [[ ]]) or match[2] (for ${ })
const key = match[1] || match[2];
if (key && key !== 'content') { // 'content' is often special, but we can treat it as a prop
placeholders.add(key);
}
if (match[1] || match[2]) placeholders.add(match[1] || match[2]);
}
// Merge with manually defined schema from library.json
if (w.configSchema) {
configSchema = { ...configSchema, ...w.configSchema };
}
if (w.defaultProps) {
defaultProps = { ...defaultProps, ...w.defaultProps };
}
placeholders.forEach(key => {
// Skip if already defined in manual schema
if (configSchema[key]) return;
// Infers type based on key name
const isImage = key.toLowerCase().includes('image') || key.toLowerCase().includes('src') || key.toLowerCase().includes('picture');
configSchema[key] = {
label: key,
type: isImage ? 'imagePicker' : 'text',
default: isImage ? 'https://picsum.photos/200/300' : '',
description: `Value for ${key}`
};
if (defaultProps[key] === undefined) {
defaultProps[key] = isImage ? 'https://picsum.photos/200/300' : '';
}
});
}
} catch (e) {
console.warn(`Failed to inspect template ${templatePath}`, e);
}
widgetRegistry.register({
component: (props: any) => <HtmlWidget src={templatePath} {...props} />,
metadata: {
id: widgetId,
name: w.name,
category: 'email',
description: `Email widget: ${w.name}`,
tags: ['email', 'html'],
defaultProps: defaultProps,
configSchema: configSchema
}
});
count++;
});
await Promise.all(widgetPromises);
console.log(`[Playground] Loaded ${count} widgets from ${url}`);
return { count, rootTemplateUrl };
} catch (e) {
console.error(`Failed to load bundle ${url}`, e);
throw e;
}
};
return { loadWidgetBundle };
}

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, ReactNode, useMemo } from 'react';
// import { translations as generatedTranslations } from '../src/translations'; // Generated translations
// --- Language Configuration ---
type LangCode = 'en' | 'fr' | 'sw' | 'de' | 'es' | 'it' | 'ja' | 'ko' | 'pt' | 'ru' | 'tr' | 'zh' | 'nl';
@ -14,6 +13,7 @@ export const supportedLanguages = [
{ code: 'nl', name: 'Nederlands' }
];
// --- Caching and Loading ---
const translationCache: { [lang: string]: Record<string, string> } = {};
const loadingPromises: { [lang: string]: Promise<Record<string, string>> } = {};

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Suche nach Bildern, Benutzern, Sammlungen...",
"Search": "Suche",
"AI Image Generator": "AI Image Generator",
"Language": "Sprache",
"Sign in": "Eintragen",
"Loading...": "Laden...",
"My Profile": "Mein Profil",
"Enter your Google API key": "Geben Sie Ihren Google API-Schlüssel ein",
"Enter your OpenAI API key": "Geben Sie Ihren OpenAI API-Schlüssel ein",
"General": "Allgemein",
"Organizations": "Organisationen",
"API Keys": "API-Schlüssel",
"Profile": "Profil",
"Gallery": "Galerie",
"Profile Settings": "Profil-Einstellungen",
"Manage your account settings and preferences": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
"Google API Key": "Google API-Schlüssel",
"For Google services (stored securely)": "Für Google-Dienste (sicher gespeichert)",
"OpenAI API Key": "OpenAI API-Schlüssel",
"For AI image generation (stored securely)": "Für die Erzeugung von AI-Bildern (sicher gespeichert)",
"Save API Keys": "API-Schlüssel speichern",
"AP Gateway": "AP-Gateway",
"AP IP Address": "AP-IP-Adresse",
"AP Password": "AP-Kennwort",
"AP SSID": "AP SSID",
"AP Subnet Mask": "AP-Subnetzmaske",
"API URL": "API-URL",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO-MULTI AUSGEGLICHEN",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BALANCED",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Zugangspunkt (AP) Modus",
"Add Container": "Container hinzufügen",
"Add Samples": "Proben hinzufügen",
"Add Slave": "Slave hinzufügen",
"Add Widget": "Widget hinzufügen",
"Add a set of sample control points to this plot": "Hinzufügen eines Satzes von Probenkontrollpunkten zu dieser Darstellung",
"Add all": "Alle hinzufügen",
"Addr:": "Addr:",
"Address Picker": "Adressausleser",
"Advanced": "Fortgeschrittene",
"All Stop": "Alle Haltestelle",
"Apply": "Bewerbung",
"Argument 0:": "Argument 0:",
"Argument 1:": "Argument 1:",
"Argument 2 (Optional):": "Argument 2 (fakultativ):",
"Arguments:": "Argumente:",
"Associated Controllers:": "Zugehörige Steuergeräte:",
"Associated Signal Plot (Optional)": "Zugehöriges Signaldiagramm (optional)",
"Aux": "Aux",
"BALANCE": "BALANCE",
"BALANCE_MAX_DIFF": "BALANCE_MAX_DIFF",
"Buzzer": "Buzzer",
"Buzzer: Fast Blink": "Buzzer: Schnelles Blinken",
"Buzzer: Long Beep/Short Pause": "Buzzer: Langer Signalton/kurze Pause",
"Buzzer: Off": "Buzzer: Aus",
"Buzzer: Slow Blink": "Buzzer: Langsames Blinken",
"Buzzer: Solid On": "Buzzer: Dauerhaft eingeschaltet",
"CE": "CE",
"COM Write": "COM Schreiben",
"CP Description (Optional):": "CP-Beschreibung (fakultativ):",
"CP Name (Optional):": "CP-Name (fakultativ):",
"CSV": "CSV",
"Call Function": "Funktion aufrufen",
"Call Method": "Methode aufrufen",
"Call REST API": "REST-API aufrufen",
"Cancel": "Abbrechen",
"Carina": "Carina",
"Cassandra Left": "Cassandra Links",
"Cassandra Right": "Cassandra Rechts",
"Castor": "Castor",
"Cetus": "Cetus",
"Charts": "Diagramme",
"Child Profiles (Sub-plots)": "Profile der Kinder (Nebenhandlungen)",
"Clear": "Klar",
"Clear All": "Alle löschen",
"Clear All CPs": "Alle CPs löschen",
"Clear Chart": "Übersichtliches Diagramm",
"Click \"Add Container\" to start building your layout": "Klicken Sie auf \"Container hinzufügen\", um mit der Erstellung Ihres Layouts zu beginnen",
"Click \"Add Widget\" to start building your HMI": "Klicken Sie auf \"Widget hinzufügen\", um mit der Erstellung Ihrer HMI zu beginnen",
"Coil to Write:": "Spule zum Schreiben:",
"Coils": "Spulen",
"Color": "Farbe",
"Coma B": "Koma B",
"Commons": "Commons",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Konfigurieren Sie den neuen Kontrollpunkt. Drücken Sie die Eingabetaste zur Bestätigung oder Esc zum Abbrechen.",
"Configure the series to be displayed on the chart.": "Konfigurieren Sie die Serien, die im Diagramm angezeigt werden sollen.",
"Connect": "Verbinden Sie",
"Connect to a Modbus server to see controller data.": "Stellen Sie eine Verbindung zu einem Modbus-Server her, um die Daten der Steuerung zu sehen.",
"Connect to view register data.": "Verbinden Sie sich, um Registerdaten anzuzeigen.",
"Connected, but no register data received yet. Waiting for data...": "Verbunden, aber noch keine Registerdaten empfangen. Ich warte auf Daten...",
"Connects to an existing Wi-Fi network.": "Stellt eine Verbindung zu einem bestehenden Wi-Fi-Netzwerk her.",
"Containers": "Behältnisse",
"Continue": "Weiter",
"Control Points": "Kontrollpunkte",
"Control Points List": "Liste der Kontrollpunkte",
"Controller Chart": "Controller-Diagramm",
"Controller Partitions": "Controller Partitionen",
"Copy \"{plotName}\" to...": "Kopieren Sie \"{plotName}\" nach...",
"Copy \"{profileName}\" to...": "Kopieren Sie \"{Profilname}\" nach...",
"Copy this plot to another slot...": "Kopieren Sie diesen Plot in einen anderen Slot...",
"Copy to existing slot...": "In vorhandenen Steckplatz kopieren...",
"Copy to...": "Kopieren nach...",
"Copy...": "Kopieren...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Krater",
"Create Control Point": "Kontrollpunkt erstellen",
"Create New Control Point": "Neuen Kontrollpunkt erstellen",
"Creates its own Wi-Fi network.": "Erzeugt ein eigenes Wi-Fi-Netzwerk.",
"Crux": "Crux",
"Current Status": "Aktueller Stand",
"Custom Widgets": "Benutzerdefinierte Widgets",
"DEC": "DEC",
"Dashboard": "Dashboard",
"Delete": "Löschen",
"Delete Profile": "Profil löschen",
"Delete control point": "Kontrollpunkt löschen",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Beschreibung",
"Device Hostname": "Hostname des Geräts",
"Disable All": "Alle deaktivieren",
"Disconnect": "Trennen Sie die Verbindung",
"Display Message": "Meldung anzeigen",
"Download": "Herunterladen",
"Download All JSON": "Alle JSON herunterladen",
"Download English Translations": "Englische Übersetzungen herunterladen",
"Download JSON for {name}": "JSON für {Name} herunterladen",
"Download Plot": "Plot herunterladen",
"Drag and resize widgets": "Widgets ziehen und Größe ändern",
"Duplicate Profile": "Profil duplizieren",
"Duration (hh:mm:ss)": "Dauer (hh:mm:ss)",
"Duration:": "Dauer:",
"E.g., Quick Ramp Up": "Z.B. Quick Ramp Up",
"ERROR": "ERROR",
"Edit": "Bearbeiten",
"Edit Profile": "Profil bearbeiten",
"Edit mode: Add, move, and configure widgets": "Bearbeitungsmodus: Widgets hinzufügen, verschieben und konfigurieren",
"Edit mode: Configure containers and add widgets": "Bearbeitungsmodus: Container konfigurieren und Widgets hinzufügen",
"Empty Canvas": "Leere Leinwand",
"Empty Layout": "Leeres Layout",
"Enable All": "Alle freigeben",
"Enable control unavailable for {name}": "Aktivieren der Kontrolle nicht verfügbar für {Name}",
"Enabled": "Aktiviert",
"End Index": "Ende Index",
"Enter CP description": "CP-Beschreibung eingeben",
"Enter CP name": "CP-Name eingeben",
"Export": "Exportieren",
"Export JSON": "JSON exportieren",
"Export to CSV": "Exportieren nach CSV",
"Favorite Coils": "Bevorzugte Spulen",
"Favorite Registers": "Bevorzugte Register",
"Favorites": "Favoriten",
"File name": "Name der Datei",
"Fill": "Füllen Sie",
"Filling": "Füllen",
"General Settings": "Allgemeine Einstellungen",
"Global Settings": "Globale Einstellungen",
"HEX": "HEX",
"HMI Edit Mode Active": "HMI-Bearbeitungsmodus aktiv",
"Hardware I/O": "Hardware-E/A",
"Heating Time": "Heizzeit",
"Help": "Hilfe",
"Home": "Startseite",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Hostname",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Leerlauf",
"Import": "Importieren",
"Import JSON": "JSON importieren",
"Info": "Infos",
"Integrations": "Integrationen",
"Interlocked": "Verriegelt",
"Jammed": "Verklemmt",
"Joystick": "Joystick",
"LOADCELL": "LOADCELL",
"Last updated": "Zuletzt aktualisiert",
"Loadcell[25]": "Kraftmesszelle[25]",
"Loadcell[26]": "Kraftmesszelle[26]",
"Loading Cassandra settings...": "Laden der Cassandra-Einstellungen...",
"Loading network settings...": "Laden der Netzwerkeinstellungen...",
"Loading profiles from Modbus...": "Profile von Modbus laden...",
"Logs": "Protokolle",
"Low": "Niedrig",
"MANUAL": "MANUELL",
"MANUAL MULTI": "MANUELL MULTI",
"MANUAL_MULTI": "MANUELL_MULTI",
"MAXLOAD": "MAXLOAD",
"MAX_TIME": "MAX_TIME",
"MINLOAD": "MINLOAD",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Verwaltung von Slave-Geräten (max. 1).",
"Markdown": "Markdown",
"Master Configuration": "Master-Konfiguration",
"Master Name": "Hauptname",
"Max": "Max",
"Max Simultaneous": "Max. gleichzeitige",
"Mid": "Mitte",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Modus",
"Move control point down": "Kontrollpunkt nach unten verschieben",
"Move control point up": "Kontrollpunkt nach oben verschieben",
"N/A": "K.A",
"NONE": "KEINE",
"Network": "Netzwerk",
"Network Settings": "Netzwerk-Einstellungen",
"No Operation": "Keine Operation",
"No coils data available. Try refreshing.": "Keine Coil-Daten verfügbar. Versuchen Sie zu aktualisieren.",
"No containers yet": "Noch keine Container",
"No enabled profile": "Kein aktiviertes Profil",
"No register data available. Try refreshing.": "Keine Registerdaten verfügbar. Versuchen Sie zu aktualisieren.",
"No source found.": "Keine Quelle gefunden.",
"No widgets found": "Keine Widgets gefunden",
"No widgets yet": "Noch keine Widgets",
"None": "Keine",
"OC": "OC",
"OFFLINE": "OFFLINE",
"OK": "OK",
"OL": "OL",
"ON": "ON",
"ONLINE": "ONLINE",
"OV": "OV",
"OVERLOAD": "OVERLOAD",
"Offset": "Versetzt",
"Operatorswitch": "Operatorswitch",
"PID Control": "PID-Regelung",
"PV": "PV",
"Partitions": "Partitionen",
"Pause": "Pause",
"Pause Profile": "Pause Profil",
"Phapp": "Phapp",
"Play from start": "Von Anfang an spielen",
"Playground": "Spielplatz",
"Plunge": "Eintauchen",
"Plunger": "Stößel",
"PlungingAuto": "EintauchenAuto",
"PlungingMan": "PlungingMan",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "Pop-out",
"PostFlow": "PostFlow",
"Press": "Presse",
"Press Cylinder": "Presse-Zylinder",
"Press Cylinder Controls": "Pressenzylinder-Steuerung",
"Presscylinder": "Pressezylinder",
"Profile Curves": "Profil-Kurven",
"Profile Name": "Profil Name",
"Profile SP": "Profil SP",
"Profiles": "Profile",
"Properties:": "Eigenschaften:",
"REMOTE": "FERNSEHEN",
"Real time Charting": "Charting in Echtzeit",
"Real-time Charts": "Charts in Echtzeit",
"Record": "Datensatz",
"Refresh Rate": "Aktualisierungsrate",
"Registers": "Register",
"Remove all": "Alle entfernen",
"Remove all control points from this plot": "Alle Kontrollpunkte aus diesem Diagramm entfernen",
"Replay": "Wiederholen Sie",
"Reset": "Zurücksetzen",
"Reset Zoom": "Zoom zurücksetzen",
"ResettingJam": "Zurücksetzen vonJam",
"Restart at end": "Neustart am Ende",
"Run Action": "Aktion ausführen",
"Run this control point action now": "Führen Sie diese Kontrollpunktaktion jetzt aus",
"SP": "SP",
"SP CMD Addr:": "SP CMD Adr:",
"SP:": "SP:",
"STA Gateway": "STA-Gateway",
"STA IP Address": "STA IP-Adresse",
"STA Password": "STA-Passwort",
"STA Primary DNS": "STA Primäre DNS",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA Sekundärer DNS",
"STA Subnet Mask": "STA-Subnetzmaske",
"STALLED": "STALLED",
"Samplesignalplot 0": "Mustersignalplot 0",
"Save AP Settings": "AP-Einstellungen speichern",
"Save All Settings": "Alle Einstellungen speichern",
"Save As": "Speichern unter",
"Save STA Settings": "STA-Einstellungen speichern",
"Save Signal Plot": "Signalplot speichern",
"Scale": "Skala",
"Scale:": "Maßstab:",
"Search...": "Suche...",
"Select Known Coil...": "Bekannte Spule auswählen...",
"Select a control point to see its properties.": "Wählen Sie einen Kontrollpunkt aus, um seine Eigenschaften anzuzeigen.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Wählen Sie einen Zielplan aus. Der Inhalt von \"{PlotName}\" überschreibt den ausgewählten Plot. Diese Aktion kann nicht rückgängig gemacht werden.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Wählen Sie ein Zielprofil. Der Inhalt von \"{Profilname}\" wird das ausgewählte Profil überschreiben. Diese Aktion kann nicht rückgängig gemacht werden.",
"Select a plot to overwrite": "Wählen Sie eine zu überschreibende Fläche",
"Select a profile to overwrite": "Wählen Sie ein zu überschreibendes Profil",
"Select a register or coil address": "Wählen Sie ein Register oder eine Spulenadresse",
"Select a signal plot to associate and edit": "Wählen Sie ein Signaldiagramm zum Zuordnen und Bearbeiten aus",
"Select source...": "Quelle auswählen...",
"Select type": "Typ auswählen",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Ausgewählte untergeordnete Profile werden mit diesem übergeordneten Profil gestartet, gestoppt, angehalten und fortgesetzt.",
"Send IFTTT Notification": "IFTTT-Benachrichtigung senden",
"Sequential Heating": "Sequentielle Heizung",
"Sequential Heating Control": "Sequentielle Heizungssteuerung",
"Series": "Serie",
"Series Toggles": "Serie Toggles",
"Series settings": "Einstellungen der Serie",
"Set All": "Alle einstellen",
"Set All SP": "Alle SP einstellen",
"Set as Default": "Als Standard festlegen",
"Settings": "Einstellungen",
"Settings...": "Einstellungen...",
"Shortplot 70s": "Shortplot 70s",
"Show Legend": "Legende anzeigen",
"Show PV": "PV anzeigen",
"Show SP": "SP anzeigen",
"Signal Control Point Details": "Details zum Signalkontrollpunkt",
"Signal Plot Editor": "Signalplot-Editor",
"Signal plots configuration loaded from API.": "Konfiguration der Signaldiagramme von der API geladen.",
"Signalplot 922 Slot 2": "Signalplot 922 Steckplatz 2",
"Signalplot 923 Slot 3": "Signalplot 923 Steckplatz 3",
"Signals": "Signale",
"Slave Mode": "Slave-Modus",
"Slave:": "Sklave:",
"Slaves": "Sklaven",
"Slot": "Schlitz",
"Slot:": "Steckplatz:",
"Source": "Quelle",
"Start": "Start",
"Start Index": "Start-Index",
"Start PID Controllers": "PID-Regler starten",
"Start Profile": "Profil starten",
"State:": "Staat:",
"Station (STA) Mode": "Station (STA) Modus",
"Stop": "Stopp",
"Stop PID Controllers": "PID-Regler anhalten",
"Stop Profile": "Profil anhalten",
"Stop and reset": "Anhalten und zurücksetzen",
"Stop at end": "Stopp am Ende",
"Stopped": "Gestoppt",
"Stopping": "Stoppen",
"Switch to edit mode to add containers": "In den Bearbeitungsmodus wechseln, um Container hinzuzufügen",
"Switch to edit mode to add widgets": "In den Bearbeitungsmodus wechseln, um Widgets hinzuzufügen",
"System Calls": "Systemaufrufe",
"System Information": "System-Informationen",
"System Messages": "System-Meldungen",
"Target Controllers (Registers)": "Ziel-Controller (Register)",
"Temperature Control Points": "Temperaturkontrollpunkte",
"Temperature Profiles": "Temperatur-Profile",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Dieser Hostname wird sowohl für den STA- als auch für den AP-Modus verwendet. Änderungen hier werden in beiden Formen gespeichert.",
"This is where you'll design and configure your HMI layouts.": "Hier werden Sie Ihre HMI-Layouts entwerfen und konfigurieren.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Dadurch wird das Profil \"{Profilname}\" dauerhaft vom Server gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Time:": "Zeit:",
"Timeline:": "Zeitleiste:",
"Title (Optional)": "Titel (fakultativ)",
"Total": "Insgesamt",
"Total Cost": "Gesamtkosten",
"Total:": "Insgesamt:",
"Type:": "Art:",
"Unknown": "Unbekannt",
"Update Profile": "Profil aktualisieren",
"Upload": "Hochladen",
"Upload All JSON": "Alle JSON hochladen",
"Upload JSON for {name}": "JSON für {Name} hochladen",
"Upload Plot": "Plot hochladen",
"User Defined": "Benutzerdefiniert",
"Value:": "Wert:",
"View": "Siehe",
"View mode: Interact with your widgets": "Ansichtsmodus: Interaktion mit Ihren Widgets",
"Visible Controllers": "Sichtbare Kontrolleure",
"Watched Items": "Beobachtete Artikel",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Wenn der Slave-Modus aktiviert ist, werden alle Omron-Regler für die Verarbeitung deaktiviert.",
"Widget editor and drag-and-drop functionality coming soon...": "Widget-Editor und Drag-and-Drop-Funktionalität in Kürze...",
"Widgets": "Widgets",
"Window (min)": "Fenster (min)",
"Window Offset": "Fenster Versatz",
"Write Coil": "Spule schreiben",
"Write GPIO": "GPIO schreiben",
"Write Holding Register": "Schreib-Halte-Register",
"X-Axis": "X-Achse",
"Y-Axis Left": "Y-Achse links",
"accel": "beschleunigung",
"decel": "abbremsen",
"e.g., Start Heating": "z.B. Start Heizung",
"e.g., Turn on coil for pre-heating stage": "z.B., Einschalten der Spule für die Vorwärmstufe",
"err": "err",
"fwd": "fwd",
"in seconds": "in Sekunden",
"info": "infos",
"none": "keine",
"reset": "zurücksetzen",
"reset_fault": "reset_fault",
"rev": "rev",
"run": "laufen",
"setup": "einrichtung",
"stop": "stoppen",
"Loading comments...": "Kommentare laden...",
"Edit with AI Wizard": "Bearbeiten mit AI Wizard",
"Be the first to like this": "Sei der Erste, dem dies gefällt",
"Versions": "Versionen",
"Current": "Aktuell",
"Add a comment...": "Einen Kommentar hinzufügen...",
"Post Comment": "Kommentar schreiben",
"No comments yet": "Noch keine Kommentare",
"Be the first to comment!": "Seien Sie der Erste, der einen Kommentar abgibt!",
"Save": "Speichern Sie",
"likes": "mag",
"like": "wie",
"Prompt Templates": "Prompt-Vorlagen",
"Optimize prompt with AI": "Optimieren Sie die Eingabeaufforderung mit AI",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Beschreiben Sie das Bild, das Sie erstellen oder bearbeiten möchten... (Strg+V zum Einfügen von Bildern)",
"e.g. Cyberpunk Portrait": "z.B. Cyberpunk Portrait",
"Prompt": "Eingabeaufforderung",
"Templates": "Schablonen",
"Optimize": "Optimieren Sie",
"Selected Images": "Ausgewählte Bilder",
"Upload Images": "Bilder hochladen",
"Choose Files": "Dateien auswählen",
"No images selected": "Keine Bilder ausgewählt",
"Upload images or select from gallery": "Bilder hochladen oder aus der Galerie auswählen",
"No templates saved yet": "Noch keine Vorlagen gespeichert",
"Save current as template": "Aktuelles als Vorlage speichern",
"Loading profile...": "Profil laden...",
"Back to feed": "Zurück zu Futtermittel",
"Create Post": "Beitrag erstellen",
"posts": "beiträge",
"followers": "anhänger",
"following": "unter",
"Joined": "Beitritt",
"Collections": "Sammlungen",
"New": "Neu",
"POSTS": "POSTEN",
"HIDDEN": "HIDDEN",
"Profile picture": "Profilbild",
"your.email@example.com": "your.email@example.com",
"Enter username": "Benutzernamen eingeben",
"Enter display name": "Anzeigename eingeben",
"Tell us about yourself...": "Erzählen Sie uns von sich...",
"Change Avatar": "Avatar ändern",
"Email": "E-Mail",
"Username": "Benutzername",
"Display Name": "Name anzeigen",
"Bio": "Bio",
"Your preferred language for the interface": "Ihre bevorzugte Sprache für die Schnittstelle",
"Save Changes": "Änderungen speichern",
"Edit Picture": "Bild bearbeiten",
"Edit Details": "Details bearbeiten",
"Generate Title & Description with AI": "Titel und Beschreibung mit AI generieren",
"Enter a title...": "Geben Sie einen Titel ein...",
"Record audio": "Audio aufnehmen",
"Describe your photo... You can use **markdown** formatting!": "Beschreiben Sie Ihr Foto... Sie können **Markdown** Formatierung verwenden!",
"Description (Optional)": "Beschreibung (fakultativ)",
"Visible": "Sichtbar",
"Make this picture visible to others": "Dieses Bild für andere sichtbar machen",
"Update": "Update",
"Loading versions...": "Versionen laden...",
"No other versions available for this image.": "Für dieses Bild sind keine anderen Versionen verfügbar."
}

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Search pictures, users, collections...",
"Search": "Search",
"AI Image Generator": "AI Image Generator",
"Language": "Language",
"Sign in": "Sign in",
"Loading...": "Loading...",
"My Profile": "My Profile",
"Enter your Google API key": "Enter your Google API key",
"Enter your OpenAI API key": "Enter your OpenAI API key",
"General": "General",
"Organizations": "Organizations",
"API Keys": "API Keys",
"Profile": "Profile",
"Gallery": "Gallery",
"Profile Settings": "Profile Settings",
"Manage your account settings and preferences": "Manage your account settings and preferences",
"Google API Key": "Google API Key",
"For Google services (stored securely)": "For Google services (stored securely)",
"OpenAI API Key": "OpenAI API Key",
"For AI image generation (stored securely)": "For AI image generation (stored securely)",
"Save API Keys": "Save API Keys",
"AP Gateway": "AP Gateway",
"AP IP Address": "AP IP Address",
"AP Password": "AP Password",
"AP SSID": "AP SSID",
"AP Subnet Mask": "AP Subnet Mask",
"API URL": "API URL",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO MULTI BALANCED",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BALANCED",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Access Point (AP) Mode",
"Add Container": "Add Container",
"Add Samples": "Add Samples",
"Add Slave": "Add Slave",
"Add Widget": "Add Widget",
"Add a set of sample control points to this plot": "Add a set of sample control points to this plot",
"Add all": "Add all",
"Addr:": "Addr:",
"Address Picker": "Address Picker",
"Advanced": "Advanced",
"All Stop": "All Stop",
"Apply": "Apply",
"Argument 0:": "Argument 0:",
"Argument 1:": "Argument 1:",
"Argument 2 (Optional):": "Argument 2 (Optional):",
"Arguments:": "Arguments:",
"Associated Controllers:": "Associated Controllers:",
"Associated Signal Plot (Optional)": "Associated Signal Plot (Optional)",
"Aux": "Aux",
"BALANCE": "BALANCE",
"BALANCE_MAX_DIFF": "BALANCE_MAX_DIFF",
"Buzzer": "Buzzer",
"Buzzer: Fast Blink": "Buzzer: Fast Blink",
"Buzzer: Long Beep/Short Pause": "Buzzer: Long Beep/Short Pause",
"Buzzer: Off": "Buzzer: Off",
"Buzzer: Slow Blink": "Buzzer: Slow Blink",
"Buzzer: Solid On": "Buzzer: Solid On",
"CE": "CE",
"COM Write": "COM Write",
"CP Description (Optional):": "CP Description (Optional):",
"CP Name (Optional):": "CP Name (Optional):",
"CSV": "CSV",
"Call Function": "Call Function",
"Call Method": "Call Method",
"Call REST API": "Call REST API",
"Cancel": "Cancel",
"Carina": "Carina",
"Cassandra Left": "Cassandra Left",
"Cassandra Right": "Cassandra Right",
"Castor": "Castor",
"Cetus": "Cetus",
"Charts": "Charts",
"Child Profiles (Sub-plots)": "Child Profiles (Sub-plots)",
"Clear": "Clear",
"Clear All": "Clear All",
"Clear All CPs": "Clear All CPs",
"Clear Chart": "Clear Chart",
"Click \"Add Container\" to start building your layout": "Click \"Add Container\" to start building your layout",
"Click \"Add Widget\" to start building your HMI": "Click \"Add Widget\" to start building your HMI",
"Coil to Write:": "Coil to Write:",
"Coils": "Coils",
"Color": "Color",
"Coma B": "Coma B",
"Commons": "Commons",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Configure the new control point. Press Enter to confirm or Esc to cancel.",
"Configure the series to be displayed on the chart.": "Configure the series to be displayed on the chart.",
"Connect": "Connect",
"Connect to a Modbus server to see controller data.": "Connect to a Modbus server to see controller data.",
"Connect to view register data.": "Connect to view register data.",
"Connected, but no register data received yet. Waiting for data...": "Connected, but no register data received yet. Waiting for data...",
"Connects to an existing Wi-Fi network.": "Connects to an existing Wi-Fi network.",
"Containers": "Containers",
"Continue": "Continue",
"Control Points": "Control Points",
"Control Points List": "Control Points List",
"Controller Chart": "Controller Chart",
"Controller Partitions": "Controller Partitions",
"Copy \"{plotName}\" to...": "Copy \"{plotName}\" to...",
"Copy \"{profileName}\" to...": "Copy \"{profileName}\" to...",
"Copy this plot to another slot...": "Copy this plot to another slot...",
"Copy to existing slot...": "Copy to existing slot...",
"Copy to...": "Copy to...",
"Copy...": "Copy...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Crater",
"Create Control Point": "Create Control Point",
"Create New Control Point": "Create New Control Point",
"Creates its own Wi-Fi network.": "Creates its own Wi-Fi network.",
"Crux": "Crux",
"Current Status": "Current Status",
"Custom Widgets": "Custom Widgets",
"DEC": "DEC",
"Dashboard": "Dashboard",
"Delete": "Delete",
"Delete Profile": "Delete Profile",
"Delete control point": "Delete control point",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Description",
"Device Hostname": "Device Hostname",
"Disable All": "Disable All",
"Disconnect": "Disconnect",
"Display Message": "Display Message",
"Download": "Download",
"Download All JSON": "Download All JSON",
"Download English Translations": "Download English Translations",
"Download JSON for {name}": "Download JSON for {name}",
"Download Plot": "Download Plot",
"Drag and resize widgets": "Drag and resize widgets",
"Duplicate Profile": "Duplicate Profile",
"Duration (hh:mm:ss)": "Duration (hh:mm:ss)",
"Duration:": "Duration:",
"E.g., Quick Ramp Up": "E.g., Quick Ramp Up",
"ERROR": "ERROR",
"Edit": "Edit",
"Edit Profile": "Edit Profile",
"Edit mode: Add, move, and configure widgets": "Edit mode: Add, move, and configure widgets",
"Edit mode: Configure containers and add widgets": "Edit mode: Configure containers and add widgets",
"Empty Canvas": "Empty Canvas",
"Empty Layout": "Empty Layout",
"Enable All": "Enable All",
"Enable control unavailable for {name}": "Enable control unavailable for {name}",
"Enabled": "Enabled",
"End Index": "End Index",
"Enter CP description": "Enter CP description",
"Enter CP name": "Enter CP name",
"Export": "Export",
"Export JSON": "Export JSON",
"Export to CSV": "Export to CSV",
"Favorite Coils": "Favorite Coils",
"Favorite Registers": "Favorite Registers",
"Favorites": "Favorites",
"File name": "File name",
"Fill": "Fill",
"Filling": "Filling",
"General Settings": "General Settings",
"Global Settings": "Global Settings",
"HEX": "HEX",
"HMI Edit Mode Active": "HMI Edit Mode Active",
"Hardware I/O": "Hardware I/O",
"Heating Time": "Heating Time",
"Help": "Help",
"Home": "Home",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Hostname",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Idle",
"Import": "Import",
"Import JSON": "Import JSON",
"Info": "Info",
"Integrations": "Integrations",
"Interlocked": "Interlocked",
"Jammed": "Jammed",
"Joystick": "Joystick",
"LOADCELL": "LOADCELL",
"Last updated": "Last updated",
"Loadcell[25]": "Loadcell[25]",
"Loadcell[26]": "Loadcell[26]",
"Loading Cassandra settings...": "Loading Cassandra settings...",
"Loading network settings...": "Loading network settings...",
"Loading profiles from Modbus...": "Loading profiles from Modbus...",
"Logs": "Logs",
"Low": "Low",
"MANUAL": "MANUAL",
"MANUAL MULTI": "MANUAL MULTI",
"MANUAL_MULTI": "MANUAL_MULTI",
"MAXLOAD": "MAXLOAD",
"MAX_TIME": "MAX_TIME",
"MINLOAD": "MINLOAD",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Manage slave devices (max 1).",
"Markdown": "Markdown",
"Master Configuration": "Master Configuration",
"Master Name": "Master Name",
"Max": "Max",
"Max Simultaneous": "Max Simultaneous",
"Mid": "Mid",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Mode",
"Move control point down": "Move control point down",
"Move control point up": "Move control point up",
"N/A": "N/A",
"NONE": "NONE",
"Network": "Network",
"Network Settings": "Network Settings",
"No Operation": "No Operation",
"No coils data available. Try refreshing.": "No coils data available. Try refreshing.",
"No containers yet": "No containers yet",
"No enabled profile": "No enabled profile",
"No register data available. Try refreshing.": "No register data available. Try refreshing.",
"No source found.": "No source found.",
"No widgets found": "No widgets found",
"No widgets yet": "No widgets yet",
"None": "None",
"OC": "OC",
"OFFLINE": "OFFLINE",
"OK": "OK",
"OL": "OL",
"ON": "ON",
"ONLINE": "ONLINE",
"OV": "OV",
"OVERLOAD": "OVERLOAD",
"Offset": "Offset",
"Operatorswitch": "Operatorswitch",
"PID Control": "PID Control",
"PV": "PV",
"Partitions": "Partitions",
"Pause": "Pause",
"Pause Profile": "Pause Profile",
"Phapp": "Phapp",
"Play from start": "Play from start",
"Playground": "Playground",
"Plunge": "Plunge",
"Plunger": "Plunger",
"PlungingAuto": "PlungingAuto",
"PlungingMan": "PlungingMan",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "Pop-out",
"PostFlow": "PostFlow",
"Press": "Press",
"Press Cylinder": "Press Cylinder",
"Press Cylinder Controls": "Press Cylinder Controls",
"Presscylinder": "Presscylinder",
"Profile Curves": "Profile Curves",
"Profile Name": "Profile Name",
"Profile SP": "Profile SP",
"Profiles": "Profiles",
"Properties:": "Properties:",
"REMOTE": "REMOTE",
"Real time Charting": "Real time Charting",
"Real-time Charts": "Real-time Charts",
"Record": "Record",
"Refresh Rate": "Refresh Rate",
"Registers": "Registers",
"Remove all": "Remove all",
"Remove all control points from this plot": "Remove all control points from this plot",
"Replay": "Replay",
"Reset": "Reset",
"Reset Zoom": "Reset Zoom",
"ResettingJam": "ResettingJam",
"Restart at end": "Restart at end",
"Run Action": "Run Action",
"Run this control point action now": "Run this control point action now",
"SP": "SP",
"SP CMD Addr:": "SP CMD Addr:",
"SP:": "SP:",
"STA Gateway": "STA Gateway",
"STA IP Address": "STA IP Address",
"STA Password": "STA Password",
"STA Primary DNS": "STA Primary DNS",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA Secondary DNS",
"STA Subnet Mask": "STA Subnet Mask",
"STALLED": "STALLED",
"Samplesignalplot 0": "Samplesignalplot 0",
"Save AP Settings": "Save AP Settings",
"Save All Settings": "Save All Settings",
"Save As": "Save As",
"Save STA Settings": "Save STA Settings",
"Save Signal Plot": "Save Signal Plot",
"Scale": "Scale",
"Scale:": "Scale:",
"Search...": "Search...",
"Select Known Coil...": "Select Known Coil...",
"Select a control point to see its properties.": "Select a control point to see its properties.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.",
"Select a plot to overwrite": "Select a plot to overwrite",
"Select a profile to overwrite": "Select a profile to overwrite",
"Select a register or coil address": "Select a register or coil address",
"Select a signal plot to associate and edit": "Select a signal plot to associate and edit",
"Select source...": "Select source...",
"Select type": "Select type",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Selected child profiles will start, stop, pause, and resume with this parent profile.",
"Send IFTTT Notification": "Send IFTTT Notification",
"Sequential Heating": "Sequential Heating",
"Sequential Heating Control": "Sequential Heating Control",
"Series": "Series",
"Series Toggles": "Series Toggles",
"Series settings": "Series settings",
"Set All": "Set All",
"Set All SP": "Set All SP",
"Set as Default": "Set as Default",
"Settings": "Settings",
"Settings...": "Settings...",
"Shortplot 70s": "Shortplot 70s",
"Show Legend": "Show Legend",
"Show PV": "Show PV",
"Show SP": "Show SP",
"Signal Control Point Details": "Signal Control Point Details",
"Signal Plot Editor": "Signal Plot Editor",
"Signal plots configuration loaded from API.": "Signal plots configuration loaded from API.",
"Signalplot 922 Slot 2": "Signalplot 922 Slot 2",
"Signalplot 923 Slot 3": "Signalplot 923 Slot 3",
"Signals": "Signals",
"Slave Mode": "Slave Mode",
"Slave:": "Slave:",
"Slaves": "Slaves",
"Slot": "Slot",
"Slot:": "Slot:",
"Source": "Source",
"Start": "Start",
"Start Index": "Start Index",
"Start PID Controllers": "Start PID Controllers",
"Start Profile": "Start Profile",
"State:": "State:",
"Station (STA) Mode": "Station (STA) Mode",
"Stop": "Stop",
"Stop PID Controllers": "Stop PID Controllers",
"Stop Profile": "Stop Profile",
"Stop and reset": "Stop and reset",
"Stop at end": "Stop at end",
"Stopped": "Stopped",
"Stopping": "Stopping",
"Switch to edit mode to add containers": "Switch to edit mode to add containers",
"Switch to edit mode to add widgets": "Switch to edit mode to add widgets",
"System Calls": "System Calls",
"System Information": "System Information",
"System Messages": "System Messages",
"Target Controllers (Registers)": "Target Controllers (Registers)",
"Temperature Control Points": "Temperature Control Points",
"Temperature Profiles": "Temperature Profiles",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "This hostname is used for both STA and AP modes. Changes here will be saved with either form.",
"This is where you'll design and configure your HMI layouts.": "This is where you'll design and configure your HMI layouts.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.",
"Time:": "Time:",
"Timeline:": "Timeline:",
"Title (Optional)": "Title (Optional)",
"Total": "Total",
"Total Cost": "Total Cost",
"Total:": "Total:",
"Type:": "Type:",
"Unknown": "Unknown",
"Update Profile": "Update Profile",
"Upload": "Upload",
"Upload All JSON": "Upload All JSON",
"Upload JSON for {name}": "Upload JSON for {name}",
"Upload Plot": "Upload Plot",
"User Defined": "User Defined",
"Value:": "Value:",
"View": "View",
"View mode: Interact with your widgets": "View mode: Interact with your widgets",
"Visible Controllers": "Visible Controllers",
"Watched Items": "Watched Items",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "When Slave Mode is enabled, all Omron controllers will be disabled for processing.",
"Widget editor and drag-and-drop functionality coming soon...": "Widget editor and drag-and-drop functionality coming soon...",
"Widgets": "Widgets",
"Window (min)": "Window (min)",
"Window Offset": "Window Offset",
"Write Coil": "Write Coil",
"Write GPIO": "Write GPIO",
"Write Holding Register": "Write Holding Register",
"X-Axis": "X-Axis",
"Y-Axis Left": "Y-Axis Left",
"accel": "accel",
"decel": "decel",
"e.g., Start Heating": "e.g., Start Heating",
"e.g., Turn on coil for pre-heating stage": "e.g., Turn on coil for pre-heating stage",
"err": "err",
"fwd": "fwd",
"in seconds": "in seconds",
"info": "info",
"none": "none",
"reset": "reset",
"reset_fault": "reset_fault",
"rev": "rev",
"run": "run",
"setup": "setup",
"stop": "stop",
"Loading comments...": "Loading comments...",
"Edit with AI Wizard": "Edit with AI Wizard",
"Be the first to like this": "Be the first to like this",
"Versions": "Versions",
"Current": "Current",
"Add a comment...": "Add a comment...",
"Post Comment": "Post Comment",
"No comments yet": "No comments yet",
"Be the first to comment!": "Be the first to comment!",
"Save": "Save",
"likes": "likes",
"like": "like",
"Prompt Templates": "Prompt Templates",
"Optimize prompt with AI": "Optimize prompt with AI",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Describe the image you want to create or edit... (Ctrl+V to paste images)",
"e.g. Cyberpunk Portrait": "e.g. Cyberpunk Portrait",
"Prompt": "Prompt",
"Templates": "Templates",
"Optimize": "Optimize",
"Selected Images": "Selected Images",
"Upload Images": "Upload Images",
"Choose Files": "Choose Files",
"No images selected": "No images selected",
"Upload images or select from gallery": "Upload images or select from gallery",
"No templates saved yet": "No templates saved yet",
"Save current as template": "Save current as template",
"Loading profile...": "Loading profile...",
"Back to feed": "Back to feed",
"Create Post": "Create Post",
"posts": "posts",
"followers": "followers",
"following": "following",
"Joined": "Joined",
"Collections": "Collections",
"New": "New",
"POSTS": "POSTS",
"HIDDEN": "HIDDEN",
"Profile picture": "Profile picture",
"your.email@example.com": "your.email@example.com",
"Enter username": "Enter username",
"Enter display name": "Enter display name",
"Tell us about yourself...": "Tell us about yourself...",
"Change Avatar": "Change Avatar",
"Email": "Email",
"Username": "Username",
"Display Name": "Display Name",
"Bio": "Bio",
"Your preferred language for the interface": "Your preferred language for the interface",
"Save Changes": "Save Changes",
"Edit Picture": "Edit Picture",
"Edit Details": "Edit Details",
"Generate Title & Description with AI": "Generate Title & Description with AI",
"Enter a title...": "Enter a title...",
"Record audio": "Record audio",
"Describe your photo... You can use **markdown** formatting!": "Describe your photo... You can use **markdown** formatting!",
"Description (Optional)": "Description (Optional)",
"Visible": "Visible",
"Make this picture visible to others": "Make this picture visible to others",
"Update": "Update",
"Loading versions...": "Loading versions...",
"No other versions available for this image.": "No other versions available for this image."
}

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Buscar fotos, usuarios, colecciones...",
"Search": "Buscar en",
"AI Image Generator": "Generador de imágenes AI",
"Language": "Idioma",
"Sign in": "Iniciar sesión",
"Loading...": "Cargando...",
"My Profile": "Mi perfil",
"Enter your Google API key": "Introduce tu clave API de Google",
"Enter your OpenAI API key": "Introduzca su clave API de OpenAI",
"General": "General",
"Organizations": "Organizaciones",
"API Keys": "Claves API",
"Profile": "Perfil",
"Gallery": "Galería",
"Profile Settings": "Configuración del perfil",
"Manage your account settings and preferences": "Gestionar la configuración y las preferencias de su cuenta",
"Google API Key": "Clave API de Google",
"For Google services (stored securely)": "Para los servicios de Google (almacenados de forma segura)",
"OpenAI API Key": "Clave API de OpenAI",
"For AI image generation (stored securely)": "Para la generación de imágenes de IA (almacenadas de forma segura)",
"Save API Keys": "Guardar claves API",
"AP Gateway": "Pasarela AP",
"AP IP Address": "Dirección IP AP",
"AP Password": "Contraseña AP",
"AP SSID": "AP SSID",
"AP Subnet Mask": "Máscara de subred AP",
"API URL": "URL API",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO MULTI BALANCED",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BALANCED",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Modo de punto de acceso (AP)",
"Add Container": "Añadir contenedor",
"Add Samples": "Añadir muestras",
"Add Slave": "Añadir esclavo",
"Add Widget": "Añadir widget",
"Add a set of sample control points to this plot": "Añadir un conjunto de puntos de control de muestra a este gráfico",
"Add all": "Añadir todo",
"Addr:": "Dirección",
"Address Picker": "Selector de direcciones",
"Advanced": "Avanzado",
"All Stop": "Todos Stop",
"Apply": "Solicitar",
"Argument 0:": "Argumento 0:",
"Argument 1:": "Argumento 1:",
"Argument 2 (Optional):": "Argumento 2 (opcional):",
"Arguments:": "Argumentos:",
"Associated Controllers:": "Controladores asociados:",
"Associated Signal Plot (Optional)": "Gráfico de señales asociadas (opcional)",
"Aux": "Aux",
"BALANCE": "BALANCE",
"BALANCE_MAX_DIFF": "BALANCE_MAX_DIFF",
"Buzzer": "Zumbador",
"Buzzer: Fast Blink": "Zumbador: Parpadeo rápido",
"Buzzer: Long Beep/Short Pause": "Zumbador: Pitido largo/Pausa corta",
"Buzzer: Off": "Timbre: Apagado",
"Buzzer: Slow Blink": "Zumbador: Parpadeo lento",
"Buzzer: Solid On": "Timbre: Encendido",
"CE": "CE",
"COM Write": "COM Escribir",
"CP Description (Optional):": "Descripción del CP (opcional):",
"CP Name (Optional):": "Nombre del CP (opcional):",
"CSV": "CSV",
"Call Function": "Función de llamada",
"Call Method": "Método de llamada",
"Call REST API": "Llamar a la API REST",
"Cancel": "Cancelar",
"Carina": "Carina",
"Cassandra Left": "Cassandra Izquierda",
"Cassandra Right": "Cassandra Derecha",
"Castor": "Ricino",
"Cetus": "Cetus",
"Charts": "Gráficos",
"Child Profiles (Sub-plots)": "Perfiles de los niños (subtramas)",
"Clear": "Claro",
"Clear All": "Borrar todo",
"Clear All CPs": "Borrar todos los CP",
"Clear Chart": "Gráfico claro",
"Click \"Add Container\" to start building your layout": "Haz clic en \"Añadir contenedor\" para empezar a crear tu diseño",
"Click \"Add Widget\" to start building your HMI": "Haga clic en \"Añadir Widget\" para empezar a crear su HMI",
"Coil to Write:": "Bobina para escribir:",
"Coils": "Bobinas",
"Color": "Color",
"Coma B": "Coma B",
"Commons": "Comunes",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Configure el nuevo punto de control. Pulse Intro para confirmar o Esc para cancelar.",
"Configure the series to be displayed on the chart.": "Configure las series que se mostrarán en el gráfico.",
"Connect": "Conectar",
"Connect to a Modbus server to see controller data.": "Conectarse a un servidor Modbus para ver los datos del controlador.",
"Connect to view register data.": "Conéctate para ver los datos del registro.",
"Connected, but no register data received yet. Waiting for data...": "Conectado, pero aún no se han recibido datos de registro. Esperando datos...",
"Connects to an existing Wi-Fi network.": "Se conecta a una red Wi-Fi existente.",
"Containers": "Contenedores",
"Continue": "Continúe en",
"Control Points": "Puntos de control",
"Control Points List": "Lista de puntos de control",
"Controller Chart": "Gráfico de controladores",
"Controller Partitions": "Particiones del controlador",
"Copy \"{plotName}\" to...": "Copiar \"{plotName}\" a...",
"Copy \"{profileName}\" to...": "Copiar \"{nombredeperfil}\" a...",
"Copy this plot to another slot...": "Copiar esta parcela a otra ranura...",
"Copy to existing slot...": "Copiar en ranura existente...",
"Copy to...": "Copiar a...",
"Copy...": "Copia...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Cráter",
"Create Control Point": "Crear punto de control",
"Create New Control Point": "Crear nuevo punto de control",
"Creates its own Wi-Fi network.": "Crea su propia red Wi-Fi.",
"Crux": "Crux",
"Current Status": "Situación actual",
"Custom Widgets": "Widgets personalizados",
"DEC": "DEC",
"Dashboard": "Cuadro de mandos",
"Delete": "Borrar",
"Delete Profile": "Borrar perfil",
"Delete control point": "Borrar punto de control",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Descripción",
"Device Hostname": "Nombre de host del dispositivo",
"Disable All": "Desactivar todo",
"Disconnect": "Desconecte",
"Display Message": "Mostrar mensaje",
"Download": "Descargar",
"Download All JSON": "Descargar todo el JSON",
"Download English Translations": "Descargar traducciones al inglés",
"Download JSON for {name}": "Descargar JSON para {nombre}",
"Download Plot": "Descargar parcela",
"Drag and resize widgets": "Arrastrar y cambiar el tamaño de los widgets",
"Duplicate Profile": "Duplicar perfil",
"Duration (hh:mm:ss)": "Duración (hh:mm:ss)",
"Duration:": "Duración:",
"E.g., Quick Ramp Up": "Por ejemplo, Quick Ramp Up",
"ERROR": "ERROR",
"Edit": "Editar",
"Edit Profile": "Editar perfil",
"Edit mode: Add, move, and configure widgets": "Modo edición: Añadir, mover y configurar widgets",
"Edit mode: Configure containers and add widgets": "Modo edición: Configurar contenedores y añadir widgets",
"Empty Canvas": "Lienzo vacío",
"Empty Layout": "Disposición vacía",
"Enable All": "Activar todo",
"Enable control unavailable for {name}": "Habilitar control no disponible para {nombre}",
"Enabled": "Activado",
"End Index": "Índice final",
"Enter CP description": "Introduzca la descripción del CP",
"Enter CP name": "Introduzca el nombre del PC",
"Export": "Exportar",
"Export JSON": "Exportar JSON",
"Export to CSV": "Exportar a CSV",
"Favorite Coils": "Bobinas favoritas",
"Favorite Registers": "Registros favoritos",
"Favorites": "Favoritos",
"File name": "Nombre del fichero",
"Fill": "Rellene",
"Filling": "Relleno",
"General Settings": "Configuración general",
"Global Settings": "Ajustes globales",
"HEX": "HEX",
"HMI Edit Mode Active": "Modo Edición HMI Activo",
"Hardware I/O": "E/S de hardware",
"Heating Time": "Tiempo de calentamiento",
"Help": "Ayuda",
"Home": "Inicio",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Nombre de host",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Ocioso",
"Import": "Importar",
"Import JSON": "Importar JSON",
"Info": "Información",
"Integrations": "Integraciones",
"Interlocked": "Entrelazados",
"Jammed": "Atascado",
"Joystick": "Joystick",
"LOADCELL": "CELDA DE CARGA",
"Last updated": "Última actualización",
"Loadcell[25]": "Célula de carga[25]",
"Loadcell[26]": "Célula de carga[26]",
"Loading Cassandra settings...": "Cargando configuración de Cassandra...",
"Loading network settings...": "Cargando configuración de red...",
"Loading profiles from Modbus...": "Cargando perfiles de Modbus...",
"Logs": "Registros",
"Low": "Bajo",
"MANUAL": "MANUAL",
"MANUAL MULTI": "MANUAL MULTI",
"MANUAL_MULTI": "MANUAL_MULTI",
"MAXLOAD": "MAXLOAD",
"MAX_TIME": "TIEMPO_MAX",
"MINLOAD": "CARGA MÍNIMA",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Gestionar dispositivos esclavos (máx. 1).",
"Markdown": "Markdown",
"Master Configuration": "Configuración maestra",
"Master Name": "Nombre principal",
"Max": "Max",
"Max Simultaneous": "Máximo simultáneo",
"Mid": "Medio",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Modo",
"Move control point down": "Mover el punto de control hacia abajo",
"Move control point up": "Mover el punto de control hacia arriba",
"N/A": "N/A",
"NONE": "NONE",
"Network": "Red",
"Network Settings": "Ajustes de red",
"No Operation": "Ninguna operación",
"No coils data available. Try refreshing.": "No hay datos de bobinas disponibles. Prueba a actualizar.",
"No containers yet": "Aún no hay contenedores",
"No enabled profile": "Perfil no habilitado",
"No register data available. Try refreshing.": "No hay datos de registro disponibles. Prueba a actualizar.",
"No source found.": "No se ha encontrado ninguna fuente.",
"No widgets found": "No se han encontrado widgets",
"No widgets yet": "Aún no hay widgets",
"None": "Ninguno",
"OC": "OC",
"OFFLINE": "FUERA DE LÍNEA",
"OK": "OK",
"OL": "OL",
"ON": "EN",
"ONLINE": "EN LÍNEA",
"OV": "OV",
"OVERLOAD": "SOBRECARGA",
"Offset": "Desplazamiento",
"Operatorswitch": "Interruptor de operador",
"PID Control": "Control PID",
"PV": "FV",
"Partitions": "Particiones",
"Pause": "Pausa",
"Pause Profile": "Pausa Perfil",
"Phapp": "Phapp",
"Play from start": "Jugar desde el principio",
"Playground": "Parque infantil",
"Plunge": "Sumérgete",
"Plunger": "Émbolo",
"PlungingAuto": "PlungingAuto",
"PlungingMan": "PlungingMan",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "Desplegable",
"PostFlow": "PostFlow",
"Press": "Pulse",
"Press Cylinder": "Cilindro de prensa",
"Press Cylinder Controls": "Controles del cilindro de prensado",
"Presscylinder": "Cilindro a presión",
"Profile Curves": "Curvas de perfil",
"Profile Name": "Nombre del perfil",
"Profile SP": "Perfil SP",
"Profiles": "Perfiles",
"Properties:": "Propiedades:",
"REMOTE": "REMOTO",
"Real time Charting": "Gráficos en tiempo real",
"Real-time Charts": "Gráficos en tiempo real",
"Record": "Registro",
"Refresh Rate": "Frecuencia de actualización",
"Registers": "Registros",
"Remove all": "Eliminar todo",
"Remove all control points from this plot": "Eliminar todos los puntos de control de este gráfico",
"Replay": "Reproducir",
"Reset": "Restablecer",
"Reset Zoom": "Restablecer zoom",
"ResettingJam": "ReiniciarJam",
"Restart at end": "Reinicio al final",
"Run Action": "Ejecutar acción",
"Run this control point action now": "Ejecute ahora esta acción de punto de control",
"SP": "SP",
"SP CMD Addr:": "SP CMD Addr:",
"SP:": "SP:",
"STA Gateway": "Pasarela STA",
"STA IP Address": "Dirección IP STA",
"STA Password": "Contraseña STA",
"STA Primary DNS": "STA DNS primario",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA DNS secundario",
"STA Subnet Mask": "Máscara de subred STA",
"STALLED": "BLOQUEADO",
"Samplesignalplot 0": "Gráfico de señal de muestreo 0",
"Save AP Settings": "Guardar configuración AP",
"Save All Settings": "Guardar todos los ajustes",
"Save As": "Guardar como",
"Save STA Settings": "Guardar ajustes STA",
"Save Signal Plot": "Guardar trazado de señal",
"Scale": "Escala",
"Scale:": "Escala:",
"Search...": "Buscar...",
"Select Known Coil...": "Seleccionar bobina conocida...",
"Select a control point to see its properties.": "Seleccione un punto de control para ver sus propiedades.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Seleccione una parcela de destino. El contenido de \"{plotName}\" sobrescribirá la parcela seleccionada. Esta acción no puede deshacerse.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Seleccione un perfil de destino. El contenido de \"{nombredelperfil}\" sobrescribirá el perfil seleccionado. Esta acción no se puede deshacer.",
"Select a plot to overwrite": "Seleccione una parcela para sobrescribir",
"Select a profile to overwrite": "Seleccione un perfil para sobrescribir",
"Select a register or coil address": "Seleccione una dirección de registro o de bobina",
"Select a signal plot to associate and edit": "Seleccione un trazado de señal para asociar y editar",
"Select source...": "Seleccionar fuente...",
"Select type": "Seleccione el tipo",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Los perfiles hijos seleccionados se iniciarán, detendrán, pausarán y reanudarán con este perfil padre.",
"Send IFTTT Notification": "Enviar notificación IFTTT",
"Sequential Heating": "Calentamiento secuencial",
"Sequential Heating Control": "Control de calefacción secuencial",
"Series": "Serie",
"Series Toggles": "Interruptores de serie",
"Series settings": "Ajustes de la serie",
"Set All": "Fijar todo",
"Set All SP": "Fijar todo SP",
"Set as Default": "Fijar por defecto",
"Settings": "Ajustes",
"Settings...": "Ajustes...",
"Shortplot 70s": "Trama corta 70s",
"Show Legend": "Mostrar leyenda",
"Show PV": "Mostrar PV",
"Show SP": "Mostrar SP",
"Signal Control Point Details": "Detalles del punto de control de señales",
"Signal Plot Editor": "Editor de trazados de señales",
"Signal plots configuration loaded from API.": "Configuración de trazados de señales cargada desde la API.",
"Signalplot 922 Slot 2": "Signalplot 922 Ranura 2",
"Signalplot 923 Slot 3": "Signalplot 923 Ranura 3",
"Signals": "Señales",
"Slave Mode": "Modo esclavo",
"Slave:": "Esclavo:",
"Slaves": "Esclavos",
"Slot": "Ranura",
"Slot:": "Ranura:",
"Source": "Fuente",
"Start": "Inicio",
"Start Index": "Inicio Índice",
"Start PID Controllers": "Iniciar controladores PID",
"Start Profile": "Iniciar perfil",
"State:": "Estado:",
"Station (STA) Mode": "Modo Estación (STA)",
"Stop": "Stop",
"Stop PID Controllers": "Detener reguladores PID",
"Stop Profile": "Detener Perfil",
"Stop and reset": "Parar y reiniciar",
"Stop at end": "Parada al final",
"Stopped": "Detenido",
"Stopping": "Detener",
"Switch to edit mode to add containers": "Cambiar al modo de edición para añadir contenedores",
"Switch to edit mode to add widgets": "Cambiar al modo de edición para añadir widgets",
"System Calls": "Llamadas al sistema",
"System Information": "Información del sistema",
"System Messages": "Mensajes del sistema",
"Target Controllers (Registers)": "Controladores de destino (registros)",
"Temperature Control Points": "Puntos de control de temperatura",
"Temperature Profiles": "Perfiles de temperatura",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Este nombre de host se utiliza tanto para los modos STA como AP. Los cambios aquí se guardarán con cualquiera de los dos modos.",
"This is where you'll design and configure your HMI layouts.": "Aquí es donde diseñará y configurará sus diseños de HMI.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Esto borrará permanentemente el perfil \"{profileName}\" del servidor. Esta acción no se puede deshacer.",
"Time:": "Hora:",
"Timeline:": "Calendario:",
"Title (Optional)": "Título (opcional)",
"Total": "Total",
"Total Cost": "Coste total",
"Total:": "Total:",
"Type:": "Tipo:",
"Unknown": "Desconocido",
"Update Profile": "Actualizar perfil",
"Upload": "Cargar",
"Upload All JSON": "Cargar todo el JSON",
"Upload JSON for {name}": "Subir JSON para {nombre}",
"Upload Plot": "Cargar parcela",
"User Defined": "Definido por el usuario",
"Value:": "Valor:",
"View": "Ver",
"View mode: Interact with your widgets": "Modo de visualización: Interactúa con tus widgets",
"Visible Controllers": "Controladores visibles",
"Watched Items": "Artículos vigilados",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Cuando se activa el modo esclavo, todos los controladores Omron se desactivarán para el procesamiento.",
"Widget editor and drag-and-drop functionality coming soon...": "Próximamente, editor de widgets y función de arrastrar y soltar...",
"Widgets": "Widgets",
"Window (min)": "Ventana (min)",
"Window Offset": "Desplazamiento de la ventana",
"Write Coil": "Bobina de escritura",
"Write GPIO": "Escribir GPIO",
"Write Holding Register": "Registro de retención de escritura",
"X-Axis": "Eje X",
"Y-Axis Left": "Eje Y Izquierda",
"accel": "accel",
"decel": "decel",
"e.g., Start Heating": "p. ej., Iniciar calefacción",
"e.g., Turn on coil for pre-heating stage": "p. ej., encender la bobina para la fase de precalentamiento",
"err": "err",
"fwd": "fwd",
"in seconds": "en segundos",
"info": "información",
"none": "ninguno",
"reset": "reiniciar",
"reset_fault": "reset_fault",
"rev": "rev",
"run": "ejecute",
"setup": "configuración",
"stop": "stop",
"Loading comments...": "Cargando comentarios...",
"Edit with AI Wizard": "Editar con el Asistente AI",
"Be the first to like this": "Sé el primero en que te guste",
"Versions": "Versiones",
"Current": "Actual",
"Add a comment...": "Añade un comentario...",
"Post Comment": "Publicar comentario",
"No comments yet": "Aún no hay comentarios",
"Be the first to comment!": "¡Sé el primero en comentar!",
"Save": "Guardar",
"likes": "le gusta",
"like": "como",
"Prompt Templates": "Plantillas",
"Optimize prompt with AI": "Optimizar la rapidez con IA",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Describe la imagen que quieres crear o editar... (Ctrl+V para pegar imágenes)",
"e.g. Cyberpunk Portrait": "por ejemplo, Cyberpunk Portrait",
"Prompt": "Pregunte a",
"Templates": "Plantillas",
"Optimize": "Optimice",
"Selected Images": "Imágenes seleccionadas",
"Upload Images": "Cargar imágenes",
"Choose Files": "Elegir archivos",
"No images selected": "No hay imágenes seleccionadas",
"Upload images or select from gallery": "Sube imágenes o selecciónalas de la galería",
"No templates saved yet": "Aún no hay plantillas guardadas",
"Save current as template": "Guardar actual como plantilla",
"Loading profile...": "Cargando perfil...",
"Back to feed": "Volver a la alimentación",
"Create Post": "Crear puesto",
"posts": "puestos",
"followers": "seguidores",
"following": "siguiente",
"Joined": "Se unió a",
"Collections": "Colecciones",
"New": "Nuevo",
"POSTS": "PUESTOS",
"HIDDEN": "OCULTA",
"Profile picture": "Foto de perfil",
"your.email@example.com": "your.email@example.com",
"Enter username": "Introducir nombre de usuario",
"Enter display name": "Introduzca el nombre para mostrar",
"Tell us about yourself...": "Háblenos de usted...",
"Change Avatar": "Cambiar avatar",
"Email": "Correo electrónico",
"Username": "Nombre de usuario",
"Display Name": "Mostrar nombre",
"Bio": "Bio",
"Your preferred language for the interface": "Su idioma preferido para la interfaz",
"Save Changes": "Guardar cambios",
"Edit Picture": "Editar imagen",
"Edit Details": "Editar detalles",
"Generate Title & Description with AI": "Generar título y descripción con IA",
"Enter a title...": "Introduzca un título...",
"Record audio": "Grabar audio",
"Describe your photo... You can use **markdown** formatting!": "Describe tu foto... ¡Puedes utilizar el formato **markdown**!",
"Description (Optional)": "Descripción (opcional)",
"Visible": "Visible",
"Make this picture visible to others": "Haz que esta foto sea visible para los demás",
"Update": "Actualización",
"Loading versions...": "Cargando versiones...",
"No other versions available for this image.": "No hay otras versiones disponibles para esta imagen."
}

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Recherchez des images, des utilisateurs, des collections...",
"Search": "Recherche",
"AI Image Generator": "Générateur d'images AI",
"Language": "Langue",
"Sign in": "S'inscrire",
"Loading...": "Chargement...",
"My Profile": "Mon profil",
"Enter your Google API key": "Entrez votre clé Google API",
"Enter your OpenAI API key": "Entrez votre clé API OpenAI",
"General": "Général",
"Organizations": "Organisations",
"API Keys": "Clés API",
"Profile": "Profil",
"Gallery": "Galerie",
"Profile Settings": "Paramètres du profil",
"Manage your account settings and preferences": "Gérer les paramètres et les préférences de votre compte",
"Google API Key": "Clé API Google",
"For Google services (stored securely)": "Pour les services Google (stockés en toute sécurité)",
"OpenAI API Key": "Clé API OpenAI",
"For AI image generation (stored securely)": "Pour la génération d'images d'IA (stockées en toute sécurité)",
"Save API Keys": "Sauvegarder les clés API",
"AP Gateway": "Passerelle AP",
"AP IP Address": "Adresse IP de l'AP",
"AP Password": "Mot de passe AP",
"AP SSID": "AP SSID",
"AP Subnet Mask": "Masque de sous-réseau de l'AP",
"API URL": "URL DE L'API",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO MULTI BALANCED",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BALANCED",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Mode point d'accès (AP)",
"Add Container": "Ajouter un conteneur",
"Add Samples": "Ajouter des échantillons",
"Add Slave": "Ajouter un esclave",
"Add Widget": "Ajouter un widget",
"Add a set of sample control points to this plot": "Ajouter un ensemble de points de contrôle de l'échantillon à ce tracé",
"Add all": "Ajouter tout",
"Addr:": "Addr :",
"Address Picker": "Sélecteur d'adresses",
"Advanced": "Avancé",
"All Stop": "Tous les arrêts",
"Apply": "Appliquer",
"Argument 0:": "Argument 0 :",
"Argument 1:": "Argument 1 :",
"Argument 2 (Optional):": "Argument 2 (facultatif) :",
"Arguments:": "Arguments :",
"Associated Controllers:": "Contrôleurs associés :",
"Associated Signal Plot (Optional)": "Tracé du signal associé (optionnel)",
"Aux": "Aux",
"BALANCE": "ÉQUILIBRE",
"BALANCE_MAX_DIFF": "BALANCE_MAX_DIFF",
"Buzzer": "Buzzer",
"Buzzer: Fast Blink": "Buzzer : Clignotement rapide",
"Buzzer: Long Beep/Short Pause": "Buzzer : Bip long/Pause courte",
"Buzzer: Off": "Buzzer : Désactivé",
"Buzzer: Slow Blink": "Buzzer : Clignotement lent",
"Buzzer: Solid On": "Buzzer : Allumé en permanence",
"CE": "CE",
"COM Write": "COM Écriture",
"CP Description (Optional):": "CP Description (facultatif) :",
"CP Name (Optional):": "Nom du CP (facultatif) :",
"CSV": "CSV",
"Call Function": "Appeler la fonction",
"Call Method": "Méthode d'appel",
"Call REST API": "Appeler l'API REST",
"Cancel": "Annuler",
"Carina": "Carina",
"Cassandra Left": "Cassandra Left",
"Cassandra Right": "Cassandra Right",
"Castor": "Castor",
"Cetus": "Cetus",
"Charts": "Graphiques",
"Child Profiles (Sub-plots)": "Profils d'enfants (sous-intrigues)",
"Clear": "Clair",
"Clear All": "Tout effacer",
"Clear All CPs": "Effacer tous les CP",
"Clear Chart": "Graphique clair",
"Click \"Add Container\" to start building your layout": "Cliquez sur \"Ajouter un conteneur\" pour commencer à construire votre modèle",
"Click \"Add Widget\" to start building your HMI": "Cliquez sur \"Ajouter un widget\" pour commencer à construire votre IHM",
"Coil to Write:": "La bobine pour écrire :",
"Coils": "Bobines",
"Color": "Couleur",
"Coma B": "Coma B",
"Commons": "Communes",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Configurez le nouveau point de contrôle. Appuyez sur Enter pour confirmer ou sur Esc pour annuler.",
"Configure the series to be displayed on the chart.": "Configurez la série à afficher sur le graphique.",
"Connect": "Connecter",
"Connect to a Modbus server to see controller data.": "Se connecter à un serveur Modbus pour consulter les données du contrôleur.",
"Connect to view register data.": "Se connecter pour visualiser les données du registre.",
"Connected, but no register data received yet. Waiting for data...": "Connecté, mais aucune donnée de registre n'a encore été reçue. En attente de données...",
"Connects to an existing Wi-Fi network.": "Se connecte à un réseau Wi-Fi existant.",
"Containers": "Conteneurs",
"Continue": "Continuer",
"Control Points": "Points de contrôle",
"Control Points List": "Liste des points de contrôle",
"Controller Chart": "Tableau des contrôleurs",
"Controller Partitions": "Partitions du contrôleur",
"Copy \"{plotName}\" to...": "Copier \"{nom du graphe}\" dans...",
"Copy \"{profileName}\" to...": "Copier \"{nomduprofil}\" dans...",
"Copy this plot to another slot...": "Copier cette parcelle dans un autre emplacement...",
"Copy to existing slot...": "Copier dans un emplacement existant...",
"Copy to...": "Copier sur...",
"Copy...": "Copier...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Cratère",
"Create Control Point": "Créer un point de contrôle",
"Create New Control Point": "Créer un nouveau point de contrôle",
"Creates its own Wi-Fi network.": "Crée son propre réseau Wi-Fi.",
"Crux": "Crux",
"Current Status": "Statut actuel",
"Custom Widgets": "Widgets personnalisés",
"DEC": "DEC",
"Dashboard": "Tableau de bord",
"Delete": "Supprimer",
"Delete Profile": "Supprimer le profil",
"Delete control point": "Supprimer le point de contrôle",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Description",
"Device Hostname": "Nom d'hôte du dispositif",
"Disable All": "Désactiver tout",
"Disconnect": "Déconnexion",
"Display Message": "Message d'affichage",
"Download": "Télécharger",
"Download All JSON": "Télécharger tous les JSON",
"Download English Translations": "Télécharger les traductions anglaises",
"Download JSON for {name}": "Télécharger le JSON pour {nom}",
"Download Plot": "Télécharger la parcelle",
"Drag and resize widgets": "Glisser et redimensionner les widgets",
"Duplicate Profile": "Profil en double",
"Duration (hh:mm:ss)": "Durée (hh:mm:ss)",
"Duration:": "Durée de l'enquête :",
"E.g., Quick Ramp Up": "Par exemple, une montée en puissance rapide",
"ERROR": "ERREUR",
"Edit": "Editer",
"Edit Profile": "Modifier le profil",
"Edit mode: Add, move, and configure widgets": "Mode édition : Ajouter, déplacer et configurer des widgets",
"Edit mode: Configure containers and add widgets": "Mode édition : Configurer les conteneurs et ajouter des widgets",
"Empty Canvas": "Toile vide",
"Empty Layout": "Mise en page vide",
"Enable All": "Activer tout",
"Enable control unavailable for {name}": "Activer le contrôle indisponible pour {nom}",
"Enabled": "Activé",
"End Index": "Index de fin",
"Enter CP description": "Saisir la description de la PC",
"Enter CP name": "Saisir le nom du CP",
"Export": "Exportation",
"Export JSON": "Exporter JSON",
"Export to CSV": "Exporter vers CSV",
"Favorite Coils": "Bobines préférées",
"Favorite Registers": "Registres préférés",
"Favorites": "Favoris",
"File name": "Nom du fichier",
"Fill": "Remplir",
"Filling": "Remplissage",
"General Settings": "Paramètres généraux",
"Global Settings": "Paramètres globaux",
"HEX": "HEX",
"HMI Edit Mode Active": "Mode d'édition de l'IHM actif",
"Hardware I/O": "E/S matérielles",
"Heating Time": "Temps de chauffage",
"Help": "Aide",
"Home": "Accueil",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Nom d'hôte",
"ID:": "ID :",
"IDLE": "IDLE",
"Idle": "Au repos",
"Import": "Importation",
"Import JSON": "Importer JSON",
"Info": "Info",
"Integrations": "Intégrations",
"Interlocked": "Enchevêtrés",
"Jammed": "Bloqué",
"Joystick": "Manette",
"LOADCELL": "LOADCELL",
"Last updated": "Dernière mise à jour",
"Loadcell[25]": "Capteur de charge[25]",
"Loadcell[26]": "Capteur de charge[26]",
"Loading Cassandra settings...": "Chargement des paramètres de Cassandra...",
"Loading network settings...": "Chargement des paramètres réseau...",
"Loading profiles from Modbus...": "Chargement des profils de Modbus...",
"Logs": "Journaux",
"Low": "Faible",
"MANUAL": "MANUEL",
"MANUAL MULTI": "MANUEL MULTI",
"MANUAL_MULTI": "MANUEL_MULTI",
"MAXLOAD": "CHARGE MAXIMALE",
"MAX_TIME": "MAX_TIME",
"MINLOAD": "CHARGE MIN",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Gérer les dispositifs esclaves (1 au maximum).",
"Markdown": "Markdown",
"Master Configuration": "Configuration principale",
"Master Name": "Nom du maître",
"Max": "Max",
"Max Simultaneous": "Maximale simultanée",
"Mid": "Moyen",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Mode",
"Move control point down": "Déplacer le point de contrôle vers le bas",
"Move control point up": "Déplacer le point de contrôle vers le haut",
"N/A": "N/A",
"NONE": "AUCUN",
"Network": "Réseau",
"Network Settings": "Paramètres du réseau",
"No Operation": "Pas d'opération",
"No coils data available. Try refreshing.": "Aucune donnée sur les bobines n'est disponible. Essayez d'actualiser.",
"No containers yet": "Pas encore de conteneurs",
"No enabled profile": "Pas de profil activé",
"No register data available. Try refreshing.": "Aucune donnée de registre disponible. Essayer de rafraîchir.",
"No source found.": "Aucune source n'a été trouvée.",
"No widgets found": "Aucun widget trouvé",
"No widgets yet": "Pas encore de widgets",
"None": "Aucun",
"OC": "OC",
"OFFLINE": "HORS LIGNE",
"OK": "OK",
"OL": "LO",
"ON": "ON",
"ONLINE": "EN LIGNE",
"OV": "OV",
"OVERLOAD": "SURCHARGE",
"Offset": "Décalage",
"Operatorswitch": "Commutateur de l'opérateur",
"PID Control": "Contrôle PID",
"PV": "PV",
"Partitions": "Cloisons",
"Pause": "Pause",
"Pause Profile": "Pause Profil",
"Phapp": "Phapp",
"Play from start": "Jouer depuis le début",
"Playground": "Terrain de jeux",
"Plunge": "Plongée",
"Plunger": "Plongeur",
"PlungingAuto": "PlongeantAuto",
"PlungingMan": "L'homme en plongée",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "Pop-out",
"PostFlow": "PostFlow",
"Press": "Presse",
"Press Cylinder": "Cylindre de presse",
"Press Cylinder Controls": "Contrôle des cylindres de presse",
"Presscylinder": "Presscylindre",
"Profile Curves": "Courbes de profil",
"Profile Name": "Nom du profil",
"Profile SP": "Profil SP",
"Profiles": "Profils",
"Properties:": "Propriétés :",
"REMOTE": "REMOTE",
"Real time Charting": "Graphiques en temps réel",
"Real-time Charts": "Graphiques en temps réel",
"Record": "Enregistrer",
"Refresh Rate": "Taux de rafraîchissement",
"Registers": "Registres",
"Remove all": "Supprimer tout",
"Remove all control points from this plot": "Supprimer tous les points de contrôle de ce tracé",
"Replay": "Replay",
"Reset": "Remise à zéro",
"Reset Zoom": "Réinitialiser le zoom",
"ResettingJam": "Réinitialisation du blocage",
"Restart at end": "Redémarrage à la fin",
"Run Action": "Exécuter l'action",
"Run this control point action now": "Exécuter cette action de point de contrôle maintenant",
"SP": "SP",
"SP CMD Addr:": "SP CMD Addr :",
"SP:": "SP :",
"STA Gateway": "Passerelle STA",
"STA IP Address": "Adresse IP de la STA",
"STA Password": "Mot de passe STA",
"STA Primary DNS": "STA DNS primaire",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA DNS secondaire",
"STA Subnet Mask": "STA Masque de sous-réseau",
"STALLED": "STALLED",
"Samplesignalplot 0": "Diagramme de signaux d'échantillonnage 0",
"Save AP Settings": "Sauvegarder les paramètres de l'AP",
"Save All Settings": "Sauvegarder tous les paramètres",
"Save As": "Enregistrer sous",
"Save STA Settings": "Sauvegarder les paramètres de la STA",
"Save Signal Plot": "Sauvegarder le tracé du signal",
"Scale": "Échelle",
"Scale:": "Échelle :",
"Search...": "Recherche...",
"Select Known Coil...": "Sélectionner la bobine connue...",
"Select a control point to see its properties.": "Sélectionnez un point de contrôle pour afficher ses propriétés.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Sélectionnez une parcelle de destination. Le contenu de \"{nom du graphe}\" remplacera le graphe sélectionné. Cette action ne peut être annulée.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Sélectionnez un profil de destination. Le contenu de \"{nomduprofil}\" remplacera le profil sélectionné. Cette action ne peut être annulée.",
"Select a plot to overwrite": "Sélectionner une parcelle à écraser",
"Select a profile to overwrite": "Sélectionner un profil à écraser",
"Select a register or coil address": "Sélection d'un registre ou d'une adresse de bobine",
"Select a signal plot to associate and edit": "Sélectionner un tracé de signal à associer et à éditer",
"Select source...": "Sélectionner la source...",
"Select type": "Sélectionner le type",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Les profils enfants sélectionnés démarrent, s'arrêtent, se mettent en pause et reprennent avec ce profil parent.",
"Send IFTTT Notification": "Envoyer une notification IFTTT",
"Sequential Heating": "Chauffage séquentiel",
"Sequential Heating Control": "Contrôle séquentiel du chauffage",
"Series": "Série",
"Series Toggles": "Série Toggles",
"Series settings": "Paramètres de la série",
"Set All": "Tout régler",
"Set All SP": "Set All SP",
"Set as Default": "Définir par défaut",
"Settings": "Paramètres",
"Settings...": "Paramètres...",
"Shortplot 70s": "Raccourci 70s",
"Show Legend": "Afficher la légende",
"Show PV": "Afficher le PV",
"Show SP": "Spectacle SP",
"Signal Control Point Details": "Détails des points de contrôle des signaux",
"Signal Plot Editor": "Éditeur de tracés de signaux",
"Signal plots configuration loaded from API.": "Configuration des tracés de signaux chargée à partir de l'API.",
"Signalplot 922 Slot 2": "Signalplot 922 Slot 2",
"Signalplot 923 Slot 3": "Signalplot 923 Slot 3",
"Signals": "Signaux",
"Slave Mode": "Mode esclave",
"Slave:": "Esclave :",
"Slaves": "Esclaves",
"Slot": "Fente",
"Slot:": "Crémaillère :",
"Source": "Source",
"Start": "Démarrage",
"Start Index": "Index de départ",
"Start PID Controllers": "Démarrer les contrôleurs PID",
"Start Profile": "Démarrer le profil",
"State:": "État :",
"Station (STA) Mode": "Mode station (STA)",
"Stop": "Arrêter",
"Stop PID Controllers": "Arrêter les contrôleurs PID",
"Stop Profile": "Profil d'arrêt",
"Stop and reset": "Arrêt et réinitialisation",
"Stop at end": "Arrêter à la fin",
"Stopped": "Arrêtée",
"Stopping": "Arrêter",
"Switch to edit mode to add containers": "Passer en mode édition pour ajouter des conteneurs",
"Switch to edit mode to add widgets": "Passer en mode édition pour ajouter des widgets",
"System Calls": "Appels du système",
"System Information": "Informations sur le système",
"System Messages": "Messages du système",
"Target Controllers (Registers)": "Contrôleurs cibles (registres)",
"Temperature Control Points": "Points de contrôle de la température",
"Temperature Profiles": "Profils de température",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Ce nom d'hôte est utilisé pour les modes STA et AP. Les modifications apportées ici seront enregistrées sous l'une ou l'autre forme.",
"This is where you'll design and configure your HMI layouts.": "C'est ici que vous concevrez et configurerez vos schémas d'IHM.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Cette action efface définitivement le profil \"{nomduprofil}\" du serveur. Cette action ne peut pas être annulée.",
"Time:": "Le temps :",
"Timeline:": "Calendrier :",
"Title (Optional)": "Titre (facultatif)",
"Total": "Total",
"Total Cost": "Coût total",
"Total:": "Total :",
"Type:": "Type :",
"Unknown": "Inconnu",
"Update Profile": "Mise à jour du profil",
"Upload": "Télécharger",
"Upload All JSON": "Télécharger tous les JSON",
"Upload JSON for {name}": "Télécharger le JSON pour {nom}",
"Upload Plot": "Télécharger le tracé",
"User Defined": "Défini par l'utilisateur",
"Value:": "Valeur :",
"View": "Voir",
"View mode: Interact with your widgets": "Mode d'affichage : Interagir avec vos widgets",
"Visible Controllers": "Contrôleurs visibles",
"Watched Items": "Articles surveillés",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Lorsque le mode esclave est activé, tous les contrôleurs Omron sont désactivés pour le traitement.",
"Widget editor and drag-and-drop functionality coming soon...": "L'éditeur de widgets et la fonctionnalité \"glisser-déposer\" seront bientôt disponibles...",
"Widgets": "Widgets",
"Window (min)": "Fenêtre (min)",
"Window Offset": "Décalage de la fenêtre",
"Write Coil": "Bobine d'écriture",
"Write GPIO": "Écriture GPIO",
"Write Holding Register": "Écriture du registre de maintien",
"X-Axis": "Axe X",
"Y-Axis Left": "Axe Y gauche",
"accel": "accel",
"decel": "décélérer",
"e.g., Start Heating": "par exemple, Démarrer le chauffage",
"e.g., Turn on coil for pre-heating stage": "par exemple, allumer le serpentin pour la phase de préchauffage",
"err": "errer",
"fwd": "en avant",
"in seconds": "en secondes",
"info": "info",
"none": "aucun",
"reset": "réinitialiser",
"reset_fault": "défaut_réinitialisation",
"rev": "réviser",
"run": "courir",
"setup": "configuration",
"stop": "arrêter",
"Loading comments...": "Chargement des commentaires...",
"Edit with AI Wizard": "Modifier avec AI Wizard",
"Be the first to like this": "Soyez le premier à aimer ce produit",
"Versions": "Versions",
"Current": "Actuel",
"Add a comment...": "Ajouter un commentaire...",
"Post Comment": "Poster un commentaire",
"No comments yet": "Pas encore de commentaires",
"Be the first to comment!": "Soyez le premier à commenter !",
"Save": "Économiser",
"likes": "aime",
"like": "comme",
"Prompt Templates": "Modèles d'invites",
"Optimize prompt with AI": "Optimiser la rapidité d'exécution grâce à l'IA",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Décrivez l'image que vous souhaitez créer ou modifier... (Ctrl+V pour coller des images)",
"e.g. Cyberpunk Portrait": "par exemple, Cyberpunk Portrait",
"Prompt": "Prompt",
"Templates": "Modèles",
"Optimize": "Optimiser",
"Selected Images": "Images sélectionnées",
"Upload Images": "Télécharger des images",
"Choose Files": "Choisir les fichiers",
"No images selected": "Aucune image sélectionnée",
"Upload images or select from gallery": "Télécharger des images ou les sélectionner dans la galerie",
"No templates saved yet": "Aucun modèle n'a encore été enregistré",
"Save current as template": "Sauvegarder la version actuelle comme modèle",
"Loading profile...": "Chargement du profil...",
"Back to feed": "Retour à l'alimentation",
"Create Post": "Créer un poste",
"posts": "postes",
"followers": "suiveurs",
"following": "suivant",
"Joined": "Rejoint",
"Collections": "Collections",
"New": "Nouveau",
"POSTS": "POSTES",
"HIDDEN": "CACHÉ",
"Profile picture": "Photo de profil",
"your.email@example.com": "your.email@example.com",
"Enter username": "Saisir le nom d'utilisateur",
"Enter display name": "Saisir le nom d'affichage",
"Tell us about yourself...": "Parlez-nous de vous...",
"Change Avatar": "Changer d'avatar",
"Email": "Courriel",
"Username": "Nom d'utilisateur",
"Display Name": "Nom d'affichage",
"Bio": "Bio",
"Your preferred language for the interface": "Votre langue préférée pour l'interface",
"Save Changes": "Enregistrer les modifications",
"Edit Picture": "Modifier l'image",
"Edit Details": "Modifier les détails",
"Generate Title & Description with AI": "Générer des titres et des descriptions avec l'IA",
"Enter a title...": "Saisir un titre...",
"Record audio": "Enregistrement audio",
"Describe your photo... You can use **markdown** formatting!": "Décrivez votre photo... Vous pouvez utiliser le format **markdown** !",
"Description (Optional)": "Description (facultatif)",
"Visible": "Visible",
"Make this picture visible to others": "Rendre cette image visible aux autres",
"Update": "Mise à jour",
"Loading versions...": "Chargement des versions...",
"No other versions available for this image.": "Aucune autre version n'est disponible pour cette image."
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Cerca immagini, utenti, collezioni...",
"Search": "Ricerca",
"AI Image Generator": "Generatore di immagini AI",
"Language": "Lingua",
"Sign in": "Accedi",
"Loading...": "Caricamento...",
"My Profile": "Il mio profilo",
"Enter your Google API key": "Inserire la chiave API di Google",
"Enter your OpenAI API key": "Inserire la chiave API OpenAI",
"General": "Generale",
"Organizations": "Organizzazioni",
"API Keys": "Chiavi API",
"Profile": "Profilo",
"Gallery": "Galleria",
"Profile Settings": "Impostazioni del profilo",
"Manage your account settings and preferences": "Gestire le impostazioni e le preferenze dell'account",
"Google API Key": "Chiave API di Google",
"For Google services (stored securely)": "Per i servizi Google (memorizzati in modo sicuro)",
"OpenAI API Key": "Chiave API OpenAI",
"For AI image generation (stored securely)": "Per la generazione di immagini AI (archiviate in modo sicuro)",
"Save API Keys": "Salvare le chiavi API",
"AP Gateway": "Gateway AP",
"AP IP Address": "Indirizzo IP AP",
"AP Password": "Password AP",
"AP SSID": "SSID DELL'AP",
"AP Subnet Mask": "Maschera di sottorete AP",
"API URL": "URL API",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO MULTI BILANCIATO",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BILANCIATO",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Modalità punto di accesso (AP)",
"Add Container": "Aggiungere un contenitore",
"Add Samples": "Aggiungi campioni",
"Add Slave": "Aggiungi Slave",
"Add Widget": "Aggiungi widget",
"Add a set of sample control points to this plot": "Aggiungere una serie di punti di controllo campione a questo grafico",
"Add all": "Aggiungi tutti",
"Addr:": "Indirizzo:",
"Address Picker": "Scegliere l'indirizzo",
"Advanced": "Avanzato",
"All Stop": "Tutti gli stop",
"Apply": "Applicare",
"Argument 0:": "Argomento 0:",
"Argument 1:": "Argomento 1:",
"Argument 2 (Optional):": "Argomento 2 (facoltativo):",
"Arguments:": "Argomenti:",
"Associated Controllers:": "Controllori associati:",
"Associated Signal Plot (Optional)": "Traccia del segnale associato (opzionale)",
"Aux": "Aux",
"BALANCE": "EQUILIBRIO",
"BALANCE_MAX_DIFF": "EQUILIBRIO_MAX_DIFF",
"Buzzer": "Cicalino",
"Buzzer: Fast Blink": "Cicalino: Lampeggio veloce",
"Buzzer: Long Beep/Short Pause": "Cicalino: Segnale acustico lungo/pausa breve",
"Buzzer: Off": "Cicalino: Spento",
"Buzzer: Slow Blink": "Cicalino: Lampeggio lento",
"Buzzer: Solid On": "Cicalino: Acceso fisso",
"CE": "CE",
"COM Write": "Scrivere COM",
"CP Description (Optional):": "Descrizione del PC (opzionale):",
"CP Name (Optional):": "Nome del PC (facoltativo):",
"CSV": "CSV",
"Call Function": "Funzione di chiamata",
"Call Method": "Metodo di chiamata",
"Call REST API": "Chiamare l'API REST",
"Cancel": "Annullamento",
"Carina": "Carina",
"Cassandra Left": "Cassandra Sinistra",
"Cassandra Right": "Cassandra Giusto",
"Castor": "Castore",
"Cetus": "Cetus",
"Charts": "Grafici",
"Child Profiles (Sub-plots)": "Profili dei bambini (sottotrame)",
"Clear": "Libero",
"Clear All": "Cancella tutto",
"Clear All CPs": "Azzeramento di tutti i PC",
"Clear Chart": "Grafico chiaro",
"Click \"Add Container\" to start building your layout": "Fare clic su \"Aggiungi contenitore\" per iniziare a costruire il layout",
"Click \"Add Widget\" to start building your HMI": "Fate clic su \"Aggiungi widget\" per iniziare a costruire il vostro HMI",
"Coil to Write:": "Bobina da scrivere:",
"Coils": "Bobine",
"Color": "Colore",
"Coma B": "Coma B",
"Commons": "Comuni",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Configurare il nuovo punto di controllo. Premere Invio per confermare o Esc per annullare.",
"Configure the series to be displayed on the chart.": "Configurare le serie da visualizzare nel grafico.",
"Connect": "Collegare",
"Connect to a Modbus server to see controller data.": "Collegarsi a un server Modbus per visualizzare i dati del controllore.",
"Connect to view register data.": "Collegarsi per visualizzare i dati del registro.",
"Connected, but no register data received yet. Waiting for data...": "Connesso, ma non sono ancora stati ricevuti dati di registro. In attesa di dati...",
"Connects to an existing Wi-Fi network.": "Si collega a una rete Wi-Fi esistente.",
"Containers": "Contenitori",
"Continue": "Continua",
"Control Points": "Punti di controllo",
"Control Points List": "Elenco dei punti di controllo",
"Controller Chart": "Grafico del controllore",
"Controller Partitions": "Partizioni del controllore",
"Copy \"{plotName}\" to...": "Copiare \"{plotName}\" in...",
"Copy \"{profileName}\" to...": "Copiare \"{nomeprofilo}\" in...",
"Copy this plot to another slot...": "Copia questa trama in un altro slot...",
"Copy to existing slot...": "Copia nello slot esistente...",
"Copy to...": "Copia a...",
"Copy...": "Copia...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Cratere",
"Create Control Point": "Creare un punto di controllo",
"Create New Control Point": "Creare un nuovo punto di controllo",
"Creates its own Wi-Fi network.": "Crea la propria rete Wi-Fi.",
"Crux": "Crux",
"Current Status": "Stato attuale",
"Custom Widgets": "Widget personalizzati",
"DEC": "DEC",
"Dashboard": "Cruscotto",
"Delete": "Cancellare",
"Delete Profile": "Cancellare il profilo",
"Delete control point": "Cancellare il punto di controllo",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Descrizione",
"Device Hostname": "Nome host del dispositivo",
"Disable All": "Disattivare tutti",
"Disconnect": "Disconnessione",
"Display Message": "Messaggio sul display",
"Download": "Scaricare",
"Download All JSON": "Scarica tutti i JSON",
"Download English Translations": "Scarica le traduzioni in inglese",
"Download JSON for {name}": "Scarica JSON per {nome}",
"Download Plot": "Scarica la trama",
"Drag and resize widgets": "Trascinare e ridimensionare i widget",
"Duplicate Profile": "Profilo duplicato",
"Duration (hh:mm:ss)": "Durata (hh:mm:ss)",
"Duration:": "Durata:",
"E.g., Quick Ramp Up": "Ad esempio, accelerazione rapida",
"ERROR": "ERRORE",
"Edit": "Modifica",
"Edit Profile": "Modifica profilo",
"Edit mode: Add, move, and configure widgets": "Modalità di modifica: Aggiungere, spostare e configurare i widget",
"Edit mode: Configure containers and add widgets": "Modalità di modifica: Configurare i contenitori e aggiungere widget",
"Empty Canvas": "Tela vuota",
"Empty Layout": "Layout vuoto",
"Enable All": "Abilitazione di tutti",
"Enable control unavailable for {name}": "Abilita il controllo non disponibile per {nome}",
"Enabled": "Abilitato",
"End Index": "Indice finale",
"Enter CP description": "Inserire la descrizione del PC",
"Enter CP name": "Inserire il nome del PC",
"Export": "Esportazione",
"Export JSON": "Esportazione JSON",
"Export to CSV": "Esportazione in CSV",
"Favorite Coils": "Bobine preferite",
"Favorite Registers": "Registri preferiti",
"Favorites": "Preferiti",
"File name": "Nome del file",
"Fill": "Riempimento",
"Filling": "Riempimento",
"General Settings": "Impostazioni generali",
"Global Settings": "Impostazioni globali",
"HEX": "ESADECIMALE",
"HMI Edit Mode Active": "Modalità di modifica HMI attiva",
"Hardware I/O": "Hardware I/O",
"Heating Time": "Tempo di riscaldamento",
"Help": "Aiuto",
"Home": "Casa",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Nome host",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Inattivo",
"Import": "Importazione",
"Import JSON": "Importazione di JSON",
"Info": "Info",
"Integrations": "Integrazioni",
"Interlocked": "Interbloccati",
"Jammed": "Inceppato",
"Joystick": "Joystick",
"LOADCELL": "CELLA DI CARICO",
"Last updated": "Ultimo aggiornamento",
"Loadcell[25]": "Cella di carico[25]",
"Loadcell[26]": "Cella di carico[26]",
"Loading Cassandra settings...": "Caricamento delle impostazioni di Cassandra...",
"Loading network settings...": "Caricamento delle impostazioni di rete...",
"Loading profiles from Modbus...": "Caricamento dei profili da Modbus...",
"Logs": "Registri",
"Low": "Basso",
"MANUAL": "MANUALE",
"MANUAL MULTI": "MULTI MANUALE",
"MANUAL_MULTI": "MANUALE_MULTI",
"MAXLOAD": "CARICO MASSIMO",
"MAX_TIME": "TEMPO MASSIMO",
"MINLOAD": "CARICO MINIMO",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Gestire i dispositivi slave (max 1).",
"Markdown": "Markdown",
"Master Configuration": "Configurazione master",
"Master Name": "Nome del master",
"Max": "Massimo",
"Max Simultaneous": "Massima simultaneità",
"Mid": "Medio",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Modalità",
"Move control point down": "Spostare il punto di controllo verso il basso",
"Move control point up": "Spostare il punto di controllo verso l'alto",
"N/A": "N/D",
"NONE": "NESSUNO",
"Network": "Rete",
"Network Settings": "Impostazioni di rete",
"No Operation": "Nessuna operazione",
"No coils data available. Try refreshing.": "Non sono disponibili dati sulle bobine. Prova a rinfrescare.",
"No containers yet": "Non ci sono ancora contenitori",
"No enabled profile": "Nessun profilo abilitato",
"No register data available. Try refreshing.": "Non ci sono dati di registro disponibili. Provare ad aggiornare.",
"No source found.": "Nessuna fonte trovata.",
"No widgets found": "Nessun widget trovato",
"No widgets yet": "Non ci sono ancora widget",
"None": "Nessuno",
"OC": "OC",
"OFFLINE": "OFFLINE",
"OK": "OK",
"OL": "OL",
"ON": "ON",
"ONLINE": "ONLINE",
"OV": "OV",
"OVERLOAD": "SOVRACCARICO",
"Offset": "Offset",
"Operatorswitch": "Interruttore operatore",
"PID Control": "Controllo PID",
"PV": "PV",
"Partitions": "Divisori",
"Pause": "Pausa",
"Pause Profile": "Profilo di pausa",
"Phapp": "Phapp",
"Play from start": "Giocare dall'inizio",
"Playground": "Parco giochi",
"Plunge": "Tuffo",
"Plunger": "Stantuffo",
"PlungingAuto": "TuffoAuto",
"PlungingMan": "Uomo che si tuffa",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "A scomparsa",
"PostFlow": "PostFlow",
"Press": "Stampa",
"Press Cylinder": "Cilindro di stampa",
"Press Cylinder Controls": "Controlli del cilindro della pressa",
"Presscylinder": "Cilindro a pressione",
"Profile Curves": "Curve del profilo",
"Profile Name": "Nome del profilo",
"Profile SP": "Profilo SP",
"Profiles": "Profili",
"Properties:": "Proprietà:",
"REMOTE": "REMOTO",
"Real time Charting": "Grafici in tempo reale",
"Real-time Charts": "Grafici in tempo reale",
"Record": "Record",
"Refresh Rate": "Frequenza di aggiornamento",
"Registers": "Registri",
"Remove all": "Rimuovi tutto",
"Remove all control points from this plot": "Rimuovere tutti i punti di controllo da questo grafico",
"Replay": "Riproduzione",
"Reset": "Reset",
"Reset Zoom": "Azzeramento dello zoom",
"ResettingJam": "Azzeramento dell'inceppamento",
"Restart at end": "Riavvio alla fine",
"Run Action": "Eseguire l'azione",
"Run this control point action now": "Eseguire ora l'azione del punto di controllo",
"SP": "SP",
"SP CMD Addr:": "SP CMD Addr:",
"SP:": "SP:",
"STA Gateway": "Gateway STA",
"STA IP Address": "Indirizzo IP STA",
"STA Password": "Password STA",
"STA Primary DNS": "STA DNS primario",
"STA SSID": "SSID STA",
"STA Secondary DNS": "STA DNS secondario",
"STA Subnet Mask": "Maschera di sottorete STA",
"STALLED": "STALLATO",
"Samplesignalplot 0": "Grafico del segnale dei campioni 0",
"Save AP Settings": "Salvare le impostazioni AP",
"Save All Settings": "Salva tutte le impostazioni",
"Save As": "Salva con nome",
"Save STA Settings": "Salvare le impostazioni STA",
"Save Signal Plot": "Salvare il grafico del segnale",
"Scale": "Scala",
"Scale:": "Scala:",
"Search...": "Ricerca...",
"Select Known Coil...": "Selezionare la bobina nota...",
"Select a control point to see its properties.": "Selezionare un punto di controllo per visualizzarne le proprietà.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Selezionare un plot di destinazione. Il contenuto di \"{plotName}\" sovrascriverà il plot selezionato. Questa azione non può essere annullata.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Selezionare un profilo di destinazione. Il contenuto di \"{profileName}\" sovrascriverà il profilo selezionato. Questa azione non può essere annullata.",
"Select a plot to overwrite": "Selezionare una trama da sovrascrivere",
"Select a profile to overwrite": "Selezionare un profilo da sovrascrivere",
"Select a register or coil address": "Selezionare un registro o un indirizzo di bobina",
"Select a signal plot to associate and edit": "Selezionare un grafico del segnale da associare e modificare",
"Select source...": "Selezionare la fonte...",
"Select type": "Selezionare il tipo",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "I profili figlio selezionati si avviano, si fermano, si mettono in pausa e riprendono con questo profilo genitore.",
"Send IFTTT Notification": "Invia una notifica IFTTT",
"Sequential Heating": "Riscaldamento sequenziale",
"Sequential Heating Control": "Controllo del riscaldamento sequenziale",
"Series": "Serie",
"Series Toggles": "Serie Toggles",
"Series settings": "Impostazioni della serie",
"Set All": "Imposta tutto",
"Set All SP": "Imposta tutti gli SP",
"Set as Default": "Imposta come predefinito",
"Settings": "Impostazioni",
"Settings...": "Impostazioni...",
"Shortplot 70s": "Trama breve 70s",
"Show Legend": "Mostra Legenda",
"Show PV": "Mostra PV",
"Show SP": "Mostra SP",
"Signal Control Point Details": "Dettagli del punto di controllo del segnale",
"Signal Plot Editor": "Editor di trame di segnale",
"Signal plots configuration loaded from API.": "Configurazione delle trame di segnale caricata dall'API.",
"Signalplot 922 Slot 2": "Signalplot 922 Slot 2",
"Signalplot 923 Slot 3": "Signalplot 923 Slot 3",
"Signals": "Segnali",
"Slave Mode": "Modalità Slave",
"Slave:": "Schiavo:",
"Slaves": "Schiavi",
"Slot": "Slot",
"Slot:": "Slot:",
"Source": "Fonte",
"Start": "Inizio",
"Start Index": "Indice di partenza",
"Start PID Controllers": "Avvio dei controllori PID",
"Start Profile": "Iniziare il profilo",
"State:": "Stato:",
"Station (STA) Mode": "Modalità stazione (STA)",
"Stop": "Stop",
"Stop PID Controllers": "Arresto dei controllori PID",
"Stop Profile": "Profilo dell'arresto",
"Stop and reset": "Arresto e ripristino",
"Stop at end": "Arresto alla fine",
"Stopped": "Interrotto",
"Stopping": "Arresto",
"Switch to edit mode to add containers": "Passare alla modalità di modifica per aggiungere i contenitori",
"Switch to edit mode to add widgets": "Passare alla modalità di modifica per aggiungere i widget",
"System Calls": "Chiamate di sistema",
"System Information": "Informazioni sul sistema",
"System Messages": "Messaggi di sistema",
"Target Controllers (Registers)": "Controllori di destinazione (registri)",
"Temperature Control Points": "Punti di controllo della temperatura",
"Temperature Profiles": "Profili di temperatura",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Questo hostname viene utilizzato sia per la modalità STA che per quella AP. Le modifiche apportate saranno salvate in entrambe le modalità.",
"This is where you'll design and configure your HMI layouts.": "Qui si progettano e configurano i layout HMI.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Questa operazione cancella definitivamente il profilo \"{profileName}\" dal server. Questa azione non può essere annullata.",
"Time:": "Tempo:",
"Timeline:": "Cronologia:",
"Title (Optional)": "Titolo (facoltativo)",
"Total": "Totale",
"Total Cost": "Costo totale",
"Total:": "Totale:",
"Type:": "Tipo:",
"Unknown": "Sconosciuto",
"Update Profile": "Aggiornamento del profilo",
"Upload": "Caricare",
"Upload All JSON": "Caricare tutti i JSON",
"Upload JSON for {name}": "Caricare JSON per {nome}",
"Upload Plot": "Carica trama",
"User Defined": "Definito dall'utente",
"Value:": "Valore:",
"View": "Vista",
"View mode: Interact with your widgets": "Modalità di visualizzazione: Interagire con i widget",
"Visible Controllers": "Controllori visibili",
"Watched Items": "Articoli osservati",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Quando la modalità Slave è abilitata, tutti i controllori Omron saranno disabilitati per l'elaborazione.",
"Widget editor and drag-and-drop functionality coming soon...": "Editor di widget e funzionalità drag-and-drop in arrivo...",
"Widgets": "Widget",
"Window (min)": "Finestra (min)",
"Window Offset": "Sfalsamento della finestra",
"Write Coil": "Scrivere la bobina",
"Write GPIO": "Scrivere GPIO",
"Write Holding Register": "Scrivere il registro di mantenimento",
"X-Axis": "Asse X",
"Y-Axis Left": "Asse Y Sinistra",
"accel": "accel",
"decel": "decelerare",
"e.g., Start Heating": "ad esempio, Avvio del riscaldamento",
"e.g., Turn on coil for pre-heating stage": "ad esempio, accendere la bobina per la fase di preriscaldamento",
"err": "sbagliare",
"fwd": "in avanti",
"in seconds": "in secondi",
"info": "info",
"none": "nessuno",
"reset": "azzeramento",
"reset_fault": "reset_fault",
"rev": "rev",
"run": "corsa",
"setup": "impostazione",
"stop": "fermarsi",
"Loading comments...": "Caricamento dei commenti...",
"Edit with AI Wizard": "Modifica con AI Wizard",
"Be the first to like this": "Sii il primo a cui piace questo",
"Versions": "Versioni",
"Current": "Attuale",
"Add a comment...": "Aggiungi un commento...",
"Post Comment": "Commento al post",
"No comments yet": "Non ci sono ancora commenti",
"Be the first to comment!": "Sii il primo a commentare!",
"Save": "Risparmiare",
"likes": "piace",
"like": "come",
"Prompt Templates": "Modelli di prompt",
"Optimize prompt with AI": "Ottimizzare il prompt con l'AI",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Descrivete l'immagine che volete creare o modificare... (Ctrl+V per incollare le immagini)",
"e.g. Cyberpunk Portrait": "ad esempio Ritratto Cyberpunk",
"Prompt": "Prompt",
"Templates": "Modelli",
"Optimize": "Ottimizzare",
"Selected Images": "Immagini selezionate",
"Upload Images": "Caricare le immagini",
"Choose Files": "Scegliere i file",
"No images selected": "Nessuna immagine selezionata",
"Upload images or select from gallery": "Caricare le immagini o selezionarle dalla galleria",
"No templates saved yet": "Nessun modello salvato",
"Save current as template": "Salvare la versione corrente come modello",
"Loading profile...": "Caricamento del profilo...",
"Back to feed": "Torna all'alimentazione",
"Create Post": "Creare un post",
"posts": "posti",
"followers": "seguaci",
"following": "di seguito",
"Joined": "Iscritto",
"Collections": "Collezioni",
"New": "Nuovo",
"POSTS": "POSTI",
"HIDDEN": "NASCOSTO",
"Profile picture": "Immagine del profilo",
"your.email@example.com": "your.email@example.com",
"Enter username": "Inserire il nome utente",
"Enter display name": "Inserire il nome del display",
"Tell us about yourself...": "Ci parli di lei...",
"Change Avatar": "Cambia Avatar",
"Email": "Email",
"Username": "Nome utente",
"Display Name": "Nome visualizzato",
"Bio": "Bio",
"Your preferred language for the interface": "La lingua preferita per l'interfaccia",
"Save Changes": "Salva le modifiche",
"Edit Picture": "Modifica immagine",
"Edit Details": "Modifica dei dettagli",
"Generate Title & Description with AI": "Generare titolo e descrizione con l'intelligenza artificiale",
"Enter a title...": "Inserire un titolo...",
"Record audio": "Registrare l'audio",
"Describe your photo... You can use **markdown** formatting!": "Descrivi la tua foto... Puoi usare la formattazione **markdown**!",
"Description (Optional)": "Descrizione (opzionale)",
"Visible": "Visibile",
"Make this picture visible to others": "Rendere questa immagine visibile agli altri",
"Update": "Aggiornamento",
"Loading versions...": "Caricamento delle versioni...",
"No other versions available for this image.": "Non sono disponibili altre versioni per questa immagine."
}

View File

@ -0,0 +1,458 @@
{
"Search pictures, users, collections...": "Foto's, gebruikers, collecties zoeken...",
"Search": "Zoek op",
"AI Image Generator": "AI Afbeelding Generator",
"Language": "Taal",
"Sign in": "Aanmelden",
"Loading...": "Aan het laden...",
"My Profile": "Mijn profiel",
"Enter your Google API key": "Voer uw Google API-sleutel in",
"Enter your OpenAI API key": "Voer uw OpenAI API-sleutel in",
"General": "Algemeen",
"Organizations": "Organisaties",
"API Keys": "API-sleutels",
"Profile": "Profiel",
"Gallery": "Galerij",
"Profile Settings": "Profielinstellingen",
"Manage your account settings and preferences": "Uw accountinstellingen en voorkeuren beheren",
"Google API Key": "Google API-sleutel",
"For Google services (stored securely)": "Voor Google-services (veilig opgeslagen)",
"OpenAI API Key": "OpenAI API sleutel",
"For AI image generation (stored securely)": "Voor het genereren van AI-afbeeldingen (veilig opgeslagen)",
"Save API Keys": "API-sleutels opslaan",
"AP Gateway": "AP Gateway",
"AP IP Address": "IP-adres AP",
"AP Password": "AP wachtwoord",
"AP SSID": "AP SSID",
"AP Subnet Mask": "AP-subnetmasker",
"API URL": "API URL",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO MULTI-GEBALANCEERD",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_GEBALANCEERD",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Modus toegangspunt (AP)",
"Add Container": "Container toevoegen",
"Add Samples": "Monsters toevoegen",
"Add Slave": "Slaaf toevoegen",
"Add Widget": "Widget toevoegen",
"Add a set of sample control points to this plot": "Een set controlepunten toevoegen aan deze plot",
"Add all": "Alles toevoegen",
"Addr:": "Adres:",
"Address Picker": "Adreskiezer",
"Advanced": "Geavanceerd",
"All Stop": "Alle stoppen",
"Apply": "Toepassen",
"Argument 0:": "Argument 0:",
"Argument 1:": "Argument 1:",
"Argument 2 (Optional):": "Argument 2 (optioneel):",
"Arguments:": "Argumenten:",
"Associated Controllers:": "Bijbehorende controllers:",
"Associated Signal Plot (Optional)": "Bijbehorend signaalplot (optioneel)",
"Aux": "Aux",
"BALANCE": "BALANS",
"BALANCE_MAX_DIFF": "BALANS_MAX_VERSCHIL",
"Buzzer": "Zoemer",
"Buzzer: Fast Blink": "Zoemer: Snel knipperen",
"Buzzer: Long Beep/Short Pause": "Zoemer: Lange piep/korte pauze",
"Buzzer: Off": "Zoemer: Uit",
"Buzzer: Slow Blink": "Zoemer: Langzaam knipperen",
"Buzzer: Solid On": "Zoemer: Continu aan",
"CE": "CE",
"COM Write": "COM schrijven",
"CP Description (Optional):": "CP Beschrijving (optioneel):",
"CP Name (Optional):": "CP-naam (optioneel):",
"CSV": "CSV",
"Call Function": "Functie oproepen",
"Call Method": "Bel methode",
"Call REST API": "REST API oproepen",
"Cancel": "Annuleren",
"Carina": "Carina",
"Cassandra Left": "Cassandra Links",
"Cassandra Right": "Cassandra Rechts",
"Castor": "Castor",
"Cetus": "Cetus",
"Charts": "Grafieken",
"Child Profiles (Sub-plots)": "Kindprofielen (subplots)",
"Clear": "Duidelijk",
"Clear All": "Alles wissen",
"Clear All CPs": "Alle CP's wissen",
"Clear Chart": "Duidelijke grafiek",
"Click \"Add Container\" to start building your layout": "Klik op \"Container toevoegen\" om te beginnen met het bouwen van je lay-out",
"Click \"Add Widget\" to start building your HMI": "Klik op \"Widget toevoegen\" om te beginnen met het bouwen van uw HMI",
"Coil to Write:": "Spoel om te schrijven:",
"Coils": "Spoelen",
"Color": "Kleur",
"Coma B": "Coma B",
"Commons": "Commons",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Configureer het nieuwe controlepunt. Druk op Enter om te bevestigen of op Esc om te annuleren.",
"Configure the series to be displayed on the chart.": "Configureer de series die moeten worden weergegeven op de grafiek.",
"Connect": "Maak verbinding met",
"Connect to a Modbus server to see controller data.": "Maak verbinding met een Modbus-server om controllergegevens te bekijken.",
"Connect to view register data.": "Maak verbinding om registergegevens te bekijken.",
"Connected, but no register data received yet. Waiting for data...": "Verbonden, maar nog geen registergegevens ontvangen. Wachten op gegevens...",
"Connects to an existing Wi-Fi network.": "Maakt verbinding met een bestaand Wi-Fi-netwerk.",
"Containers": "Containers",
"Continue": "Ga verder",
"Control Points": "Controlepunten",
"Control Points List": "Lijst met controlepunten",
"Controller Chart": "Regelaar Grafiek",
"Controller Partitions": "Controller-partities",
"Copy \"{plotName}\" to...": "Kopieer \"{plotnaam}\" naar...",
"Copy \"{profileName}\" to...": "Kopieer \"{profielnaam}\" naar...",
"Copy this plot to another slot...": "Kopieer deze plot naar een andere sleuf...",
"Copy to existing slot...": "Kopiëren naar bestaande sleuf...",
"Copy to...": "Kopiëren naar...",
"Copy...": "Kopiëren...",
"Corona": "Corona",
"Corvus": "Corvus",
"Crater": "Krater",
"Create Control Point": "Controlepunt maken",
"Create New Control Point": "Nieuw controlepunt maken",
"Creates its own Wi-Fi network.": "Maakt zijn eigen Wi-Fi-netwerk.",
"Crux": "Crux",
"Current Status": "Huidige status",
"Custom Widgets": "Aangepaste Widgets",
"DEC": "DEC",
"Dashboard": "Dashboard",
"Delete": "Verwijder",
"Delete Profile": "Profiel verwijderen",
"Delete control point": "Controlepunt verwijderen",
"Delta Vfd[15]": "Delta Vfd[15]",
"Description": "Beschrijving",
"Device Hostname": "Hostnaam apparaat",
"Disable All": "Alles uitschakelen",
"Disconnect": "Ontkoppelen",
"Display Message": "Bericht weergeven",
"Download": "Downloaden",
"Download All JSON": "Alle JSON downloaden",
"Download English Translations": "Engelse vertalingen downloaden",
"Download JSON for {name}": "JSON downloaden voor {naam}",
"Download Plot": "Download Plot",
"Drag and resize widgets": "Widgets slepen en formaat wijzigen",
"Duplicate Profile": "Duplicaat profiel",
"Duration (hh:mm:ss)": "Duur (uu:mm:ss)",
"Duration:": "Duur:",
"E.g., Quick Ramp Up": "Bijvoorbeeld Quick Ramp Up",
"ERROR": "FOUT",
"Edit": "Bewerk",
"Edit Profile": "Profiel bewerken",
"Edit mode: Add, move, and configure widgets": "Bewerkingsmodus: Widgets toevoegen, verplaatsen en configureren",
"Edit mode: Configure containers and add widgets": "Bewerkingsmodus: Containers configureren en widgets toevoegen",
"Empty Canvas": "Leeg canvas",
"Empty Layout": "Lege lay-out",
"Enable All": "Alles inschakelen",
"Enable control unavailable for {name}": "Controle niet beschikbaar voor {naam} inschakelen",
"Enabled": "Ingeschakeld",
"End Index": "Einde Index",
"Enter CP description": "CP-beschrijving invoeren",
"Enter CP name": "Voer CP-naam in",
"Export": "Exporteer",
"Export JSON": "JSON exporteren",
"Export to CSV": "Exporteren naar CSV",
"Favorite Coils": "Favoriete spoelen",
"Favorite Registers": "Favoriete registers",
"Favorites": "Favorieten",
"File name": "Bestandsnaam",
"Fill": "Vullen",
"Filling": "Vullen",
"General Settings": "Algemene instellingen",
"Global Settings": "Wereldwijde instellingen",
"HEX": "HEX",
"HMI Edit Mode Active": "HMI-bewerkingsmodus actief",
"Hardware I/O": "Hardware I/O",
"Heating Time": "Opwarmtijd",
"Help": "Help",
"Home": "Home",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Hostnaam",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Inactief",
"Import": "Importeren",
"Import JSON": "JSON importeren",
"Info": "Info",
"Integrations": "Integraties",
"Interlocked": "Vergrendeld",
"Jammed": "Vastgelopen",
"Joystick": "Joystick",
"LOADCELL": "LOADCELL",
"Last updated": "Laatst bijgewerkt",
"Loadcell[25]": "Loadcell[25]",
"Loadcell[26]": "Loadcell[26]",
"Loading Cassandra settings...": "Cassandra instellingen laden...",
"Loading network settings...": "Netwerkinstellingen laden...",
"Loading profiles from Modbus...": "Profielen laden van Modbus...",
"Logs": "Logboeken",
"Low": "Laag",
"MANUAL": "HANDMATIG",
"MANUAL MULTI": "HANDMATIG MULTI",
"MANUAL_MULTI": "HANDMATIG_MULTI",
"MAXLOAD": "MAXLOAD",
"MAX_TIME": "MAX_TIJD",
"MINLOAD": "MINLOAD",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Slave-apparaten beheren (max 1).",
"Markdown": "Markdown",
"Master Configuration": "Hoofdconfiguratie",
"Master Name": "Master Naam",
"Max": "Max",
"Max Simultaneous": "Max. gelijktijdig",
"Mid": "Midden",
"Min": "Min",
"Modbus": "Modbus",
"Mode": "Modus",
"Move control point down": "Verplaats controlepunt omlaag",
"Move control point up": "Verplaats controlepunt omhoog",
"N/A": "N.V.T",
"NONE": "GEEN",
"Network": "Netwerk",
"Network Settings": "Netwerkinstellingen",
"No Operation": "Geen bediening",
"No coils data available. Try refreshing.": "Geen spoelgegevens beschikbaar. Probeer te verversen.",
"No containers yet": "Nog geen containers",
"No enabled profile": "Geen ingeschakeld profiel",
"No register data available. Try refreshing.": "Geen registergegevens beschikbaar. Probeer te verversen.",
"No source found.": "Geen bron gevonden.",
"No widgets found": "Geen widgets gevonden",
"No widgets yet": "Nog geen widgets",
"None": "Geen",
"OC": "OC",
"OFFLINE": "OFFLINE",
"OK": "OK",
"OL": "OL",
"ON": "OP",
"ONLINE": "ONLINE",
"OV": "OV",
"OVERLOAD": "OVERLOAD",
"Offset": "Offset",
"Operatorswitch": "Operatorschakelaar",
"PID Control": "PID-regeling",
"PV": "PV",
"Partitions": "Scheidingswanden",
"Pause": "Pauze",
"Pause Profile": "Profiel pauzeren",
"Phapp": "Phapp",
"Play from start": "Speel vanaf het begin",
"Playground": "Speeltuin",
"Plunge": "Duik",
"Plunger": "Plunjer",
"PlungingAuto": "DuikAuto",
"PlungingMan": "DuikendMan",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"Pop-out": "Pop-up",
"PostFlow": "PostFlow",
"Press": "Druk op",
"Press Cylinder": "Perscilinder",
"Press Cylinder Controls": "Cilinderbediening",
"Presscylinder": "Perscilinder",
"Profile Curves": "Profielcurven",
"Profile Name": "Profielnaam",
"Profile SP": "Profiel SP",
"Profiles": "Profielen",
"Properties:": "Eigenschappen:",
"REMOTE": "AFSTAND",
"Real time Charting": "Real-time grafieken",
"Real-time Charts": "Real-time grafieken",
"Record": "Opnemen",
"Refresh Rate": "Verversingssnelheid",
"Registers": "Registers",
"Remove all": "Alles verwijderen",
"Remove all control points from this plot": "Alle controlepunten uit dit diagram verwijderen",
"Replay": "Replay",
"Reset": "Reset",
"Reset Zoom": "Zoom resetten",
"ResettingJam": "ResettenJam",
"Restart at end": "Opnieuw starten aan het einde",
"Run Action": "Actie uitvoeren",
"Run this control point action now": "Voer deze controlepuntactie nu uit",
"SP": "SP",
"SP CMD Addr:": "SP CMD Addr:",
"SP:": "SP:",
"STA Gateway": "STA Gateway",
"STA IP Address": "STA IP-adres",
"STA Password": "STA Wachtwoord",
"STA Primary DNS": "STA Primair DNS",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA Secundair DNS",
"STA Subnet Mask": "STA Subnetmasker",
"STALLED": "STALLED",
"Samplesignalplot 0": "Voorbeeldsignaalplot 0",
"Save AP Settings": "AP-instellingen opslaan",
"Save All Settings": "Alle instellingen opslaan",
"Save As": "Opslaan als",
"Save STA Settings": "STA-instellingen opslaan",
"Save Signal Plot": "Signaalplot opslaan",
"Scale": "Schaal",
"Scale:": "Schaal:",
"Search...": "Zoeken...",
"Select Known Coil...": "Selecteer bekende spoel...",
"Select a control point to see its properties.": "Selecteer een controlepunt om de eigenschappen ervan te bekijken.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Selecteer een bestemmingsplot. De inhoud van \"{plotnaam}\" overschrijft het geselecteerde perceel. Deze actie kan niet ongedaan worden gemaakt.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Selecteer een doelprofiel. De inhoud van \"{profileName}\" zal het geselecteerde profiel overschrijven. Deze actie kan niet ongedaan worden gemaakt.",
"Select a plot to overwrite": "Selecteer een perceel om te overschrijven",
"Select a profile to overwrite": "Selecteer een profiel om te overschrijven",
"Select a register or coil address": "Selecteer een register of spoeladres",
"Select a signal plot to associate and edit": "Selecteer een signaalplot om te koppelen en te bewerken",
"Select source...": "Selecteer bron...",
"Select type": "Selecteer type",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Geselecteerde kindprofielen zullen starten, stoppen, pauzeren en hervatten met dit ouderprofiel.",
"Send IFTTT Notification": "IFTTT-kennisgeving verzenden",
"Sequential Heating": "Sequentiële verwarming",
"Sequential Heating Control": "Sequentiële verwarmingsregeling",
"Series": "Serie",
"Series Toggles": "Serieschakelaars",
"Series settings": "Serie-instellingen",
"Set All": "Alles instellen",
"Set All SP": "Alle SP instellen",
"Set as Default": "Instellen als standaard",
"Settings": "Instellingen",
"Settings...": "Instellingen...",
"Shortplot 70s": "Korte Plot 70",
"Show Legend": "Legende weergeven",
"Show PV": "Toon PV",
"Show SP": "Toon SP",
"Signal Control Point Details": "Details signaalcontrolepunt",
"Signal Plot Editor": "Signaalplot-editor",
"Signal plots configuration loaded from API.": "Signaalplots configuratie geladen van API.",
"Signalplot 922 Slot 2": "Signaalplot 922 sleuf 2",
"Signalplot 923 Slot 3": "Signaalplot 923 Sleuf 3",
"Signals": "Signalen",
"Slave Mode": "Slavenmodus",
"Slave:": "Slaaf:",
"Slaves": "Slaven",
"Slot": "Sleuf",
"Slot:": "Gleuf:",
"Source": "Bron",
"Start": "Start",
"Start Index": "Index starten",
"Start PID Controllers": "PID-regelaars starten",
"Start Profile": "Profiel starten",
"State:": "Staat:",
"Station (STA) Mode": "Station (STA) Modus",
"Stop": "Stop",
"Stop PID Controllers": "PID-regelaars stoppen",
"Stop Profile": "Stop profiel",
"Stop and reset": "Stoppen en resetten",
"Stop at end": "Stoppen aan het einde",
"Stopped": "Gestopt",
"Stopping": "Stoppen",
"Switch to edit mode to add containers": "Schakel over naar de bewerkingsmodus om containers toe te voegen",
"Switch to edit mode to add widgets": "Schakel over naar de bewerkingsmodus om widgets toe te voegen",
"System Calls": "Systeemoproepen",
"System Information": "Systeeminformatie",
"System Messages": "Systeemberichten",
"Target Controllers (Registers)": "Doelcontrollers (registers)",
"Temperature Control Points": "Temperatuurcontrolepunten",
"Temperature Profiles": "Temperatuurprofielen",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Deze hostnaam wordt gebruikt voor zowel de STA- als de AP-modus. Wijzigingen hier worden opgeslagen in beide vormen.",
"This is where you'll design and configure your HMI layouts.": "Hier ontwerpt en configureert u uw HMI lay-outs.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Dit zal het profiel \"{profileName}\" permanent verwijderen van de server. Deze actie kan niet ongedaan worden gemaakt.",
"Time:": "Tijd:",
"Timeline:": "Tijdlijn:",
"Title (Optional)": "Titel (optioneel)",
"Total": "Totaal",
"Total Cost": "Totale kosten",
"Total:": "Totaal:",
"Type:": "Type:",
"Unknown": "Onbekend",
"Update Profile": "Profiel bijwerken",
"Upload": "Uploaden",
"Upload All JSON": "Alle JSON uploaden",
"Upload JSON for {name}": "JSON uploaden voor {naam}",
"Upload Plot": "Upload perceel",
"User Defined": "Door gebruiker gedefinieerd",
"Value:": "Waarde:",
"View": "Bekijk",
"View mode: Interact with your widgets": "Weergavemodus: Interactie met je widgets",
"Visible Controllers": "Zichtbare regelaars",
"Watched Items": "Bekeken items",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Als de slave-modus is ingeschakeld, worden alle Omron-controllers uitgeschakeld voor verwerking.",
"Widget editor and drag-and-drop functionality coming soon...": "Widget editor en drag-and-drop functionaliteit binnenkort beschikbaar...",
"Widgets": "Widgets",
"Window (min)": "Venster (min)",
"Window Offset": "Venster offset",
"Write Coil": "Spoel schrijven",
"Write GPIO": "GPIO schrijven",
"Write Holding Register": "Schrijf houdregister",
"X-Axis": "X-as",
"Y-Axis Left": "Y-as links",
"accel": "accel",
"decel": "decel",
"e.g., Start Heating": "bijv. Start Verwarming",
"e.g., Turn on coil for pre-heating stage": "bijv. Spiraal inschakelen voor voorverwarmen",
"err": "err",
"fwd": "fwd",
"in seconds": "in seconden",
"info": "info",
"none": "geen",
"reset": "reset",
"reset_fault": "reset_fout",
"rev": "rev",
"run": "uitvoeren",
"setup": "setup",
"stop": "stop",
"Loading comments...": "Reacties laden...",
"Edit with AI Wizard": "Bewerken met AI Wizard",
"Be the first to like this": "Vind dit als eerste leuk",
"Versions": "Versies",
"Current": "Huidige",
"Add a comment...": "Een opmerking toevoegen...",
"Post Comment": "Reactie plaatsen",
"No comments yet": "Nog geen opmerkingen",
"Be the first to comment!": "Geef als eerste uw commentaar!",
"Save": "Sla",
"likes": "houdt van",
"like": "zoals",
"Prompt Templates": "Sjablonen voor vragen",
"Optimize prompt with AI": "Optimaliseer prompt met AI",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Beschrijf de afbeelding die je wilt maken of bewerken... (Ctrl+V om afbeeldingen te plakken)",
"e.g. Cyberpunk Portrait": "bijvoorbeeld Cyberpunk-portret",
"Prompt": "Prompt",
"Templates": "Sjablonen",
"Optimize": "Optimaliseer",
"Selected Images": "Geselecteerde afbeeldingen",
"Upload Images": "Afbeeldingen uploaden",
"Choose Files": "Bestanden kiezen",
"No images selected": "Geen afbeeldingen geselecteerd",
"Upload images or select from gallery": "Upload afbeeldingen of selecteer ze uit de galerij",
"No templates saved yet": "Nog geen sjablonen opgeslagen",
"Save current as template": "Huidige opslaan als sjabloon",
"Loading profile...": "Profiel laden...",
"Back to feed": "Terug naar feed",
"Create Post": "Maak post",
"posts": "berichten",
"followers": "volgers",
"following": "volgend op",
"Joined": "Aangesloten bij",
"Collections": "Collecties",
"New": "Nieuw",
"POSTS": "POSTEN",
"HIDDEN": "VERBORGEN",
"Profile picture": "Profielfoto",
"your.email@example.com": "your.email@example.com",
"Enter username": "Gebruikersnaam invoeren",
"Enter display name": "Weergavenaam invoeren",
"Tell us about yourself...": "Vertel ons over jezelf...",
"Change Avatar": "Avatar wijzigen",
"Email": "E-mail",
"Username": "Gebruikersnaam",
"Display Name": "Naam weergeven",
"Bio": "Bio",
"Your preferred language for the interface": "De taal van je voorkeur voor de interface",
"Save Changes": "Wijzigingen opslaan",
"Edit Picture": "Afbeelding bewerken",
"Edit Details": "Details bewerken",
"Generate Title & Description with AI": "Titel en beschrijving genereren met AI",
"Enter a title...": "Voer een titel in...",
"Record audio": "Audio opnemen",
"Describe your photo... You can use **markdown** formatting!": "Beschrijf je foto... Je kunt **markdown** opmaak gebruiken!",
"Description (Optional)": "Beschrijving (optioneel)",
"Visible": "Zichtbaar",
"Make this picture visible to others": "Maak deze foto zichtbaar voor anderen",
"Update": "Update",
"Loading versions...": "Laden van versies...",
"No other versions available for this image.": "Er zijn geen andere versies beschikbaar voor deze afbeelding."
}

330
packages/ui/src/index.css Normal file
View File

@ -0,0 +1,330 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Photo sharing app design system - modern gradients and glass effects */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 15%;
--card: 0 0% 100%;
--card-foreground: 240 10% 15%;
--card-glass: 0 0% 100% / 0.8;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 15%;
--primary: 262 83% 58%;
--primary-foreground: 0 0% 98%;
--primary-glow: 280 100% 70%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 193 76% 59%;
--accent-foreground: 0 0% 98%;
--accent-glow: 193 100% 70%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262 83% 58%;
--radius: 0.75rem;
/* Photo sharing specific tokens */
--gradient-primary: linear-gradient(135deg, hsl(262 83% 58%), hsl(280 100% 70%));
--gradient-secondary: linear-gradient(135deg, hsl(193 76% 59%), hsl(262 83% 58%));
--gradient-hero: linear-gradient(135deg, hsl(0 0% 100%) 0%, hsl(262 83% 58% / 0.05) 100%);
--glass-bg: hsl(0 0% 100% / 0.8);
--glass-border: hsl(240 5.9% 90%);
--photo-shadow: 0 25px 50px -12px hsl(262 83% 58% / 0.15);
--glow-shadow: 0 0 40px hsl(262 83% 58% / 0.2);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
/* Material Design dark theme base - #121212 */
--background: 0 0% 7%;
--foreground: 0 0% 95%;
/* Surface elevation system following Material Design */
--card: 0 0% 9%;
/* 1dp elevation = base + 5% white overlay */
--card-foreground: 0 0% 95%;
--card-glass: 0 0% 12% / 0.8;
/* Higher elevation surfaces */
--popover: 0 0% 11%;
/* 2dp elevation = base + 7% white overlay */
--popover-foreground: 0 0% 95%;
/* Accessible primary colors for dark theme */
--primary: 270 91% 75%;
/* Lighter for better accessibility */
--primary-foreground: 0 0% 10%;
--primary-glow: 280 100% 80%;
--secondary: 0 0% 15%;
/* 3dp elevation = base + 8% white overlay */
--secondary-foreground: 0 0% 95%;
--muted: 0 0% 15%;
--muted-foreground: 0 0% 65%;
/* Better contrast for muted text */
--accent: 270 91% 75%;
--accent-foreground: 0 0% 10%;
--accent-glow: 280 100% 80%;
--destructive: 0 70% 50%;
/* More accessible red */
--destructive-foreground: 0 0% 95%;
--border: 0 0% 15%;
--input: 0 0% 15%;
--ring: 270 91% 75%;
/* Material Design elevation surfaces */
--surface-1dp: 0 0% 9%;
/* Cards, switches */
--surface-2dp: 0 0% 11%;
/* App bars (resting) */
--surface-3dp: 0 0% 12%;
/* Refresh indicator, search bar (resting) */
--surface-4dp: 0 0% 13%;
/* App bars (scrolled) */
--surface-6dp: 0 0% 14%;
/* FAB (resting), snackbars */
--surface-8dp: 0 0% 15%;
/* Menus, cards (picked up), switches (thumb) */
--surface-12dp: 0 0% 16%;
/* FAB (pressed) */
--surface-16dp: 0 0% 17%;
/* Navigation drawer, modal bottom sheets */
--surface-24dp: 0 0% 18%;
/* Dialogs */
/* Photo sharing specific tokens for dark mode */
--gradient-primary: linear-gradient(135deg, hsl(270 91% 75%), hsl(280 100% 80%));
--gradient-secondary: linear-gradient(135deg, hsl(270 91% 75%), hsl(260 85% 70%));
--gradient-hero: linear-gradient(135deg, hsl(0 0% 7%) 0%, hsl(270 91% 75% / 0.1) 100%);
--glass-bg: hsl(0 0% 12% / 0.4);
--glass-border: hsl(270 91% 75% / 0.2);
--photo-shadow: 0 25px 50px -12px hsl(270 91% 75% / 0.25);
--glow-shadow: 0 0 40px hsl(270 91% 75% / 0.3);
--sidebar-background: 0 0% 7%;
--sidebar-foreground: 0 0% 95%;
--sidebar-primary: 270 91% 75%;
--sidebar-primary-foreground: 0 0% 10%;
--sidebar-accent: 0 0% 15%;
--sidebar-accent-foreground: 0 0% 95%;
--sidebar-border: 0 0% 15%;
--sidebar-ring: 270 91% 75%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
/* Internet Explorer 10+ */
scrollbar-width: none;
/* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
/* Safari and Chrome */
}
/* Custom thin scrollbar */
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: 20px;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}
@layer components {
/* Ensure links in prose content are clickable and styled */
.prose a {
@apply text-primary hover:text-primary/80 underline hover:no-underline transition-colors cursor-pointer;
}
.prose a:visited {
@apply text-primary/70;
}
/* Enhanced prose table styling */
.prose table {
@apply w-full border-collapse border border-border;
}
.prose thead {
@apply bg-muted/50;
}
.prose th {
@apply border border-border px-4 py-2 text-left font-semibold;
}
.prose td {
@apply border border-border px-4 py-2;
}
.prose tbody tr:hover {
@apply bg-muted/30;
}
/* Heading styles */
.prose h1 {
@apply text-3xl font-bold mb-4 mt-6;
}
.prose h2 {
@apply text-2xl font-bold mb-3 mt-5;
}
.prose h3 {
@apply text-xl font-semibold mb-2 mt-4;
}
.prose h4 {
@apply text-lg font-semibold mb-2 mt-3;
}
/* List styles */
.prose ul {
@apply list-disc list-inside mb-4;
}
.prose ol {
@apply list-decimal list-inside mb-4;
}
.prose li {
@apply mb-1;
}
/* Code blocks */
.prose code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-foreground font-medium;
}
.prose pre {
@apply bg-muted p-4 rounded-lg overflow-x-auto mb-4 text-foreground;
}
.prose pre code {
@apply bg-transparent p-0 text-foreground font-normal;
}
/* Blockquotes */
.prose blockquote {
@apply border-l-4 border-primary pl-4 italic my-4;
}
/* Paragraphs */
.prose p {
@apply mb-4;
}
/* Horizontal rules */
.prose hr {
@apply border-t border-border my-6;
}
/* Strong/Bold */
.prose strong {
@apply font-bold;
}
/* Emphasis/Italic */
.prose em {
@apply italic;
}
}
/* TikTok-style Video Player Overrides */
@layer components {
/* Force Vidstack video to use object-contain for full video display */
[data-media-player] video {
object-fit: contain !important;
object-position: center !important;
max-width: 100% !important;
max-height: 100% !important;
width: 100% !important;
height: 100% !important;
}
/* Constrain video player to viewport */
[data-media-player] {
max-width: 100% !important;
max-height: 100% !important;
}
/* Email Widget Overrides (Design View) */
/* Fix for "font-size: 0" hack in email templates causing invisible text in editor */
.html-widget-container .long-text {
font-size: 12px;
/* The inline style from the widget prop will override this if valid */
}
/*
Reset email table structure for Editor View
- Makes the gray structural background transparent
- Allows widgets to take full width of their container in the editor
*/
.html-widget-container .vb-outer {
background-color: transparent !important;
height: auto !important;
}
.html-widget-container .vb-outer div {
max-width: 100% !important;
}
.html-widget-container table {
max-width: 100% !important;
}
}

View File

@ -369,7 +369,7 @@ export interface FeedPost {
}
export const fetchFeedPosts = async (
source: 'home' | 'collection' | 'tag' | 'user' = 'home',
source: 'home' | 'collection' | 'tag' | 'user' | 'widget' = 'home',
sourceId?: string,
isOrgContext?: boolean,
orgSlug?: string,
@ -380,7 +380,7 @@ export const fetchFeedPosts = async (
};
export const fetchFeedPostsPaginated = async (
source: 'home' | 'collection' | 'tag' | 'user' = 'home',
source: 'home' | 'collection' | 'tag' | 'user' | 'widget' = 'home',
sourceId?: string,
isOrgContext?: boolean,
orgSlug?: string,

View File

@ -0,0 +1,129 @@
import { PageLayout, LayoutContainer, WidgetInstance } from './unifiedLayoutManager';
import { widgetRegistry } from './widgetRegistry';
import { template } from '@/lib/variables';
import { marked } from 'marked';
export const generateEmailHtml = async (layout: PageLayout, rootTemplateUrl: string): Promise<string> => {
try {
// 1. Fetch Root Template
const rootRes = await fetch(rootTemplateUrl);
if (!rootRes.ok) throw new Error(`Failed to load root template: ${rootTemplateUrl}`);
let rootHtml = await rootRes.text();
// 2. Resolve Root Template Variables (e.g. ${title})
// Map layout properties to variables. We map 'name' to 'title' for common usage.
const rootVars: Record<string, any> = {
...layout,
title: layout.name,
SOURCE: '${SOURCE}'
};
rootHtml = template(rootHtml, rootVars, false);
// 3. Generate Content Body
const contentHtml = await generateContainerHtml(layout.containers);
// 4. Inject Content
if (rootHtml.includes('${SOURCE}')) {
return rootHtml.replace('${SOURCE}', contentHtml);
} else {
return rootHtml.replace('</body>', `${contentHtml}</body>`);
}
} catch (error) {
console.error("Email generation failed:", error);
throw error;
}
};
const generateContainerHtml = async (containers: LayoutContainer[]): Promise<string> => {
let html = '';
// Sort containers
const sortedContainers = [...containers].sort((a, b) => (a.order || 0) - (b.order || 0));
for (const container of sortedContainers) {
const gap = container.gap || 0;
// Container Table
html += `<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin-bottom: ${gap}px; min-width: 100%;">`;
html += `<tr><td align="center" valign="top">`;
html += `<table width="100%" border="0" cellpadding="0" cellspacing="0"><tr>`;
if (container.columns === 1) {
// Stacked vertical
html += `<td width="100%" valign="top">`;
const sortedWidgets = [...container.widgets].sort((a, b) => (a.order || 0) - (b.order || 0));
for (const widget of sortedWidgets) {
html += await generateWidgetHtml(widget);
}
if (container.children?.length > 0) {
html += await generateContainerHtml(container.children);
}
html += `</td>`;
} else {
// Grid Layout
const colWidth = Math.floor(100 / container.columns);
const sortedWidgets = [...container.widgets].sort((a, b) => (a.order || 0) - (b.order || 0));
for (let i = 0; i < sortedWidgets.length; i += container.columns) {
const rowWidgets = sortedWidgets.slice(i, i + container.columns);
if (i > 0) html += `</tr><tr>`;
for (const widget of rowWidgets) {
html += `<td width="${colWidth}%" valign="top">`;
html += await generateWidgetHtml(widget);
html += `</td>`;
}
// Filler cells
if (rowWidgets.length < container.columns) {
for (let j = rowWidgets.length; j < container.columns; j++) {
html += `<td width="${colWidth}%">&nbsp;</td>`;
}
}
}
}
html += `</tr></table>`;
html += `</td></tr>`;
html += `</table>`;
}
return html;
};
const generateWidgetHtml = async (widget: WidgetInstance): Promise<string> => {
const def = widgetRegistry.get(widget.widgetId);
if (!def) return `<!-- Missing Widget: ${widget.widgetId} -->`;
const templateUrl = def.metadata.defaultProps?.__templateUrl;
if (templateUrl) {
try {
const res = await fetch(templateUrl);
if (res.ok) {
const content = await res.text();
// Process props for markdown conversion
const processedProps = { ...(widget.props || {}) };
if (def.metadata.configSchema) {
for (const [key, schema] of Object.entries(def.metadata.configSchema)) {
if ((schema as any).type === 'markdown' && typeof processedProps[key] === 'string') {
try {
processedProps[key] = await marked.parse(processedProps[key]);
} catch (e) {
console.warn(`Failed to parse markdown for widget ${widget.widgetId} prop ${key}`, e);
}
}
}
}
// Perform Substitution using helper
return template(content, processedProps, false);
}
} catch (e) {
console.error(`Failed to fetch template for ${widget.widgetId}`, e);
}
}
return `<!-- Widget Content: ${widget.widgetId} -->`;
};

View File

@ -8,11 +8,38 @@ export interface LayoutStorageService {
saveToApiOnly(data: RootLayoutData, pageId?: string): Promise<boolean>;
}
// Database-only service for page layouts (localStorage disabled)
// In-memory cache for non-database layouts (playground, dashboard, profile)
// Database-only service for page layouts (localStorage disabled for DB items)
// In-memory cache WITH localStorage persistence for playground/testing
export class DatabaseLayoutService implements LayoutStorageService {
private memoryCache: Map<string, RootLayoutData> = new Map();
constructor() {
// Attempt to load playground cache from localStorage
try {
const stored = localStorage.getItem('polymech_playground_layout_cache');
if (stored) {
const parsed = JSON.parse(stored);
Object.keys(parsed).forEach(key => {
this.memoryCache.set(key, parsed[key]);
});
}
} catch (e) {
console.warn('Failed to load playground cache', e);
}
}
private persistMemoryCache() {
try {
const obj: Record<string, any> = {};
this.memoryCache.forEach((value, key) => {
obj[key] = value;
});
localStorage.setItem('polymech_playground_layout_cache', JSON.stringify(obj));
} catch (e) {
console.warn('Failed to persist playground cache', e);
}
}
async load(pageId?: string): Promise<RootLayoutData | null> {
if (!pageId) return null;
@ -46,11 +73,8 @@ export class DatabaseLayoutService implements LayoutStorageService {
if (!error && data && data[column]) {
const rootData = data[column] as unknown as RootLayoutData;
console.log(`[LayoutStorage] Loaded data from ${table} for ID ${actualId}:`, rootData);
if (isCollection && layoutKey) {
const pageLayout = rootData.pages?.[layoutKey] || null;
console.log(`[LayoutStorage] Extracted layout for key "${layoutKey}":`, pageLayout);
return {
pages: { [pageId]: pageLayout },
version: rootData.version || '1.0.0',
@ -76,7 +100,7 @@ export class DatabaseLayoutService implements LayoutStorageService {
if (!pageId.startsWith('page-') && !pageId.startsWith('collection-')) {
this.memoryCache.set(pageId, data);
logger.info('💾 Saved to memory cache:', pageId);
this.persistMemoryCache();
return true;
}
@ -112,7 +136,6 @@ export class DatabaseLayoutService implements LayoutStorageService {
let dataToSave: any = data;
if (isCollection && layoutKey) {
console.log(`[LayoutStorage] Saving collection layout for key "${layoutKey}"`, data.pages[pageId]);
const { data: existingData, error: fetchError } = await supabase
.from('collections')
.select('layout')
@ -133,7 +156,6 @@ export class DatabaseLayoutService implements LayoutStorageService {
existingLayout.pages[layoutKey] = data.pages[pageId];
existingLayout.lastUpdated = Date.now();
dataToSave = existingLayout;
console.log(`[LayoutStorage] Merged layout to save to collection ${actualId}:`, dataToSave);
}
const { error } = await supabase

View File

@ -0,0 +1,100 @@
import { PageLayout } from './unifiedLayoutManager';
export interface LayoutTemplate {
name: string;
layoutJson: string;
isPredefined?: boolean;
}
const PREDEFINED_TEMPLATES: LayoutTemplate[] = [
{
name: "Product Layout (Amazon)",
isPredefined: true,
layoutJson: JSON.stringify({
"id": "template-product",
"name": "Product Layout",
"createdAt": 1715694800000,
"updatedAt": 1715694800000,
"containers": [
{
"id": "product-main",
"type": "container",
"columns": 2,
"gap": 24,
"widgets": [
{
"id": "product-images",
"widgetId": "photo-grid",
"props": {}
},
{
"id": "product-details",
"widgetId": "markdown-text",
"props": {
"content": "# Super Widget 3000\n**$99.99**\n\n⭐ (420 ratings)\n\n- Feature 1: Amazing\n- Feature 2: Incredible\n- Feature 3: Unbelievable"
}
}
],
"children": [],
"order": 0
},
{
"id": "related-prods",
"type": "container",
"columns": 1,
"gap": 16,
"widgets": [
{
"id": "related-title",
"widgetId": "markdown-text",
"props": { "content": "### Customers also bought" }
},
{
"id": "related-grid",
"widgetId": "photo-grid-widget",
"props": {}
}
],
"children": [],
"order": 1
}
]
})
}
];
export class LayoutTemplateManager {
private static STORAGE_KEY = 'polymech_layout_templates';
static getTemplates(): LayoutTemplate[] {
const stored = localStorage.getItem(this.STORAGE_KEY);
const custom: LayoutTemplate[] = stored ? JSON.parse(stored) : [];
return [...PREDEFINED_TEMPLATES, ...custom];
}
static saveTemplate(name: string, layoutJson: string) {
const templates = this.getCustomTemplates();
// Check if exists and update
const existingIndex = templates.findIndex(t => t.name === name);
const newTemplate: LayoutTemplate = { name, layoutJson, isPredefined: false };
if (existingIndex >= 0) {
templates[existingIndex] = newTemplate;
} else {
templates.push(newTemplate);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(templates));
}
static getCustomTemplates(): LayoutTemplate[] {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
}
static deleteTemplate(name: string) {
const templates = this.getCustomTemplates().filter(t => t.name !== name);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(templates));
}
}

View File

@ -89,7 +89,8 @@ export const createPageInDb = async (
content: string;
tags?: string[];
is_public?: boolean;
visible?: boolean
visible?: boolean;
parent?: string | null;
},
addLog: LogFunction = defaultLog
) => {
@ -125,6 +126,7 @@ export const createPageInDb = async (
is_public: args.is_public ?? false,
visible: args.visible ?? true,
type: 'article', // Default type
parent: args.parent,
};
addLog('debug', '[PAGE-TOOLS] createPageInDb - New page object before insert', { newPage: JSON.stringify(newPage) });
@ -165,6 +167,7 @@ export const createPageTool = (userId: string, addLog: LogFunction = defaultLog)
tags: z.array(z.string()).optional().describe('An array of relevant tags for the page.'),
is_public: z.boolean().optional().default(false).describe('Whether the page should be publicly accessible. Defaults to false.'),
visible: z.boolean().optional().default(true).describe('Whether the page should be visible in lists. Defaults to true.'),
parent: z.string().optional().describe('The ID of the parent page, if any.'),
}),
function: async (args) => {
try {

103
packages/ui/src/lib/toc.ts Normal file
View File

@ -0,0 +1,103 @@
import { marked } from 'marked';
import { PageLayout, LayoutContainer } from '@/lib/unifiedLayoutManager';
export interface MarkdownHeading {
depth: number;
slug: string;
text: string;
}
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}
interface TocOpts {
minHeadingLevel?: number;
maxHeadingLevel?: number;
title?: string;
}
/**
* Basic slugify function to match simple regex used in renderer
* Note: For production with non-latin chars, consider github-slugger
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/** Extract headings from markdown content */
export function extractHeadings(content: string): MarkdownHeading[] {
const tokens = marked.lexer(content);
const headings: MarkdownHeading[] = [];
marked.walkTokens(tokens, (token) => {
if (token.type === 'heading') {
headings.push({
depth: token.depth,
text: token.text,
slug: slugify(token.text)
});
}
});
return headings;
}
/** Extract headings from all widgets in a page layout */
export function extractHeadingsFromLayout(layout: PageLayout): MarkdownHeading[] {
let allHeadings: MarkdownHeading[] = [];
const processContainer = (container: LayoutContainer) => {
// Process widgets in this container
container.widgets.forEach(widget => {
// Check if it's a text widget (based on ID or props)
// Assuming 'text-widget' or any widget with 'content' or 'text' prop that is markdown
// We can check if it has a 'content' prop which is a string
if (widget.props && typeof widget.props.content === 'string') {
const widgetHeadings = extractHeadings(widget.props.content);
allHeadings = [...allHeadings, ...widgetHeadings];
}
});
// Process nested containers
container.children.forEach(processContainer);
};
layout.containers.forEach(processContainer);
return allHeadings;
}
/** Convert the flat headings array into a nested tree structure. */
export function generateToC(
headings: MarkdownHeading[],
{ minHeadingLevel, maxHeadingLevel, title }: TocOpts
) {
headings = headings.filter(({ depth }) => depth >= (minHeadingLevel || 2) && depth <= (maxHeadingLevel || 4));
const toc: Array<TocItem> = [];
if (title) {
toc.push({ depth: 2, slug: '_top', text: title, children: [] });
}
for (const heading of headings) injectChild(toc, { ...heading, children: [] });
return toc;
}
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
function injectChild(items: TocItem[], item: TocItem): void {
const lastItem = items.at(-1);
if (!lastItem || lastItem.depth >= item.depth) {
items.push(item);
} else {
// If the last item is lesser depth (e.g. 2 vs 3), we try to put it in children.
// However, if the gap is too large (e.g. 2 vs 4), simple recursion handles it
// by putting it in the children of the level 2 item.
injectChild(lastItem.children, item);
}
}

View File

@ -27,6 +27,8 @@ export interface PageLayout {
containers: LayoutContainer[];
createdAt: number;
updatedAt: number;
loadedBundles?: string[];
rootTemplate?: string;
}
export interface RootLayoutData {
@ -36,6 +38,7 @@ export interface RootLayoutData {
}
import { layoutStorage } from './layoutStorage';
import { widgetRegistry } from '@/lib/widgetRegistry';
export class UnifiedLayoutManager {
private static readonly VERSION = '1.0.0';
@ -189,10 +192,17 @@ export class UnifiedLayoutManager {
}
}
// Get default props from registry if available
let defaultProps = {};
const widgetDef = widgetRegistry.get(widgetId);
if (widgetDef && widgetDef.metadata.defaultProps) {
defaultProps = { ...widgetDef.metadata.defaultProps };
}
const newWidget: WidgetInstance = {
id: this.generateWidgetId(),
widgetId,
props: {},
props: defaultProps,
order
};
@ -245,6 +255,44 @@ export class UnifiedLayoutManager {
return findAndUpdateWidget(layout.containers);
}
// Rename widget (change instance ID)
static renameWidget(layout: PageLayout, oldId: string, newId: string): boolean {
// 1. Check if newId already exists
const findWidget = (containers: LayoutContainer[]): boolean => {
for (const container of containers) {
if (container.widgets.some(w => w.id === newId)) {
return true;
}
if (findWidget(container.children)) {
return true;
}
}
return false;
};
if (findWidget(layout.containers)) {
console.warn(`Cannot rename widget: ID ${newId} already exists`);
return false;
}
// 2. Find and rename
const findAndRename = (containers: LayoutContainer[]): boolean => {
for (const container of containers) {
const widget = container.widgets.find(w => w.id === oldId);
if (widget) {
widget.id = newId;
return true;
}
if (findAndRename(container.children)) {
return true;
}
}
return false;
};
return findAndRename(layout.containers);
}
// Update container columns
@ -593,10 +641,47 @@ export class UnifiedLayoutManager {
try {
const parsedData = JSON.parse(jsonData) as PageLayout;
console.log('[ULM] Raw imported data:', parsedData);
// Basic validation
if (!parsedData.id || !parsedData.containers) {
throw new Error('Invalid layout data for import.');
}
// Check for widget count for debugging
let widgetCount = 0;
const countWidgets = (containers: LayoutContainer[]) => {
containers.forEach(c => {
widgetCount += c.widgets.length;
countWidgets(c.children);
});
};
countWidgets(parsedData.containers);
console.log(`[ULM] Imported layout has ${widgetCount} widgets.`);
// Sanitization: Ensure all widget IDs are unique within the imported layout
// This fixes issues where templates might contain hardcoded IDs or duplicates
const seenIds = new Set<string>();
const sanitizeIds = (containers: LayoutContainer[]) => {
containers.forEach(container => {
// Container ID check (less critical but good practice)
// Skipping container ID check for now to avoid breaking references if any
container.widgets.forEach(widget => {
if (seenIds.has(widget.id)) {
const newId = this.generateWidgetId();
console.warn(`[ULM] Found duplicate widget ID ${widget.id} during import. Regenerating to ${newId}`);
widget.id = newId;
}
seenIds.add(widget.id);
});
sanitizeIds(container.children);
});
};
sanitizeIds(parsedData.containers);
// Ensure the ID in the JSON matches the target pageId
if (parsedData.id !== pageId) {
console.warn(`[ULM] Mismatch between target pageId (${pageId}) and imported ID (${parsedData.id}). Overwriting ID.`);
@ -604,7 +689,7 @@ export class UnifiedLayoutManager {
}
// Load the existing root data
const rootData = await this.loadRootData();
const rootData = await this.loadRootData(pageId);
// Update the specific page layout
rootData.pages[pageId] = parsedData;
@ -613,7 +698,7 @@ export class UnifiedLayoutManager {
console.log('[ULM] Saving imported layout to storage:', rootData.pages[pageId]);
// Save the updated root data
await this.saveRootData(rootData);
await this.saveRootData(rootData, pageId);
return parsedData;
} catch (error) {

View File

@ -0,0 +1,75 @@
// Ported from @polymech/commons/variables.ts and @polymech/core/constants.ts
// Optimized for browser usage (no process/fs dependencies)
// standard expression for variables, eg : ${foo}
export const REGEX_VAR = /\$\{([^\s:}]+)(?::([^\s:}]+))?\}/g
// alternate expression for variables, eg : %{foo}. this is required
// to deal with parent expression parsers where '$' is reserved, eg: %{my_var}
export const REGEX_VAR_ALT = /&\{([^\s:}]+)(?::([^\s:}]+))?\}/g
// Minimal config replacement for browser
export const DEFAULT_ROOTS = {
// Add any safe defaults if needed, or leave empty for frontend
}
export const DATE_VARS = () => {
return {
YYYY: new Date(Date.now()).getFullYear(),
MM: new Date(Date.now()).getMonth() + 1,
DD: new Date(Date.now()).getDate(),
HH: new Date(Date.now()).getHours(),
SS: new Date(Date.now()).getSeconds()
}
}
export const _substitute = (template: string, map: Record<string, any>, keep: boolean = true, alt: boolean = false) => {
const transform = (k: any) => k || ''
return template.replace(alt ? REGEX_VAR_ALT : REGEX_VAR, (match, key, format) => {
if (map[key] !== undefined && map[key] !== null) {
return transform(map[key]).toString()
} else if (map[key.replace(/-/g, '_')] !== undefined && map[key.replace(/-/g, '_')] !== null) {
return transform(map[key.replace(/-/g, '_')]).toString()
} else if (keep) {
return "${" + key + "}"
} else {
return ""
}
})
}
export const substitute = (alt: boolean, template: string, vars: Record<string, any> = {}, keep: boolean = true) =>
alt ? _substitute(template, vars, keep, alt) : _substitute(template, vars, keep, alt)
export const DEFAULT_VARS = (vars: any) => {
return {
...DEFAULT_ROOTS,
...DATE_VARS(),
...vars
}
}
export const resolveVariables = (path: string, alt: boolean = false, vars: Record<string, string> = {}, keep = false) =>
substitute(alt, path, DEFAULT_VARS(vars), keep)
export const resolve = (path: string, alt: boolean = false, vars: Record<string, string> = {}, keep = false) =>
resolveVariables(path, alt, vars, keep)
export const template = (
path: string,
vars: Record<string, any> = {},
keep: boolean = false,
depth: number = 3
): string => {
const map = DEFAULT_VARS(vars)
let oldValue = path
let newValue = resolve(oldValue, false, map, keep)
let iterationCount = 0
while (newValue !== oldValue && iterationCount < depth) {
iterationCount++
oldValue = newValue
newValue = resolve(oldValue, false, map, keep)
}
return newValue
}

View File

@ -3,7 +3,7 @@ import React from 'react';
export interface WidgetMetadata {
id: string;
name: string;
category: 'control' | 'display' | 'chart' | 'system' | 'custom';
category: 'control' | 'display' | 'chart' | 'system' | 'custom' | string;
description: string;
icon?: React.ComponentType;
thumbnail?: string;
@ -25,8 +25,8 @@ class WidgetRegistry {
register(definition: WidgetDefinition) {
if (this.widgets.has(definition.metadata.id)) {
console.warn(`Widget with id '${definition.metadata.id}' already registered`);
return;
// Allow overwriting for HMR/Dynamic loading, just log info if needed
// console.debug(`Updating existing widget registration: '${definition.metadata.id}'`);
}
this.widgets.set(definition.metadata.id, definition);
}

View File

@ -0,0 +1,13 @@
import { createRoot } from "react-dom/client";
import EmbedApp from "./EmbedApp";
import "./index.css";
import { ThemeProvider } from "@/components/ThemeProvider";
// Check for initial state injected by server
const initialState = (window as any).__INITIAL_STATE__ || {};
createRoot(document.getElementById("root")!).render(
<ThemeProvider defaultTheme="light">
<EmbedApp initialState={initialState} />
</ThemeProvider>
);

21
packages/ui/src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import { createRoot } from "react-dom/client";
import { registerSW } from 'virtual:pwa-register'
import App from "./App.tsx";
// Register Service Worker
const updateSW = registerSW({
onNeedRefresh() {
// Optionally ask user to refresh, but for now we auto-update
},
onOfflineReady() {
console.log('PWA App is ready to work offline')
},
})
import "./index.css";
import { ThemeProvider } from "@/components/ThemeProvider";
createRoot(document.getElementById("root")!).render(
<ThemeProvider defaultTheme="light">
<App />
</ThemeProvider>
);

View File

@ -2,6 +2,11 @@ import React from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate, Navigate } from "react-router-dom";
import { CreationWizardPopup } from "@/components/CreationWizardPopup";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
const NewPost = () => {
const { user, loading } = useAuth();
@ -10,6 +15,7 @@ const NewPost = () => {
const [sharedTitle, setSharedTitle] = React.useState("");
const [sharedText, setSharedText] = React.useState("");
const [debugLogs, setDebugLogs] = React.useState<string[]>([]);
const [showLogs, setShowLogs] = React.useState(false);
const addLog = (msg: string) => {
console.log(msg);
@ -50,6 +56,14 @@ const NewPost = () => {
newImages = [...newImages, ...fileImages];
}
// Handle raw URL share (Mobile Share Target)
if (newImages.length === 0 && (url || (text && (text.startsWith('http://') || text.startsWith('https://'))))) {
const targetUrl = url || text;
toast.info("Processing shared link...");
const virtualItem = await processSharedUrl(targetUrl);
newImages = [virtualItem];
}
if (newImages.length > 0) {
setSharedImages(newImages);
}
@ -75,6 +89,58 @@ const NewPost = () => {
return <div className="min-h-screen bg-background pt-14 flex items-center justify-center">Loading...</div>;
}
// Helper to process URL like GlobalDragDrop does
const processSharedUrl = async (url: string) => {
addLog(`Processing shared URL: ${url}`);
try {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
addLog(`Fetching site info from: ${serverUrl}/api/serving/site-info`);
const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`);
addLog(`Response status: ${response.status}`);
let siteInfo = {};
if (response.ok) {
siteInfo = await response.json();
addLog(`Site info fetched: ${JSON.stringify(siteInfo).slice(0, 100)}...`);
} else {
const txt = await response.text();
addLog(`Failed to fetch site info: ${response.status} ${response.statusText} - ${txt}`);
// Open logs on error
setShowLogs(true);
}
const virtualItem = {
id: `shared-link-${Date.now()}`,
path: url,
src: (siteInfo as any).page?.image || 'https://picsum.photos/800/600',
title: (siteInfo as any).page?.title || (siteInfo as any).title || url,
description: (siteInfo as any).page?.description || (siteInfo as any).description || '',
type: 'page-external',
meta: siteInfo,
file: null,
selected: true
} as any;
return virtualItem;
} catch (err: any) {
addLog(`Error processing shared URL: ${err.message || err}`);
toast.error("Could not fetch link details. See debug logs.");
setShowLogs(true);
return {
id: `shared-link-${Date.now()}`,
path: url,
src: 'https://picsum.photos/800/600',
title: url,
description: '',
type: 'page-external',
meta: { url },
file: null,
selected: true
} as any;
}
};
if (!user) {
return <Navigate to="/auth" replace />;
}
@ -87,6 +153,33 @@ const NewPost = () => {
onClose={() => navigate('/')}
preloadedImages={sharedImages}
/>
{/* Debug Logs Dialog */}
<Dialog open={showLogs} onOpenChange={setShowLogs}>
<DialogContent className="max-w-md max-h-[80vh]">
<DialogHeader>
<DialogTitle>Debug Logs</DialogTitle>
<DialogDescription>Use these logs to troubleshoot connectivity issues.</DialogDescription>
</DialogHeader>
<div className="h-[300px] w-full border rounded-md p-2 bg-muted/50 overflow-auto font-mono text-xs">
{debugLogs.length === 0 ? (
<div className="text-muted-foreground italic">No logs yet...</div>
) : (
debugLogs.map((log, i) => (
<div key={i} className="border-b border-border/50 py-1 break-words">
{log}
</div>
))
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => navigator.clipboard.writeText(debugLogs.join('\n'))}>
Copy
</Button>
<Button size="sm" onClick={() => setShowLogs(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,247 @@
import React, { useEffect } from 'react';
import { GenericCanvas } from '@/components/hmi/GenericCanvas';
import { usePlaygroundLogic } from '@/hooks/usePlaygroundLogic.tsx';
import { useWebSocket } from '@/contexts/WS_Socket';
import { PlaygroundHeader } from '@/components/playground/PlaygroundHeader';
import { TemplateDialogs } from '@/components/playground/TemplateDialogs';
import { toast } from 'sonner';
import { useSelection } from '@/hooks/useSelection';
import { SelectionHandler } from '@/components/hmi/SelectionHandler';
import { WidgetPropertyPanel } from '@/components/widgets/WidgetPropertyPanel';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
const PlaygroundCanvas = () => {
const {
// State
viewMode, setViewMode,
previewHtml,
isAppReady,
isEditMode, setIsEditMode,
pageId,
pageName,
layoutJson,
templates,
isSaveDialogOpen, setIsSaveDialogOpen,
newTemplateName, setNewTemplateName,
isPasteDialogOpen, setIsPasteDialogOpen,
pasteJsonContent, setPasteJsonContent,
// Handlers
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handlePasteJson,
handleLoadContext,
handleExportHtml,
handleSendTestEmail,
htmlSize,
currentLayout,
importPageLayout
} = usePlaygroundLogic();
const { connectToServer, isConnected, wsStatus, disconnectFromServer } = useWebSocket();
const { selectedWidgetId, selectWidget, clearSelection, moveSelection } = useSelection({ pageId });
// Log "playground ready" when widgets are loaded
useEffect(() => {
if (isAppReady && isConnected) {
// We need to access the socket directly or expose a send method.
// The context currently only exposes connection methods.
// We should update the context or modbusService to allow sending generic messages.
// But modbusService.sendCommand exists.
import('@/services/modbusService').then(module => {
module.default.sendCommand('log', {
name: 'playground-canvas',
level: 'info',
message: 'Playground ready, loaded widgets',
}).catch(err => console.error('Failed to send log:', err));
});
}
}, [isAppReady, isConnected]);
// Auto-log Layout JSON on change
useEffect(() => {
if (isConnected && currentLayout) {
import('@/services/modbusService').then(module => {
const service = module.default;
service.log({
name: 'canvas-page-latest',
message: currentLayout,
options: { mode: 'overwrite', format: 'json' }
}).catch(err => console.error('Failed to log layout json:', err));
});
}
}, [currentLayout, isConnected]);
// Auto-log Preview HTML on change
useEffect(() => {
if (isConnected && previewHtml) {
import('@/services/modbusService').then(module => {
const service = module.default;
service.log({
name: 'canvas-html-latest',
message: previewHtml,
options: { mode: 'overwrite', format: 'html' }
}).catch(err => console.error('Failed to log preview html:', err));
});
}
}, [previewHtml, isConnected]);
// Handle external layout updates (from file watcher)
// Handle external layout updates (from file watcher)
useEffect(() => {
if (isConnected) {
let unsubscribe: (() => void) | undefined;
let isCancelled = false;
import('@/services/modbusService').then(module => {
if (isCancelled) return;
const service = module.default;
unsubscribe = service.addMessageHandler('layout-update', (data) => {
console.log(`[Playground] Received layout update`, typeof data);
if (typeof data === 'string') {
// Handle Base64 content (HTML/MD)
try {
const decoded = atob(data);
if (decoded.trim().startsWith('<')) {
toast.info("Received HTML update (View in logs)");
}
} catch (e) {
console.error('Failed to decode base64 layout update', e);
}
} else {
// Handle JSON Object (Layout)
importPageLayout(pageId, JSON.stringify(data)).then(() => {
console.log('[Playground] External layout applied successfully');
toast.info(`Layout updated from watcher`);
}).catch(err => {
console.error('Failed to import external layout:', err);
toast.error(`Failed to update layout from watcher`);
});
}
});
});
return () => {
isCancelled = true;
if (unsubscribe) unsubscribe();
};
}
}, [isConnected, pageId, importPageLayout]);
return (
<div className="h-[calc(100vh-3.5rem)] bg-background flex flex-col overflow-hidden">
<PlaygroundHeader
viewMode={viewMode}
setViewMode={setViewMode}
handleExportHtml={handleExportHtml}
htmlSize={htmlSize}
onSendTestEmail={handleSendTestEmail}
templates={templates}
handleLoadTemplate={handleLoadTemplate}
onSaveTemplateClick={() => setIsSaveDialogOpen(true)}
onPasteJsonClick={() => setIsPasteDialogOpen(true)}
handleDumpJson={handleDumpJson}
handleLoadContext={handleLoadContext}
isEditMode={isEditMode}
setIsEditMode={setIsEditMode}
/>
<div className="flex-1 overflow-y-auto p-0 bg-slate-50 dark:bg-slate-900/50">
{!isAppReady ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<p className="text-sm text-muted-foreground">Restoring Playground...</p>
</div>
</div>
) : (
<>
<div className={viewMode === 'design' ? 'h-full' : 'hidden'}>
<ResizablePanelGroup
direction="horizontal"
autoSaveId="playground-layout"
className="h-full"
>
<ResizablePanel defaultSize={75} minSize={50}>
<ScrollArea className="h-full">
<div className="p-8">
<SelectionHandler
onMoveSelection={moveSelection}
onClearSelection={clearSelection}
enabled={viewMode === 'design'}
/>
<GenericCanvas
pageId={pageId}
pageName={pageName}
isEditMode={isEditMode}
showControls={true}
selectedWidgetId={selectedWidgetId}
onSelectWidget={selectWidget}
/>
{layoutJson && (
<div className="mt-8 p-4 border rounded-lg bg-muted/50">
<h3 className="text-sm font-semibold mb-2">Layout JSON</h3>
<pre className="text-xs font-mono overflow-auto max-h-[400px] whitespace-pre-wrap break-all bg-background p-4 rounded border">
{layoutJson}
</pre>
</div>
)}
</div>
</ScrollArea>
</ResizablePanel>
{/* Property Panel - Desktop Only (Hidden on mobile via CSS) */}
{selectedWidgetId && isEditMode && (
<>
<ResizableHandle withHandle className="hidden md:flex" />
<ResizablePanel
defaultSize={25}
minSize={15}
maxSize={40}
className="hidden md:block"
// Ensure panel state is remembered or defaults gracefully
id="property-panel"
order={2}
>
<WidgetPropertyPanel
pageId={pageId}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={selectWidget}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
<div className={viewMode === 'preview' ? 'block h-full w-full' : 'hidden'}>
<iframe
title="Email Preview"
srcDoc={previewHtml}
className="w-full h-full border-0 bg-white"
/>
</div>
</>
)}
</div>
<TemplateDialogs
isSaveDialogOpen={isSaveDialogOpen}
setIsSaveDialogOpen={setIsSaveDialogOpen}
newTemplateName={newTemplateName}
setNewTemplateName={setNewTemplateName}
handleSaveTemplate={handleSaveTemplate}
isPasteDialogOpen={isPasteDialogOpen}
setIsPasteDialogOpen={setIsPasteDialogOpen}
pasteJsonContent={pasteJsonContent}
setPasteJsonContent={setPasteJsonContent}
handlePasteJson={handlePasteJson}
/>
</div>
);
};
export default PlaygroundCanvas;

View File

@ -955,11 +955,11 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const containerClassName = embedded
? `flex flex-col bg-background h-full ${className || ''}`
: "bg-background flex flex-col";
: "bg-background flex flex-col h-full";
return (
<div className={containerClassName}>
<div className={embedded ? "w-full" : "w-full max-w-[1600px] mx-auto"}>
<div className={embedded ? "w-full h-full" : "w-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? (
<ArticleRenderer {...rendererProps} mediaItem={mediaItem} />

View File

@ -43,7 +43,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className={props.className || ""}>
<div className={props.className || "h-full"}>
{/* Mobile Header - Controls and Info at Top */}
<div className="lg:hidden landscape:hidden py-4 bg-card ">
<CompactPostHeader

View File

@ -5,12 +5,21 @@ import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus } from "lucide-react";
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { T, translate } from "@/i18n";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
import { PageActions } from "@/components/PageActions";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { Sidebar } from "@/components/sidebar/Sidebar";
import { TableOfContents } from "@/components/sidebar/TableOfContents";
import { MobileTOC } from "@/components/sidebar/MobileTOC";
import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc";
import { useLayout } from "@/contexts/LayoutContext";
interface Page {
id: string;
title: string;
@ -18,6 +27,10 @@ interface Page {
content: any;
owner: string;
parent: string | null;
parent_page?: {
title: string;
slug: string;
} | null;
type: string | null;
tags: string[] | null;
is_public: boolean;
@ -37,20 +50,24 @@ interface UserPageProps {
userId?: string;
slug?: string;
embedded?: boolean;
initialPage?: Page;
}
const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: UserPageProps) => {
const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initialPage }: UserPageProps) => {
const { userId: paramUserId, slug: paramSlug, orgSlug } = useParams<{ userId: string; slug: string; orgSlug?: string }>();
const navigate = useNavigate();
const { user: currentUser } = useAuth();
const { getLoadedPageLayout, loadPageLayout } = useLayout();
const userId = propUserId || paramUserId;
const slug = propSlug || paramSlug;
const [page, setPage] = useState<Page | null>(null);
const [page, setPage] = useState<Page | null>(initialPage || null);
const [childPages, setChildPages] = useState<{ id: string; title: string; slug: string }[]>([]);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [isEditMode, setIsEditMode] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false);
@ -60,16 +77,45 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
const [slugValue, setSlugValue] = useState("");
const [tagsValue, setTagsValue] = useState("");
const [slugError, setSlugError] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
// TOC State
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
const isOwner = currentUser?.id === userId;
useEffect(() => {
if (initialPage) {
setLoading(false);
return;
}
if (userId && slug) {
fetchPage();
fetchUserProfile();
}
}, [userId, slug]);
}, [userId, slug, initialPage]);
const fetchChildPages = async (parentId: string) => {
try {
let query = supabase
.from('pages')
.select('id, title, slug, visible, is_public')
.eq('parent', parentId)
.order('title');
if (!isOwner) {
query = query.eq('visible', true).eq('is_public', true);
}
const { data, error } = await query;
if (error) throw error;
setChildPages(data || []);
} catch (error) {
console.error('Error fetching child pages:', error);
}
};
const fetchPage = async () => {
try {
@ -96,7 +142,15 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
return;
}
setPage(data);
setPage(data as Page);
// Fetch parent page if it exists
if (data.parent) {
fetchParentPage(data.parent);
}
// Fetch child pages
fetchChildPages(data.id);
} catch (error) {
console.error('Error fetching page:', error);
toast.error(translate('Failed to load page'));
@ -106,6 +160,72 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
}
};
const fetchParentPage = async (parentId: string) => {
try {
const { data, error } = await supabase
.from('pages')
.select('title, slug')
.eq('id', parentId)
.single();
if (error) throw error;
setPage(prev => prev ? ({
...prev,
parent_page: data
}) : null);
} catch (error) {
console.error('Error fetching parent page:', error);
}
};
// Reactive Heading Extraction
// This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context
const { loadedPages } = useLayout();
useEffect(() => {
if (!page) return;
const extract = async () => {
if (typeof page.content === 'string') {
const extracted = extractHeadings(page.content);
setHeadings(extracted);
} else {
const pageId = `page-${page.id}`;
// Try to get from context
let layout = getLoadedPageLayout(pageId);
if (!layout) {
// If not loaded yet, check if we need to trigger load
// Only trigger load if we haven't already (prevents loops if load fails)
// Actually loadPageLayout checks if loaded, so safe to call.
loadPageLayout(pageId, page.title).catch(e => console.error(e));
// We don't await here because we want this effect to re-run when loadedPages updates
} else {
const extracted = extractHeadingsFromLayout(layout);
setHeadings(extracted);
}
}
};
extract();
}, [page, loadedPages]); // Re-run when page set or any layout loads
// Hash Navigation Effect
useEffect(() => {
if (headings.length > 0 && window.location.hash) {
const id = window.location.hash.slice(1);
// Wait for next tick to ensure DOM is ready
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
}, [headings]); // Run when headings are populated
const fetchUserProfile = async () => {
try {
const { data: profile } = await supabase
@ -127,43 +247,24 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
}
};
const handleToggleVisibility = async () => {
if (!page || !isOwner) return;
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id)
.eq('owner', currentUser?.id);
if (error) throw error;
setPage({ ...page, visible: !page.visible });
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
// Actions now handled by PageActions component
const handlePageUpdate = (updatedPage: Page) => {
// Check if parent has changed
if (updatedPage.parent !== page?.parent) {
if (!updatedPage.parent) {
// Parent removed
setPage({ ...updatedPage, parent_page: null });
} else {
// Parent changed, fetch new details
setPage(updatedPage);
fetchParentPage(updatedPage.parent);
}
};
const handleTogglePublic = async () => {
if (!page || !isOwner) return;
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id)
.eq('owner', currentUser?.id);
if (error) throw error;
setPage({ ...page, is_public: !page.is_public });
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));
} catch (error) {
console.error('Error toggling public status:', error);
toast.error(translate('Failed to update page status'));
// Also refresh children if needed, though usually this affects *other* pages listing this one as child
// But if we became a child, we might want to check something.
// Actually, if we are viewing this page, its children list shouldn't change just by changing its parent,
// unless we selected one of our children as parent (which is forbidden by picker).
} else {
setPage(updatedPage);
}
};
@ -331,71 +432,84 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
}
return (
<div className={`${embedded ? 'h-full' : 'min-h-screen'} bg-background p-2`}>
<div className={embedded ? "w-full h-full overflow-y-auto scrollbar-custom p-4" : "w-full md:container md:mx-auto md:py-8"}>
{/* Header */}
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
{/* Top Header (Back button) - Fixed if not embedded */}
{!embedded && (
<div className="flex items-center justify-between mb-8">
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0">
<div className="container mx-auto py-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`)}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Back to profile</T></span>
</Button>
<div className="flex items-center gap-2">
{isOwner && (
<>
<Button
variant="outline"
size="sm"
onClick={handleToggleVisibility}
>
{page.visible ? (
<>
<Eye className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Visible</T></span>
</>
) : (
<>
<EyeOff className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Hidden</T></span>
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleTogglePublic}
>
<span className="hidden md:inline"><T>{page.is_public ? 'Public' : 'Private'}</T></span>
<span className="md:hidden"><T>{page.is_public ? 'Pub' : 'Priv'}</T></span>
</Button>
<Button
variant={isEditMode ? "default" : "outline"}
size="sm"
onClick={() => setIsEditMode(!isEditMode)}
className={isEditMode ? "bg-primary text-white" : ""}
>
{isEditMode ? (
<>
<Eye className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>View</T></span>
</>
) : (
<>
<Edit3 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Edit</T></span>
</>
)}
</Button>
</>
)}
</div>
</div>
)}
{/* Main Split Layout */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Sidebar Left - Fixed width, independent scroll */}
{(headings.length > 0 || childPages.length > 0) && (
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r bg-background/50 h-full hidden lg:flex flex-col ${embedded ? '' : 'lg:static lg:max-h-none'} shrink-0 transition-all duration-300`}>
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 bg-background/50 z-10`}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{!isSidebarCollapsed && (
<div className="overflow-y-auto flex-1 pb-4 scrollbar-custom">
{/* Child Pages List */}
{childPages.length > 0 && (
<div className="px-4 py-2 border-b mb-2">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wider text-xs"><T>Child Pages</T></h3>
<div className="flex flex-col gap-1">
{childPages.map(child => (
<Link
key={child.id}
to={orgSlug ? `/org/${orgSlug}/user/${userId}/pages/${child.slug}` : `/user/${userId}/pages/${child.slug}`}
className="text-sm py-1 px-2 rounded hover:bg-muted truncate block text-foreground/80 hover:text-primary transition-colors"
>
{child.title}
</Link>
))}
</div>
</div>
)}
{/* Table of Contents */}
{headings.length > 0 && (
<TableOfContents
headings={headings}
minHeadingLevel={2}
title=""
className="border-t-0 pt-2 px-4"
/>
)}
</div>
)}
</Sidebar>
)}
{/* Right Content - Independent scroll */}
<div className="flex-1 overflow-y-auto scrollbar-custom min-w-0 h-full">
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
{/* Mobile TOC */}
<div className="lg:hidden mb-6">
{headings.length > 0 && <MobileTOC headings={headings} />}
</div>
{/* Page Header */}
<div className="mb-8">
<div className="flex items-start gap-4 mb-4">
@ -403,6 +517,19 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
<FileText className="h-8 w-8 text-white" />
</div>
<div className="flex-1">
{/* Parent Page Eyebrow */}
{page.parent_page && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
<Link
to={orgSlug ? `/org/${orgSlug}/user/${userId}/pages/${page.parent_page.slug}` : `/user/${userId}/pages/${page.parent_page.slug}`}
className="hover:text-primary transition-colors flex items-center gap-1"
>
<FileText className="h-3 w-3" />
<span>{page.parent_page.title}</span>
</Link>
</div>
)}
{/* Editable Title */}
{editingTitle && isOwner && isEditMode ? (
<div className="flex items-center gap-2 mb-2">
@ -446,6 +573,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
</h1>
)}
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{userProfile && (
<Link
@ -464,12 +592,18 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
})}
</div>
</div>
</div>
</div>
</div>
</div>
<Separator className="my-6" />
{/* Tags and Type */}
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="space-y-3 mb-8">
<div className="flex items-center gap-2 flex-wrap w-full">
{page.type && (
<Badge variant="outline">{page.type}</Badge>
)}
@ -484,7 +618,20 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
<T>Private</T>
</Badge>
)}
<div className="ml-auto">
<PageActions
page={page}
isOwner={isOwner}
isEditMode={isEditMode}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
onPageUpdate={handlePageUpdate}
className="ml-auto"
/>
</div>
</div>
<Separator className="my-6" />
{/* Editable Tags */}
{editingTags && isOwner && isEditMode ? (
@ -524,7 +671,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
</Button>
</div>
) : (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap w-full">
{page.tags && page.tags.map((tag, index) => (
<Badge
key={index}
@ -537,86 +684,33 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
))}
{isOwner && isEditMode && (
<Button
size="sm"
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground"
onClick={handleStartEditTags}
className="h-6 px-2"
>
<Plus className="h-3 w-3 mr-1" />
<T>{page.tags && page.tags.length > 0 ? 'Edit tags' : 'Add tags'}</T>
<T>Edit Tags</T>
</Button>
)}
</div>
)}
</div>
{/* Editable Slug */}
{isOwner && isEditMode && (
<div className="mt-2">
{editingSlug ? (
<div className="flex items-start gap-2">
<div className="flex-1">
<Input
value={slugValue}
onChange={(e) => {
setSlugValue(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
setSlugError(null);
}}
className="text-sm font-mono"
placeholder="page-slug"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveSlug();
if (e.key === 'Escape') setEditingSlug(false);
}}
autoFocus
disabled={savingField === 'slug'}
/>
{slugError && (
<p className="text-xs text-destructive mt-1">{slugError}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
URL: /user/{userId}/pages/<span className="font-semibold">{slugValue || page.slug}</span>
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleSaveSlug}
disabled={savingField === 'slug'}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingSlug(false)}
disabled={savingField === 'slug'}
>
<X className="h-4 w-4" />
</Button>
{/* Content Body */}
<div>
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} />
</div>
) : (
<div
className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={handleStartEditSlug}
>
<span>Slug:</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">{page.slug}</code>
<Edit className="h-3 w-3" />
</div>
)}
</div>
)}
</div>
</div>
{/* Page Content - Widget Canvas */}
<div className="max-w-none">
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={isEditMode && isOwner}
showControls={isEditMode && isOwner}
/>
)}
</div>
{/* Footer */}
@ -641,9 +735,11 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false }: User
)}
</div>
</div>
</div>
</div>
);
</div>
</div>);
};
export default UserPage;

View File

@ -0,0 +1,151 @@
# Video Feed Playground
## Overview
A TikTok-style infinite scroll video player playground built on deobfuscated TikTok source code, integrated with our video database.
## Access
Navigate to: **`/playground/video-feed`**
## Configuration
### Environment Variables
- **`VITE_ENABLE_VIDEO_ERROR_OVERLAY`** (default: `true`)
- Set to `false` to hide video error overlays
- Useful for production or when you want a cleaner UX
- Add to `.env.local`: `VITE_ENABLE_VIDEO_ERROR_OVERLAY=false`
- Errors will still be logged to console
## Features
### ✅ Currently Implemented
- **Infinite Scroll**: Vertical scrolling through videos with snap-to-view behavior
- **Video Playback**:
- **HLS Support**: Uses Vidstack MediaPlayer for Mux HLS video streams
- Full video controls (play/pause, seek, volume, fullscreen)
- Auto-loop enabled
- Responsive video player
- **Action Bar**: Like, comment, share, bookmark buttons (UI only)
- **Video Metadata**: Title, description, author info overlay
- **Real User Data**:
- ✅ Actual user avatars from profiles table
- ✅ Real display names, usernames from database
- ✅ Actual likes count from database
- ✅ Real comments count from database
- **Keyboard Controls**:
- `↑/↓` - Navigate videos
- Vidstack's built-in controls for playback
- **Auto-play**: Videos auto-play when in view
- **Progress Indicator**: Visual indicators on the right showing current video
- **Loading States**: Smooth loading and error states
### 🚧 Mocked/Not Implemented Yet
The following features are mocked with placeholder data:
- **Stats** (partially mocked):
- ✅ Likes count - **REAL** from database
- ✅ Comments count - **REAL** from database
- ❌ Play count - Mocked (proportional to likes)
- ❌ Share count - Mocked (proportional to comments)
- ❌ Bookmark count - Mocked (proportional to likes)
- **Subtitles/Captions**: Types exist, but no actual subtitle rendering
- **Music/Audio Info**: Shows mock music data
- **Challenges/Hashtags**: Extracted from description hashtags (partially real)
- **Effects**: No video effects applied
- **Interactive Actions**:
- Comment button - UI only, shows count but doesn't open comments
- Like button - Shows real count, but clicking doesn't save
- Share button - Copies URL to clipboard
- Follow button - UI only, not functional
- Bookmark button - UI only, not functional
- **Privacy Controls**: PNS (Privacy & Network Security) features omitted
## Data Flow
1. **Fetches videos** from database using `fetchMediaItems()` from `@/utils/mediaUtils`
- Queries `pictures` table filtering for `type = 'mux-video'`
- Gets real likes_count and comments_count
2. **Fetches user profiles** from `profiles` table
- Gets avatar_url, display_name, username, email
- Maps profiles to video authors
3. **Transforms** our `MediaItem` format to TikTok's `VideoItem` format
4. **Mixes real and mocked data**:
- ✅ **Real**: User avatars, display names, likes count, comments count
- ❌ **Mocked**: Music info, play count, share count, bookmark count
- ⚠️ **Extracted**: Challenges from description hashtags
## Architecture
```
VideoFeedPlayground.tsx
├── Fetches videos from database
├── Transforms to TikTok format
└── Renders VideoFeed
├── useVideoPlayer (playback state)
├── useInfiniteScroll (scroll behavior)
└── VideoItem[]
├── VideoPlayer (video element + controls)
├── VideoActionBar (right side actions)
└── VideoMetadata (bottom overlay)
```
## Adding Features
### To Implement Subtitles
1. Add subtitle file URLs to video metadata
2. Fetch/parse WebVTT files using `src/player/utils/webvtt.ts`
3. Enable `showSubtitles` prop in `VideoPlayer`
4. Subtitles will render automatically
### To Connect Comments
1. Import comment components from `src/components/Comments.tsx`
2. Replace mock comment panel in `VideoFeed.tsx`
3. Connect to existing comment system
### To Add Real Music
1. Add music metadata to video records
2. Extract from video files or link separately
3. Update transform function in `VideoFeedPlayground.tsx`
## Technical Details
### Video Player Implementation
The player uses **Vidstack MediaPlayer** instead of native HTML5 `<video>` elements because:
- **HLS Support**: Mux videos are HLS streams (`.m3u8`) which need special handling
- **Cross-browser**: Works on all modern browsers without additional libraries
- **Built-in Controls**: Professional video controls out of the box
- **Already Installed**: Your project already uses `@vidstack/react` for VideoCard
The original TikTok player design used native HTML5 video, but that doesn't support HLS streams on most browsers. Vidstack provides the necessary HLS support while maintaining a clean interface.
## Known Limitations
- **Performance**: Not optimized for very large video counts (>100)
- **Mobile**: Touch gestures not fully implemented
- **Analytics**: No tracking/analytics hooked up yet
- **Custom Controls**: Using Vidstack's default controls instead of fully custom TikTok-style overlay
## Next Steps
1. **Implement Subtitles**: Add WebVTT subtitle support
2. **Connect Comments**: Link to existing comment system
3. **Add Video Upload**: Direct integration with upload flow
4. **Performance**: Add video preloading and memory management
5. **Mobile UX**: Swipe gestures, better mobile controls
6. **Analytics**: Track watch time, engagement
## Development Notes
- Player is in development mode (no build required)
- Component hot-reloading works
- Check console for video playback errors
- Uses existing database schema (no migrations needed)

View File

@ -0,0 +1,127 @@
/**
* Simple Video Player Component
* Simplified version for immediate testing
*/
import React, { useRef, useState, useEffect } from 'react';
interface SimpleVideoPlayerProps {
src: string;
poster?: string;
isActive: boolean;
muted?: boolean;
onPlay?: () => void;
onPause?: () => void;
}
export const SimpleVideoPlayer: React.FC<SimpleVideoPlayerProps> = ({
src,
poster,
isActive,
muted = true,
onPlay,
onPause
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
// Auto-play when video becomes active
useEffect(() => {
if (!videoRef.current) return;
const video = videoRef.current;
if (isActive && !isPlaying) {
video.play().then(() => {
setIsPlaying(true);
onPlay?.();
}).catch(console.error);
} else if (!isActive && isPlaying) {
video.pause();
setIsPlaying(false);
onPause?.();
}
}, [isActive, isPlaying, onPlay, onPause]);
// Handle time updates
const handleTimeUpdate = () => {
if (!videoRef.current) return;
const video = videoRef.current;
if (video.duration) {
setProgress(video.currentTime / video.duration);
}
};
// Handle metadata loaded
const handleLoadedMetadata = () => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
};
// Toggle play/pause
const togglePlay = () => {
if (!videoRef.current) return;
const video = videoRef.current;
if (isPlaying) {
video.pause();
setIsPlaying(false);
onPause?.();
} else {
video.play().then(() => {
setIsPlaying(true);
onPlay?.();
}).catch(console.error);
}
};
return (
<div className="relative w-full h-full bg-black">
<video
ref={videoRef}
className="w-full h-full object-cover"
src={src}
poster={poster}
muted={muted}
playsInline
preload="metadata"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onClick={togglePlay}
/>
{/* Simple Play/Pause Overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={togglePlay}
className={`bg-black/30 backdrop-blur-sm rounded-full p-4 text-white hover:bg-black/50 transition-all duration-200 ${
isActive ? 'opacity-0 hover:opacity-100' : 'opacity-100'
}`}
>
{isPlaying ? (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</button>
</div>
{/* Progress Bar */}
<div className="absolute bottom-4 left-4 right-4">
<div className="w-full h-1 bg-white/20 rounded-full">
<div
className="h-full bg-white rounded-full transition-all duration-100"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,202 @@
/**
* Video Action Bar Component (Right Side)
* Based on deobfuscated TikTok action bar implementation
*/
import React, { useState } from 'react';
import { VideoItem } from '../types';
import { formatCount, getRelativeTime } from '../utils';
interface VideoActionBarProps {
video: VideoItem;
isLiked?: boolean;
onLike?: () => void;
onComment?: () => void;
onShare?: () => void;
onBookmark?: () => void;
onFollow?: () => void;
onAvatarClick?: (userId: string) => void;
className?: string;
}
export const VideoActionBar: React.FC<VideoActionBarProps> = ({
video,
isLiked,
onLike,
onComment,
onShare,
onBookmark,
onFollow,
onAvatarClick,
className = ''
}) => {
const [isFollowing, setIsFollowing] = useState(false);
const [isBookmarked, setIsBookmarked] = useState(false);
const handleLike = () => {
onLike?.();
};
const handleFollow = () => {
setIsFollowing(!isFollowing);
onFollow?.();
};
const handleBookmark = () => {
setIsBookmarked(!isBookmarked);
onBookmark?.();
};
return (
<div className={`flex flex-col items-center justify-end space-y-4 p-4 min-w-[80px] ${className}`}>
{/* Author Avatar and Follow Button */}
<div className="flex flex-col items-center space-y-2">
<button
onClick={() => onAvatarClick?.(video.author.id)}
className="relative focus:outline-none focus:ring-2 focus:ring-white rounded-full"
aria-label={`View profile of ${video.author.nickname}`}
>
<img
src={video.author.avatarThumb}
alt={video.author.nickname}
className="w-12 h-12 rounded-full border-2 border-white/20 object-cover"
loading="lazy"
/>
{/* Verified Badge */}
{video.author.verified && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 24 24" fill="white">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
)}
</button>
{/* Follow Button */}
<button
onClick={handleFollow}
className={`w-6 h-6 rounded-full flex items-center justify-center text-white transition-all duration-200 hover:scale-110 ${
isFollowing
? 'bg-gray-600 hover:bg-gray-700'
: 'bg-tiktok-red hover:bg-red-600'
}`}
aria-label={isFollowing ? "Unfollow" : "Follow"}
>
{isFollowing ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
)}
</button>
</div>
{/* Action Buttons */}
<div className="flex flex-col items-center space-y-6">
{/* Like Button */}
<button
onClick={onLike}
className={`flex flex-col items-center space-y-1 transition-colors group ${
isLiked ? 'text-tiktok-red' : 'text-white hover:text-tiktok-red'
}`}
aria-label={`${isLiked ? 'Unlike' : 'Like'} video`}
>
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
<svg width="28" height="28" viewBox="0 0 24 24" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" strokeWidth={isLiked ? 0 : 2}>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
<span className="text-xs font-semibold">
{formatCount(video.stats.diggCount || 0)}
</span>
</button>
{/* Comment Button */}
<button
onClick={onComment}
className="flex flex-col items-center space-y-1 text-white hover:text-blue-400 transition-colors group"
aria-label="View comments"
>
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.99 4c0-1.1-.89-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/>
</svg>
</div>
<span className="text-xs font-semibold">
{formatCount(video.stats.commentCount || 0)}
</span>
</button>
{/* Bookmark Button */}
<button
onClick={handleBookmark}
className="flex flex-col items-center space-y-1 text-white hover:text-yellow-400 transition-colors group"
aria-label={`${isBookmarked ? 'Remove bookmark' : 'Bookmark'} video`}
>
<div className={`p-2 rounded-full transition-all duration-200 group-hover:scale-110 ${
isBookmarked ? 'text-yellow-400' : ''
}`}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<span className="text-xs font-semibold">
{formatCount(video.stats.collectCount || 0)}
</span>
</button>
{/* Share Button */}
<button
onClick={onShare}
className="flex flex-col items-center space-y-1 text-white hover:text-green-400 transition-colors group"
aria-label="Share video"
>
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
</div>
<span className="text-xs font-semibold">
{formatCount(video.stats.shareCount || 0)}
</span>
</button>
{/* Music Disc */}
{video.music && (
<div className="flex flex-col items-center space-y-1">
<div
className="w-10 h-10 rounded-full bg-cover bg-center animate-spin-slow border-2 border-white/20 cursor-pointer hover:scale-110 transition-transform"
style={{
backgroundImage: `url("${video.music.coverMedium}")`,
animationDuration: '3s'
}}
title={`${video.music.title} - ${video.music.authorName}`}
/>
</div>
)}
</div>
{/* Video Stats (Play Count, Time) */}
<div className="flex flex-col items-center space-y-1 text-white/70 text-xs mt-4">
<div className="flex items-center space-x-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<span>{formatCount(video.stats.playCount || 0)}</span>
</div>
{video.createTime && (
<div className="text-center">
{getRelativeTime(video.createTime)}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,304 @@
/**
* Video Feed Container Component
* Based on deobfuscated TikTok infinite scroll implementation
*/
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { VideoItem } from '../types';
import { useInfiniteScroll } from '../hooks';
import { VideoPlayer } from './VideoPlayer';
import { VideoActionBar } from './VideoActionBar';
import { VideoMetadata } from './VideoMetadata';
import Comments from '@/components/Comments';
import type { MediaPlayerInstance } from '@vidstack/react';
interface VideoFeedProps {
videos: VideoItem[];
likedVideos?: Set<string>;
onLoadMore?: () => void;
hasMore?: boolean;
loading?: boolean;
autoplay?: boolean;
muted?: boolean;
onVideoChange?: (index: number) => void;
onLike?: (videoId: string) => void;
onComment?: (videoId: string) => void;
onShare?: (videoId: string) => void;
onFollow?: (userId: string) => void;
onAvatarClick?: (userId: string) => void;
className?: string;
}
export const VideoFeed: React.FC<VideoFeedProps> = ({
videos,
likedVideos,
onLoadMore,
hasMore = true,
loading = false,
autoplay = true,
muted = true,
onVideoChange,
onLike,
onComment,
onShare,
onFollow,
onAvatarClick,
className = ''
}) => {
const [showComments, setShowComments] = useState(false);
const [activeCommentVideoId, setActiveCommentVideoId] = useState<string | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [isMuted, setIsMuted] = useState(muted);
const [isFirstPlay, setIsFirstPlay] = useState(false); // Set to false to enable autoplay from the start
const playerRefs = useRef<(MediaPlayerInstance | null)[]>([]);
// Infinite scroll hook
const infiniteScroll = useInfiniteScroll(videos, {
onLoadMore,
hasMore,
onVideoChange: (index) => {
if (index !== currentIndex) {
setCurrentIndex(index);
onVideoChange?.(index);
}
}
});
useEffect(() => {
playerRefs.current = playerRefs.current.slice(0, videos.length);
}, [videos]);
useEffect(() => {
// Pause all other videos when currentIndex changes
playerRefs.current.forEach((player, index) => {
if (player && index !== currentIndex) {
player.pause();
}
});
// The active video will autoplay via the `autoPlay` prop on the VideoPlayer
}, [currentIndex, videos]);
// Handle comment button click
const handleCommentClick = useCallback((videoId: string) => {
setActiveCommentVideoId(videoId);
setShowComments(true);
onComment?.(videoId);
}, [onComment]);
// Handle share button click
const handleShareClick = useCallback((videoId: string) => {
// Copy video URL to clipboard
const videoUrl = `${window.location.origin}/video/${videoId}`;
navigator.clipboard?.writeText(videoUrl).then(() => {
console.log('Video URL copied to clipboard');
});
onShare?.(videoId);
}, [onShare]);
const togglePlay = () => {
const currentPlayer = playerRefs.current[currentIndex];
if (currentPlayer) {
if (currentPlayer.state.playing) {
currentPlayer.pause();
} else {
currentPlayer.play().catch(e => console.warn("Toggle play failed", e));
}
}
};
const toggleMute = () => {
const newMuted = !isMuted;
setIsMuted(newMuted);
};
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case ' ':
event.preventDefault();
togglePlay();
break;
case 'ArrowUp':
event.preventDefault();
if (infiniteScroll.prevVideo) {
infiniteScroll.prevVideo();
}
break;
case 'ArrowDown':
event.preventDefault();
if (infiniteScroll.nextVideo) {
infiniteScroll.nextVideo();
}
break;
case 'm':
case 'M':
event.preventDefault();
toggleMute();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [infiniteScroll, togglePlay, toggleMute]);
return (
<div className={`h-screen w-full bg-black ${className}`}>
{/* Main Video Feed Container */}
<div
ref={infiniteScroll.containerRef}
className="h-full w-full overflow-y-scroll overflow-x-hidden snap-y snap-mandatory scrollbar-hide"
style={{ scrollBehavior: 'smooth' }}
>
{videos.map((video, index) => {
const isActive = index === currentIndex;
return (
<article
key={video.id}
ref={(el) => {
if (infiniteScroll.registerVideoRef) {
infiniteScroll.registerVideoRef(index, el);
}
}}
data-scroll-index={index}
className="h-screen w-full snap-start flex relative"
style={{ scrollSnapAlign: 'start' }}
>
{/* Main content container (video + metadata) */}
<div className="flex-1 min-w-0 h-full relative" onClick={togglePlay}>
<VideoPlayer
video={video}
isActive={isActive}
isMuted={isMuted}
autoPlay={isActive && !isFirstPlay}
onPlayerChange={(player) => {
playerRefs.current[index] = player;
}}
/>
{/* Video Metadata Overlay */}
<VideoMetadata
video={video}
onHashtagClick={(hashtag) => {
console.log('Hashtag clicked:', hashtag);
// Handle hashtag navigation
}}
onMentionClick={(username) => {
console.log('Mention clicked:', username);
// Handle user profile navigation
}}
/>
{/* "Click to Play" overlay is removed to allow autoplay */}
{/* Loading Indicator */}
{/* Implement a loading state based on player.state.waiting */}
{/* Error State - Can be disabled with VITE_ENABLE_VIDEO_ERROR_OVERLAY=false */}
{/* Implement error state based on player.state.error */}
</div>
{/* Action Bar */}
<VideoActionBar
video={video}
isLiked={likedVideos?.has(video.id)}
onLike={() => onLike?.(video.id)}
onComment={() => handleCommentClick(video.id)}
onShare={() => handleShareClick(video.id)}
onBookmark={() => console.log('Bookmark:', video.id)}
onFollow={() => onFollow?.(video.author.id || '')}
onAvatarClick={onAvatarClick}
className="absolute bottom-24 right-2 z-20"
/>
</article>
);
})}
{/* Loading More Indicator */}
{loading && (
<div className="h-screen flex items-center justify-center bg-black">
<div className="text-white text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
<div>Loading more videos...</div>
</div>
</div>
)}
{/* End of Feed */}
{!hasMore && videos.length > 0 && (
<div className="h-screen flex items-center justify-center bg-black">
<div className="text-white text-center">
<div className="text-2xl mb-2">🎉</div>
<div>You've reached the end!</div>
<button
onClick={() => {
if (infiniteScroll.scrollToVideo) {
infiniteScroll.scrollToVideo(0);
}
}}
className="mt-4 px-6 py-2 bg-tiktok-red rounded-full text-white hover:bg-red-600 transition-colors"
>
Back to Top
</button>
</div>
</div>
)}
</div>
{/* Navigation Indicators */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20 flex flex-col space-y-1">
{videos.slice(
Math.max(0, currentIndex - 2),
currentIndex + 3
).map((_, relativeIndex) => {
const actualIndex = Math.max(0, currentIndex - 2) + relativeIndex;
const isActive = actualIndex === currentIndex;
return (
<button
key={actualIndex}
onClick={() => {
if (infiniteScroll.scrollToVideo) {
infiniteScroll.scrollToVideo(actualIndex);
}
}}
className={`w-1 h-6 rounded-full transition-all duration-200 ${
isActive
? 'bg-white'
: 'bg-white/30 hover:bg-white/50'
}`}
aria-label={`Go to video ${actualIndex + 1}`}
/>
);
})}
</div>
{/* Comments Panel (placeholder) */}
{showComments && activeCommentVideoId && (
<div className="absolute inset-0 bg-black/50 z-30 flex items-end">
<div className="w-full h-2/3 bg-background text-foreground rounded-t-2xl p-4 overflow-y-auto">
<div className="flex justify-between items-center mb-4 sticky top-0 bg-background py-2">
<h3 className="text-lg font-semibold">Comments</h3>
<button
onClick={() => setShowComments(false)}
className="p-2 hover:bg-accent rounded-full"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<Comments pictureId={activeCommentVideoId} />
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,215 @@
/**
* Video Metadata Overlay Component
* Based on deobfuscated TikTok video metadata display
*/
import React, { useState } from 'react';
import { VideoItem } from '../types';
interface VideoMetadataProps {
video: VideoItem;
onHashtagClick?: (hashtag: string) => void;
onMentionClick?: (username: string) => void;
className?: string;
}
export const VideoMetadata: React.FC<VideoMetadataProps> = ({
video,
onHashtagClick,
onMentionClick,
className = ''
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// Parse description with hashtags and mentions
const parseDescription = (text: string) => {
if (!text) return [];
const parts = [];
const hashtagRegex = /#(\w+)/g;
const mentionRegex = /@(\w+)/g;
let lastIndex = 0;
const matches = [];
// Find all hashtags and mentions
let match;
while ((match = hashtagRegex.exec(text)) !== null) {
matches.push({
type: 'hashtag',
content: match[0],
value: match[1],
index: match.index
});
}
// Reset regex
hashtagRegex.lastIndex = 0;
while ((match = mentionRegex.exec(text)) !== null) {
matches.push({
type: 'mention',
content: match[0],
value: match[1],
index: match.index
});
}
// Sort matches by index
matches.sort((a, b) => a.index - b.index);
// Build parts array
matches.forEach((match, i) => {
// Add text before match
if (match.index > lastIndex) {
parts.push({
type: 'text',
content: text.slice(lastIndex, match.index)
});
}
// Add match
parts.push(match);
lastIndex = match.index + match.content.length;
});
// Add remaining text
if (lastIndex < text.length) {
parts.push({
type: 'text',
content: text.slice(lastIndex)
});
}
return parts;
};
const descriptionParts = parseDescription(video.desc || '');
const shouldShowMore = (video.desc || '').length > 100;
return (
<div className={`absolute bottom-4 left-4 right-24 z-30 ${className}`}>
<div className="space-y-2">
{/* Author Info */}
<div className="flex items-center space-x-2">
<button
className="text-white font-semibold hover:underline focus:outline-none focus:underline transition-colors"
onClick={() => onMentionClick?.(video.author.uniqueId || '')}
>
@{video.author.uniqueId || video.author.nickname}
</button>
{video.author.verified && (
<div className="w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 24 24" fill="white">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
)}
</div>
{/* Video Description */}
{video.desc && (
<div className="text-white">
<div
className={`text-sm leading-relaxed whitespace-pre-wrap ${
isExpanded ? '' : 'line-clamp-2'
}`}
>
{descriptionParts.map((part, index) => {
if (part.type === 'hashtag') {
return (
<button
key={index}
onClick={() => onHashtagClick?.(part.value)}
className="text-blue-300 hover:text-blue-200 font-semibold transition-colors"
>
{part.content}
</button>
);
} else if (part.type === 'mention') {
return (
<button
key={index}
onClick={() => onMentionClick?.(part.value)}
className="text-blue-300 hover:text-blue-200 font-semibold transition-colors"
>
{part.content}
</button>
);
} else {
return <span key={index}>{part.content}</span>;
}
})}
</div>
{shouldShowMore && !isExpanded && (
<button
onClick={() => setIsExpanded(true)}
className="text-white/70 hover:text-white text-sm mt-1 transition-colors"
>
more
</button>
)}
</div>
)}
{/* Challenges/Hashtags */}
{video.challenges && video.challenges.length > 0 && (
<div className="flex flex-wrap gap-2">
{video.challenges.map((challenge) => (
<button
key={challenge.id}
onClick={() => onHashtagClick?.(challenge.title)}
className="bg-black/20 backdrop-blur-sm rounded-full px-3 py-1 text-xs text-white hover:bg-black/40 transition-colors"
>
#{challenge.title}
</button>
))}
</div>
)}
{/* Effects and Music Info */}
<div className="flex items-center space-x-3 text-white/80 text-xs">
{/* Effect Stickers */}
{video.effectStickers && video.effectStickers.length > 0 && (
<div className="flex items-center space-x-1 bg-black/20 backdrop-blur-sm rounded-full px-2 py-1">
<img
src={video.effectStickers[0].iconUrl}
alt="Effect"
className="w-4 h-4 rounded"
/>
<span>{video.effectStickers[0].name}</span>
</div>
)}
{/* Music Info */}
{video.music && (
<div className="flex items-center space-x-1 bg-black/20 backdrop-blur-sm rounded-full px-2 py-1 max-w-xs">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
<span className="truncate">
{video.music.title} - {video.music.authorName}
</span>
</div>
)}
</div>
{/* Video Duration Badge */}
{video.video.duration && (
<div className="inline-flex items-center space-x-1 bg-black/40 backdrop-blur-sm rounded px-2 py-1 text-xs text-white">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
<path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
<span>{Math.floor(video.video.duration)}s</span>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,86 @@
/**
* Main Video Player Component
* Based on deobfuscated TikTok video player implementation
*/
import React, { useRef, useEffect } from 'react';
import { VideoItem } from '../types';
import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
// Import Vidstack styles
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
interface VideoPlayerProps {
video: VideoItem;
isActive: boolean;
isMuted: boolean;
autoPlay: boolean;
onPlayerChange: (player: MediaPlayerInstance | null) => void;
className?: string;
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
video,
isActive,
isMuted,
autoPlay,
onPlayerChange,
className = ''
}) => {
const player = useRef<MediaPlayerInstance>(null);
console.log('autoPlay ' + video.video.playAddr, autoPlay, video);
useEffect(() => {
// player.current.muted = false;
}, []);
useEffect(() => {
// Pass the player instance to the parent component.
onPlayerChange(player.current);
}, [onPlayerChange]);
useEffect(() => {
// This effect ensures that the mute state of the player is always in sync
// with the global mute state managed by the VideoFeed component.
if (player.current) {
player.current.muted = isMuted;
}
}, [isMuted]);
return (
<div className={`w-full h-full bg-black flex justify-center items-center ${className}`}>
{/* Video Player - Using Vidstack for HLS support */}
<MediaPlayer
ref={player}
title={video.desc || 'Video'}
src={
video.video.playAddr.includes('/api/videos/')
? { src: video.video.playAddr, type: 'video/mp4' }
: video.video.playAddr
}
poster={video.video.cover}
playsInline
loop
muted={false}
autoPlay={true}
load={isActive ? "eager" : "idle"}
posterLoad="eager"
crossOrigin="anonymous"
className="w-full h-full"
style={{
'--video-brand': '#ff0050',
'--media-object-fit': 'contain',
'--media-object-position': 'center'
} as any}
>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
noScrubGesture
/>
</MediaPlayer>
</div>
);
};

View File

@ -0,0 +1,8 @@
/**
* Components exports for TikTok Video Player
*/
export * from './VideoPlayer';
export * from './VideoActionBar';
export * from './VideoMetadata';
export * from './VideoFeed';

View File

@ -0,0 +1,229 @@
/**
* Mock data for TikTok Video Player
* Includes sample videos, users, and comments for testing
*/
import { VideoItem, CommentItem } from '../types';
// Mock video URLs - you can replace these with your Mux playback IDs
const SAMPLE_VIDEOS = [
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4'
];
const SAMPLE_COVERS = [
'https://images.unsplash.com/photo-1611095790444-1dfa35e37b52?w=400&h=600&fit=crop',
'https://images.unsplash.com/photo-1611095790790-d4c4d8d0c7c7?w=400&h=600&fit=crop',
'https://images.unsplash.com/photo-1611095791146-1c5d7b1c7b1c?w=400&h=600&fit=crop',
'https://images.unsplash.com/photo-1611095791234-1234567890ab?w=400&h=600&fit=crop',
'https://images.unsplash.com/photo-1611095791345-abcdef123456?w=400&h=600&fit=crop'
];
const SAMPLE_AVATARS = [
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=100&h=100&fit=crop&crop=face',
'https://images.unsplash.com/photo-1547425260-76bcadfb4f2c?w=100&h=100&fit=crop&crop=face'
];
const SAMPLE_USERNAMES = [
'creativecoder',
'designguru',
'techexplorer',
'artlover',
'musicmaker',
'dancequeen',
'comedyking',
'foodie_life',
'travel_addict',
'fitness_pro',
'bookworm',
'gamer_elite',
'nature_lover',
'city_explorer',
'vintage_vibes'
];
const SAMPLE_DESCRIPTIONS = [
'Just discovered this amazing technique! 🔥 #coding #webdev #react',
'When you finally fix that bug that\'s been haunting you for days 😅 #programming #developer',
'Building something cool with React and TypeScript ⚡ #frontend #typescript',
'The satisfaction of clean code 💯 #cleancode #javascript #programming',
'Late night coding session vibes 🌙 #coding #developer #nightowl',
'React hooks make everything so much easier! 🪝 #react #hooks #webdev',
'CSS animations are pure magic ✨ #css #animation #frontend',
'Debugging: 50% coding, 50% detective work 🕵️ #debugging #programming',
'When your code works on the first try 🎉 #coding #success #developer',
'TypeScript saves the day again! 💪 #typescript #javascript #webdev',
'Component reusability for the win! 🏆 #react #components #frontend',
'The joy of solving complex algorithms 🧠 #algorithms #coding #problem-solving',
'Responsive design is an art form 🎨 #responsive #css #webdesign',
'API integration made simple 🔌 #api #backend #integration',
'Performance optimization tips that actually work! ⚡ #performance #optimization'
];
const SAMPLE_MUSIC = [
{
id: 'music_1',
title: 'Coding Beats',
playUrl: 'https://example.com/music1.mp3',
coverMedium: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=100&h=100&fit=crop',
authorName: 'LoFi Producer'
},
{
id: 'music_2',
title: 'Tech Vibes',
playUrl: 'https://example.com/music2.mp3',
coverMedium: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop',
authorName: 'Electronic Artist'
}
];
const SAMPLE_CHALLENGES = [
{ id: 'challenge_1', title: 'CodeChallenge', desc: 'Show your coding skills' },
{ id: 'challenge_2', title: 'ReactTips', desc: 'Share React development tips' },
{ id: 'challenge_3', title: 'WebDevLife', desc: 'Life as a web developer' }
];
// Generate random stats
const generateStats = () => ({
diggCount: Math.floor(Math.random() * 100000) + 1000,
playCount: Math.floor(Math.random() * 1000000) + 10000,
shareCount: Math.floor(Math.random() * 10000) + 100,
commentCount: Math.floor(Math.random() * 5000) + 50,
collectCount: Math.floor(Math.random() * 20000) + 200
});
// Generate mock video data
export const generateMockVideos = (count: number = 20): VideoItem[] => {
return Array.from({ length: count }, (_, index) => {
const authorIndex = index % SAMPLE_USERNAMES.length;
const videoIndex = index % SAMPLE_VIDEOS.length;
const coverIndex = index % SAMPLE_COVERS.length;
return {
id: `video_${index + 1}`,
author: {
nickname: SAMPLE_USERNAMES[authorIndex],
uniqueId: SAMPLE_USERNAMES[authorIndex].toLowerCase(),
id: `user_${authorIndex + 1}`,
secUid: `sec_${authorIndex + 1}`,
avatarThumb: SAMPLE_AVATARS[authorIndex % SAMPLE_AVATARS.length],
verified: Math.random() > 0.7
},
video: {
width: 720,
height: 1280,
duration: Math.floor(Math.random() * 60) + 15, // 15-75 seconds
ratio: '9:16',
playAddr: SAMPLE_VIDEOS[videoIndex],
downloadAddr: SAMPLE_VIDEOS[videoIndex],
cover: SAMPLE_COVERS[coverIndex],
dynamicCover: SAMPLE_COVERS[coverIndex]
},
stats: generateStats(),
desc: SAMPLE_DESCRIPTIONS[index % SAMPLE_DESCRIPTIONS.length],
createTime: Date.now() - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000), // Within last week
isPinnedItem: Math.random() > 0.9,
music: SAMPLE_MUSIC[index % SAMPLE_MUSIC.length],
challenges: Math.random() > 0.5 ? [SAMPLE_CHALLENGES[index % SAMPLE_CHALLENGES.length]] : [],
effectStickers: Math.random() > 0.7 ? [{
id: `effect_${index}`,
name: 'Cool Effect',
iconUrl: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=50&h=50&fit=crop'
}] : []
};
});
};
// Mock comments data
const SAMPLE_COMMENT_TEXTS = [
'This is amazing! 🔥',
'Love this content! Keep it up 💪',
'So helpful, thank you!',
'Can you make a tutorial on this?',
'Mind blown 🤯',
'This changed my perspective completely',
'Incredible work! 👏',
'I need to try this right now',
'Best explanation I\'ve seen',
'You\'re so talented! ✨',
'This deserves more views',
'Saving this for later 📌',
'How did you learn this?',
'Pure genius! 🧠',
'This is exactly what I needed'
];
export const generateMockComments = (videoId: string, count: number = 10): CommentItem[] => {
return Array.from({ length: count }, (_, index) => {
const authorIndex = index % SAMPLE_USERNAMES.length;
const hasReplies = Math.random() > 0.7;
const comment: CommentItem = {
cid: `comment_${videoId}_${index + 1}`,
user: `user_${authorIndex + 1}`,
text: SAMPLE_COMMENT_TEXTS[index % SAMPLE_COMMENT_TEXTS.length],
createTime: Date.now() - Math.floor(Math.random() * 24 * 60 * 60 * 1000), // Within last day
user_digged: Math.random() > 0.8,
is_author_digged: Math.random() > 0.9,
digg_count: Math.floor(Math.random() * 1000) + 1,
reply_comment_total: hasReplies ? Math.floor(Math.random() * 5) + 1 : 0,
comment_language: 'en-US'
};
// Add replies if needed
if (hasReplies) {
comment.reply_comment = Array.from({ length: comment.reply_comment_total }, (_, replyIndex) => ({
cid: `reply_${videoId}_${index}_${replyIndex + 1}`,
user: `user_${(replyIndex + 5) % SAMPLE_USERNAMES.length + 1}`,
text: `@${SAMPLE_USERNAMES[authorIndex]} ${SAMPLE_COMMENT_TEXTS[(replyIndex + 5) % SAMPLE_COMMENT_TEXTS.length]}`,
createTime: comment.createTime + (replyIndex + 1) * 60000, // Replies come after original
user_digged: Math.random() > 0.9,
is_author_digged: Math.random() > 0.95,
digg_count: Math.floor(Math.random() * 100) + 1,
reply_comment_total: 0,
comment_language: 'en-US'
}));
}
return comment;
});
};
// Mock user data
export const generateMockUser = (userId: string) => {
const userIndex = parseInt(userId.replace('user_', '')) - 1;
const usernameIndex = userIndex % SAMPLE_USERNAMES.length;
return {
id: userId,
uniqueId: SAMPLE_USERNAMES[usernameIndex].toLowerCase(),
nickname: SAMPLE_USERNAMES[usernameIndex],
avatarThumb: SAMPLE_AVATARS[userIndex % SAMPLE_AVATARS.length],
verified: Math.random() > 0.7,
followerCount: Math.floor(Math.random() * 1000000) + 1000,
followingCount: Math.floor(Math.random() * 1000) + 50,
heartCount: Math.floor(Math.random() * 10000000) + 100000,
videoCount: Math.floor(Math.random() * 500) + 10
};
};
// Export default mock data
export const MOCK_VIDEOS = generateMockVideos(50);
export const MOCK_COMMENTS = MOCK_VIDEOS.reduce((acc, video) => {
acc[video.id] = generateMockComments(video.id);
return acc;
}, {} as Record<string, CommentItem[]>);

View File

@ -0,0 +1,5 @@
/**
* Hooks exports for TikTok Video Player
*/
export * from './useInfiniteScroll';

View File

@ -0,0 +1,204 @@
/**
* Infinite Scroll Hook
* Based on deobfuscated TikTok infinite scroll mechanism
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { UseInfiniteScrollReturn, VideoItem } from '../types/index.js';
import { throttle, getElementCenterDistance } from '../utils';
interface UseInfiniteScrollOptions {
onLoadMore?: () => void;
hasMore?: boolean;
threshold?: number;
preloadDistance?: number;
onVideoChange?: (index: number) => void;
}
export function useInfiniteScroll(
videos: VideoItem[],
options: UseInfiniteScrollOptions = {}
): UseInfiniteScrollReturn {
const {
onLoadMore,
hasMore = true,
threshold = 0.8,
preloadDistance = 6,
onVideoChange
} = options;
const [currentIndex, setCurrentIndex] = useState(0);
const currentIndexRef = useRef(currentIndex);
currentIndexRef.current = currentIndex;
const [isLoading, setIsLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const videoRefs = useRef<Map<number, HTMLElement>>(new Map());
// Register video element ref
const registerVideoRef = useCallback((index: number, element: HTMLElement | null) => {
if (element) {
videoRefs.current.set(index, element);
} else {
videoRefs.current.delete(index);
}
}, []);
// Calculate current video based on viewport position
const calculateCurrentVideo = useCallback(() => {
if (!containerRef.current || videos.length === 0) return 0;
let closestIndex = 0;
let closestDistance = Infinity;
// Find video closest to viewport center
videoRefs.current.forEach((element, index) => {
const distance = getElementCenterDistance(element);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
return closestIndex;
}, [videos.length]);
// Throttled scroll handler based on TikTok's implementation
const handleScroll = useCallback(
throttle(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
// Calculate current video index
const newIndex = calculateCurrentVideo();
// Update current index if changed
if (newIndex !== currentIndexRef.current && newIndex >= 0 && newIndex < videos.length) {
setCurrentIndex(newIndex);
onVideoChange?.(newIndex);
}
// Preload more content when approaching end
const shouldLoadMore = scrollTop + containerHeight >= scrollHeight * threshold;
if (shouldLoadMore && hasMore && !isLoading && onLoadMore) {
setIsLoading(true);
onLoadMore();
}
// Preload next videos when within preloadDistance of current
if (videos.length - newIndex < preloadDistance && hasMore && !isLoading && onLoadMore) {
setIsLoading(true);
onLoadMore();
}
}, 100),
[
videos.length,
hasMore,
isLoading,
onLoadMore,
threshold,
preloadDistance,
onVideoChange,
calculateCurrentVideo
]
);
// Attach scroll event listener
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
// Handle loading state reset
useEffect(() => {
if (isLoading) {
// Reset loading state after a delay to prevent rapid firing
const timer = setTimeout(() => {
setIsLoading(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [isLoading]);
// Keyboard navigation (arrow keys)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!containerRef.current) return;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
const prevElement = videoRefs.current.get(currentIndex - 1);
if (prevElement) {
prevElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
break;
case 'ArrowDown':
event.preventDefault();
if (currentIndex < videos.length - 1) {
const nextElement = videoRefs.current.get(currentIndex + 1);
if (nextElement) {
nextElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
break;
case ' ':
event.preventDefault();
// Space bar handling can be implemented by parent component
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex, videos.length]);
// Scroll to specific video
const scrollToVideo = useCallback((index: number) => {
if (index < 0 || index >= videos.length) return;
const element = videoRefs.current.get(index);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [videos.length]);
// Navigate to next video
const nextVideo = useCallback(() => {
const nextIndex = Math.min(currentIndex + 1, videos.length - 1);
scrollToVideo(nextIndex);
}, [currentIndex, videos.length, scrollToVideo]);
// Navigate to previous video
const prevVideo = useCallback(() => {
const prevIndex = Math.max(currentIndex - 1, 0);
scrollToVideo(prevIndex);
}, [currentIndex, scrollToVideo]);
return {
containerRef,
currentIndex,
isLoading,
handleScroll,
registerVideoRef,
scrollToVideo,
nextVideo,
prevVideo
} as UseInfiniteScrollReturn & {
registerVideoRef: (index: number, element: HTMLElement | null) => void;
scrollToVideo: (index: number) => void;
nextVideo: () => void;
prevVideo: () => void;
};
}

View File

@ -0,0 +1,22 @@
/**
* TikTok Video Player - Main Export
* Based on deobfuscated TikTok source code
*/
// Components
export * from './components';
// Hooks
export * from './hooks';
// Types
export * from './types';
// Utils
export * from './utils';
// Mock Data (for development)
export * from './data/mockData';
// Main component export
export { VideoFeed as TikTokVideoPlayer } from './components/VideoFeed';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,299 @@
/**
* TikTok Video Player Types
* Based on deobfuscated TikTok source code
*/
// Core Video Types
export interface VideoItem {
id: string;
author: {
nickname?: string;
uniqueId?: string;
id?: string;
secUid?: string;
avatarThumb?: string;
verified?: boolean;
};
video: {
width?: number;
height?: number;
duration?: number;
ratio?: string;
playAddr?: string;
downloadAddr?: string;
cover?: string;
dynamicCover?: string;
};
stats: {
diggCount?: number;
playCount?: number;
shareCount?: number;
commentCount?: number;
collectCount?: number;
};
desc?: string;
createTime?: number;
isPinnedItem?: boolean;
imagePost?: {
images: Array<{ url: string }>;
};
music?: {
id: string;
title: string;
playUrl: string;
coverMedium: string;
authorName: string;
};
challenges?: Array<{
id: string;
title: string;
desc: string;
}>;
effectStickers?: Array<{
id: string;
name: string;
iconUrl: string;
}>;
}
// Video Player State Management
export interface VideoDetailState {
currentIndex: number;
itemListKey: ItemListKey;
subtitleContent: SubtitleCue[];
ifShowSubtitle: boolean;
subtitleStruct: SubtitleStruct | null;
seekType: SeekType;
playMode: PlayMode;
isScrollGuideVisible: boolean;
isYmlRightPanelVisible: boolean;
}
// Swiper Navigation State
export interface SwiperModeState {
currentIndex: Record<string, number>;
disabled: boolean;
itemListKey: ItemListKey;
onboardingShowing: boolean;
loginCTAShowing: boolean;
leavingModalShowing: boolean;
playbackRate: number;
dimmer: boolean;
showBrowseMode: boolean;
needLeavingModal: boolean;
iconType: IconType;
seekType: SeekType;
}
// Subtitle Types
export interface SubtitleStruct {
url: string;
language: string;
expire?: number;
}
export interface SubtitleCue {
start: number;
end: number;
text: string;
startStr: string;
}
export interface SubtitleInfo {
Version: string;
Format: SubtitleFormat;
Url: string;
LanguageCodeName: string;
UrlExpire?: number;
}
// Comment System Types
export interface CommentItem {
cid: string;
user: string;
text: string;
createTime: number;
user_digged: boolean;
is_author_digged: boolean;
digg_count: number;
reply_comment_total: number;
reply_comment?: CommentItem[];
replyCache?: {
comments: CommentItem[];
cursor: string;
hasMore: boolean;
loading: boolean;
};
comment_language?: string;
}
export interface VideoCommentState {
awemeId?: string;
cursor: string;
comments: CommentItem[];
hasMore: boolean;
loading: boolean;
isFirstLoad: boolean;
fetchType: 'load_by_current' | 'preload_by_ml';
currentAspect: 'all' | string;
}
// Enums
export enum PlayMode {
VideoDetail = "video_detail",
OneColumn = "one_column",
MiniPlayer = "mini_player"
}
export enum SeekType {
None = "none",
Forward = "forward",
Backward = "backward",
Scrub = "scrub"
}
export enum IconType {
None = "none",
Mute = "mute",
Unmute = "unmute",
Play = "play",
Pause = "pause"
}
export enum EnterMethod {
VideoDetailPage = "video_detail_page",
VideoCoverClick = "video_cover_click",
VideoCoverClickAIGCDesc = "video_cover_click_aigc_desc",
VideoErrorAutoReload = "video_error_auto_reload",
CreatorCard = "creator_card",
ClickButton = "click_button"
}
export enum SubtitleFormat {
WebVTT = "webvtt",
CreatorCaption = "creator_caption"
}
export enum ItemListKey {
Video = "video",
SearchTop = "search_top",
SearchVideo = "search_video",
SearchPhoto = "search_photo"
}
export enum StatusCode {
Ok = 0,
UnknownError = -1,
NetworkError = -2
}
// Component Props
export interface VideoPlayerProps {
videos: VideoItem[];
currentIndex?: number;
onVideoChange?: (index: number) => void;
onLoadMore?: () => void;
hasMore?: boolean;
loading?: boolean;
autoplay?: boolean;
muted?: boolean;
controls?: boolean;
className?: string;
}
export interface VideoItemProps {
video: VideoItem;
index: number;
isActive: boolean;
onSelect: () => void;
onLoad?: () => void;
}
export interface CommentSystemProps {
videoId: string;
comments?: CommentItem[];
onLoadMore?: () => void;
onAddComment?: (text: string) => void;
onLikeComment?: (commentId: string) => void;
onReplyComment?: (commentId: string, text: string) => void;
}
// Hook Return Types
export interface UseVideoPlayerReturn {
currentIndex: number;
isPlaying: boolean;
isMuted: boolean;
volume: number;
progress: number;
duration: number;
buffered: number;
isLoading: boolean;
error: string | null;
play: () => void;
pause: () => void;
togglePlay: () => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
seek: (time: number) => void;
nextVideo: () => void;
prevVideo: () => void;
clearError: () => void;
setCurrentIndex?: (index: number) => void;
videoRef?: React.RefObject<HTMLVideoElement>;
getVideoElement?: () => HTMLVideoElement | null;
seekType?: SeekType;
playMode?: PlayMode;
currentVideo?: VideoItem;
}
export interface UseInfiniteScrollReturn {
containerRef: React.RefObject<HTMLDivElement>;
currentIndex: number;
isLoading: boolean;
handleScroll: () => void;
registerVideoRef?: (index: number, element: HTMLElement | null) => void;
scrollToVideo?: (index: number) => void;
nextVideo?: () => void;
prevVideo?: () => void;
}
// Mux Integration Types (for future use)
export interface MuxVideoSource {
playbackId: string;
aspectRatio?: string;
duration?: number;
maxResolution?: string;
status?: 'preparing' | 'ready' | 'errored';
}
// Analytics Types
export interface VideoAnalytics {
videoId: string;
watchTime: number;
completionRate: number;
interactions: {
likes: number;
shares: number;
comments: number;
};
timestamp: number;
}
// Error Types
export interface VideoError {
code: string;
message: string;
details?: any;
}
// Configuration Types
export interface PlayerConfig {
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
preload?: 'none' | 'metadata' | 'auto';
crossOrigin?: 'anonymous' | 'use-credentials';
playsInline?: boolean;
controls?: boolean;
poster?: string;
}

View File

@ -0,0 +1,294 @@
/**
* Utility functions for TikTok Video Player
* Based on deobfuscated TikTok utility code
*/
export * from './webvtt';
/**
* Throttle function calls
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout | null = null;
let lastExecTime = 0;
return (...args: Parameters<T>) => {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func(...args);
lastExecTime = currentTime;
} else {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
lastExecTime = Date.now();
}, delay - (currentTime - lastExecTime));
}
};
}
/**
* Debounce function calls
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
/**
* Format large numbers (e.g., 1.2K, 1.5M)
*/
export function formatCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
} else if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
}
/**
* Format time duration
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* Format time (alias for formatDuration)
*/
export function formatTime(seconds: number): string {
return formatDuration(seconds);
}
/**
* Get current subtitle cue based on time
*/
export function getCurrentCue(
cues: Array<{ start: number; end: number; text: string }>,
currentTime: number
): { start: number; end: number; text: string } | null {
const cue = cues.find(c => currentTime >= c.start && currentTime <= c.end);
return cue || null;
}
/**
* Get relative time string (e.g., "2 hours ago")
*/
export function getRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ago`;
} else if (hours > 0) {
return `${hours}h ago`;
} else if (minutes > 0) {
return `${minutes}m ago`;
} else {
return 'now';
}
}
/**
* Clamp value between min and max
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Check if element is in viewport
*/
export function isInViewport(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Get element's position relative to viewport center
*/
export function getElementCenterDistance(element: HTMLElement): number {
const rect = element.getBoundingClientRect();
const elementCenter = rect.top + rect.height / 2;
const viewportCenter = window.innerHeight / 2;
return Math.abs(elementCenter - viewportCenter);
}
/**
* Invariant function for error checking (from deobfuscated TikTok code)
*/
export function invariant(condition: boolean, message?: string, ...args: any[]): void {
if (!condition) {
let error: Error;
if (message === undefined) {
// Production mode - generic error message
error = new Error(
"Minified exception occurred; use the non-minified dev environment " +
"for the full error message and additional helpful warnings."
);
} else {
// Development mode - detailed error message
let argIndex = 0;
error = new Error(
message.replace(/%s/g, () => args[argIndex++])
);
error.name = "Invariant Violation";
}
// Set framesToPop for better stack traces
(error as any).framesToPop = 1;
throw error;
}
}
/**
* Safe function execution with error handling
*/
export function safeExecute<T>(
fn: () => T,
onError?: (error: Error) => void,
fallback?: T
): T | undefined {
try {
return fn();
} catch (error) {
if (onError) {
onError(error as Error);
}
return fallback;
}
}
/**
* Create URL with fallback for invalid URLs
*/
export function createSafeURL(url: string, base?: string): URL | null {
try {
return new URL(url, base);
} catch {
return null;
}
}
/**
* Check if device supports touch
*/
export function isTouchDevice(): boolean {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
/**
* Check if device is mobile
*/
export function isMobile(): boolean {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
/**
* Get video aspect ratio
*/
export function getAspectRatio(width: number, height: number): string {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
}
/**
* Preload image
*/
export function preloadImage(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = reject;
img.src = src;
});
}
/**
* Copy text to clipboard
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const result = document.execCommand('copy');
textArea.remove();
return result;
}
} catch {
return false;
}
}
/**
* Generate unique ID
*/
export function generateId(): string {
return Math.random().toString(36).substr(2, 9);
}
/**
* Deep clone object
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T;
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as unknown as T;
}
if (typeof obj === 'object') {
const cloned = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
}
return obj;
}

View File

@ -0,0 +1,248 @@
/**
* WebVTT Subtitle Parser
* Based on deobfuscated TikTok WebVTT parsing code
*/
import { SubtitleCue } from '../types';
export interface WebVTTParseOptions {
strict?: boolean;
includeMeta?: boolean;
}
export interface WebVTTResult {
valid: boolean;
strict: boolean;
cues: SubtitleCue[];
errors: Error[];
meta?: Record<string, string>;
}
// Timestamp regex pattern
const TIMESTAMP_PATTERN = /([0-9]{1,2})?:?([0-9]{2}):([0-9]{2}\.[0-9]{2,3})/;
/**
* Parse WebVTT subtitle content
*/
export function parseWebVTT(vttContent: string, options: WebVTTParseOptions = {}): WebVTTResult {
if (!vttContent || typeof vttContent !== "string") {
return { cues: [], valid: false, strict: false, errors: [] };
}
const { strict = true, includeMeta = false } = options;
try {
// Normalize line endings and split into blocks
const normalizedContent = vttContent
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
const blocks = normalizedContent.split("\n\n");
const header = blocks.shift();
// Validate WebVTT header
if (!header || !header.startsWith("WEBVTT")) {
throw new Error('Must start with "WEBVTT"');
}
const headerLines = header.split("\n");
const headerComment = headerLines[0].replace("WEBVTT", "");
// Validate header comment format
if (headerComment.length > 0 && headerComment[0] !== " " && headerComment[0] !== "\t") {
throw new Error("Header comment must start with space or tab");
}
// Handle empty content
if (blocks.length === 0 && headerLines.length === 1) {
return {
valid: true,
strict,
cues: [],
errors: []
};
}
// Parse cue blocks
const parseResult = parseCueBlocks(blocks, strict);
const cues = parseResult.cues;
const errors = parseResult.errors;
// Throw first error in strict mode
if (strict && errors.length > 0) {
throw errors[0];
}
// Parse metadata if requested
let metadata: Record<string, string> | undefined;
if (includeMeta) {
const metaObject: Record<string, string> = {};
headerLines.slice(1).forEach(line => {
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
metaObject[key] = value;
}
});
metadata = Object.keys(metaObject).length > 0 ? metaObject : undefined;
}
return {
valid: errors.length === 0,
strict,
cues,
errors,
meta: metadata
};
} catch (error) {
return {
valid: false,
strict,
cues: [],
errors: [error as Error]
};
}
}
/**
* Parse individual cue blocks
*/
function parseCueBlocks(blocks: string[], strict: boolean): { cues: SubtitleCue[]; errors: Error[] } {
const cues: SubtitleCue[] = [];
const errors: Error[] = [];
blocks.forEach((block, index) => {
try {
const cue = parseSingleCue(block, index, strict);
if (cue) {
cues.push(cue);
}
} catch (error) {
errors.push(error as Error);
}
});
return { cues, errors };
}
/**
* Parse a single WebVTT cue
*/
function parseSingleCue(cueBlock: string, cueIndex: number, strict: boolean): SubtitleCue | null {
const lines = cueBlock.split("\n").filter(Boolean);
// Skip NOTE blocks
if (lines.length > 0 && lines[0].trim().startsWith("NOTE")) {
return null;
}
// Validate cue structure
if (lines.length === 1 && !lines[0].includes("-->")) {
throw new Error(`Cue identifier cannot be standalone (cue #${cueIndex})`);
}
if (lines.length > 1 && !(lines[0].includes("-->") || lines[1].includes("-->"))) {
throw new Error(`Cue identifier needs to be followed by timestamp (cue #${cueIndex})`);
}
// Find timestamp line
const timestampLineIndex = lines.findIndex(line => line.includes("-->"));
if (timestampLineIndex === -1) {
throw new Error(`No timestamp found in cue ${cueIndex}`);
}
const timestampLine = lines[timestampLineIndex];
const timestampParts = timestampLine.split(" --> ");
if (timestampParts.length !== 2 ||
!isValidTimestamp(timestampParts[0]) ||
!isValidTimestamp(timestampParts[1])) {
throw new Error(`Invalid cue timestamp (cue #${cueIndex})`);
}
// Parse timestamps
const startTime = parseTimestamp(timestampParts[0]);
const endTime = parseTimestamp(timestampParts[1]);
// Validate timestamp order
if (strict) {
if (startTime > endTime) {
throw new Error(`Start timestamp greater than end (cue #${cueIndex})`);
}
if (endTime <= startTime) {
throw new Error(`End must be greater than start (cue #${cueIndex})`);
}
}
if (!strict && endTime < startTime) {
throw new Error(`End must be greater or equal to start when not strict (cue #${cueIndex})`);
}
// Format start time display
const startTimeParts = timestampParts[0].split(".")[0].split(":");
const startTimeDisplay = startTimeParts[0] !== "00" ?
timestampParts[0] :
`${startTimeParts[1]}:${startTimeParts[2]}`;
// Extract cue text
const textLines = lines.slice(timestampLineIndex + 1);
const text = textLines.join("\n").trim();
if (!text) {
return null;
}
return {
start: startTime,
end: endTime,
text,
startStr: startTimeDisplay
};
}
/**
* Validate timestamp format
*/
function isValidTimestamp(timestamp: string): boolean {
return TIMESTAMP_PATTERN.test(timestamp);
}
/**
* Parse timestamp string to seconds
*/
function parseTimestamp(timestampString: string): number {
const matches = timestampString.match(TIMESTAMP_PATTERN);
if (!matches) {
throw new Error(`Invalid timestamp format: ${timestampString}`);
}
const hours = parseFloat(matches[1] || "0") * 60 * 60;
const minutes = parseFloat(matches[2]) * 60;
const seconds = parseFloat(matches[3]);
const totalSeconds = hours + minutes + seconds;
return Number(totalSeconds.toFixed(6));
}
/**
* Format time in seconds to display string
*/
export function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Get current subtitle cue for given time
*/
export function getCurrentCue(cues: SubtitleCue[], currentTime: number): SubtitleCue | null {
return cues.find(cue => currentTime >= cue.start && currentTime <= cue.end) || null;
}

View File

@ -0,0 +1 @@
export * from "./modbusService.js"

View File

@ -0,0 +1,474 @@
/**
* Interval in milliseconds for polling REST API endpoints
* (Coils, Registers, SystemInfo).
*/
export const REST_POLLING_INTERVAL_MS = 2000;
/**
* WebSocket reconnect attempt interval in milliseconds.
*/
export const WS_RECONNECT_INTERVAL_MS = 5000;
/**
* WebSocket register refresh interval in milliseconds.
*/
export const WS_REGISTER_REFRESH_INTERVAL_MS = 500;
// Add new constant for polling
export const WS_REGISTER_POLL_INTERVAL_MS = 2500; // Interval for polling all registers via WebSocket
// Add other constants here as needed
export const WS_HEARTBEAT_INTERVAL_MS = 10000;
export const WS_HEARTBEAT_TIMEOUT_MS = 25000;
export type StatusChangeCallback = (status: WsStatus) => void;
const getLogLevelConsoleColor = (level: string): string => {
switch (level) {
case 'Fatal':
case 'Error':
return 'color: #ed4e4c; font-weight: bold;';
case 'Warning':
return 'color: #d2c057;';
case 'Info':
return 'color: #2774f0;';
case 'Debug':
return 'color: #01c800;';
case 'Verbose':
return 'color: #a142f4;';
case 'Trace':
return 'color: #898989;';
default:
return 'color: #cfd0d0;';
}
};
let currentLogGroup = '';
const logToConsole = (log: any, levelMap: { [key: number]: string }) => {
const { level, message, name, id, timestamp } = log;
const levelStr = (typeof level === 'number' ? levelMap[level] : level) || 'Unknown';
if (levelStr === 'Silent') return;
const time = new Date(timestamp || Date.now()).toLocaleTimeString('en-GB');
const levelColor = getLogLevelConsoleColor(levelStr);
const componentInfo = name ? `${name}${id !== undefined ? `#${id}` : ''}` : 'System';
// Only create a new group if the component has changed
if (componentInfo !== currentLogGroup) {
if (currentLogGroup !== '') {
console.groupEnd(); // Close previous group
}
console.group(`%c[${componentInfo}]`, 'color: #e59e66; font-weight: bold;');
currentLogGroup = componentInfo;
}
console.log(
`%c${time} %c[${levelStr.toUpperCase()}] %c${message}`,
'color: grey;',
levelColor,
'color: inherit;'
);
};
import {
} from '../types';
import { WsStatus } from './types';
export type DisplayMessagePayload = {
id: string | number;
message: string;
timestamp: number;
};
export interface LogOptions {
mode?: 'append' | 'overwrite';
format?: 'json' | 'html' | 'md';
}
export interface LogPayload {
name: string;
level?: string;
message: any;
options?: LogOptions;
[key: string]: any;
}
enum LogLevel {
SILENT = 0,
FATAL,
ERROR,
WARNING,
INFO,
TRACE,
VERBOSE
};
const LOG_LEVEL_MAP: { [key in LogLevel]: string } = {
[LogLevel.SILENT]: 'Silent',
[LogLevel.FATAL]: 'Fatal',
[LogLevel.ERROR]: 'Error',
[LogLevel.WARNING]: 'Warning',
[LogLevel.INFO]: 'Info',
[LogLevel.TRACE]: 'Trace',
[LogLevel.VERBOSE]: 'Verbose',
};
class ModbusService {
private ws: WebSocket | null = null;
private wsUrl: string = '';
private status: WsStatus = 'DISCONNECTED';
private messageId: number = 0;
private pendingRequests: Map<number, {
resolve: Function;
reject: Function;
command: string;
data?: any;
}> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private isIntentionalDisconnect: boolean = false;
private isFetchingAllRegisters: boolean = false;
private heartbeatTimer: NodeJS.Timeout | null = null;
private lastMessageTimestamp: number = 0;
private messageHandlers: Map<string, Set<(data: any) => void>> = new Map();
constructor() { }
// --- Connection Management ---
public connect(
wsUrl: string,
onStatusChange: StatusChangeCallback,
): Promise<boolean> {
if (this.ws && (this.status === 'CONNECTED' || this.status === 'CONNECTING' || this.status === 'RECONNECTING')) {
if (this.status === 'CONNECTED') {
console.log('WebSocket already connected.');
return Promise.resolve(false);
}
}
this.wsUrl = wsUrl;
this.onStatusChange = onStatusChange;
this.isIntentionalDisconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
return this.attemptConnection().then(() => true);
}
private attemptConnection(): Promise<void> {
return new Promise((resolve, reject) => {
// If we already have a valid/active connection or an ongoing connection attempt, abort this attempt
if (this.ws) {
const isOpen = this.ws.readyState === WebSocket.OPEN && this.status === 'CONNECTED';
const isConnecting = this.ws.readyState === WebSocket.CONNECTING && (this.status === 'CONNECTING' || this.status === 'RECONNECTING');
if (isOpen || isConnecting) {
console.log('[ModbusService] Existing WebSocket is in a valid state (', this.ws.readyState, '/', this.status, '). Aborting new connection attempt.');
resolve();
return;
}
}
if (!this.wsUrl) {
console.error('WebSocket URL is not set.');
this.updateStatus('ERROR');
reject(new Error('WebSocket URL is not set.'));
return;
}
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
if (this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
console.log('[ModbusService] Closing existing WebSocket before new attempt.');
this.ws.close();
}
this.ws = null;
}
const connectingState = (this.status === 'DISCONNECTED' || this.status === 'ERROR') ? 'CONNECTING' : 'RECONNECTING';
this.updateStatus(connectingState);
console.log(`Attempting WebSocket connection to ${this.wsUrl}...`);
try {
const newWs = new WebSocket(this.wsUrl);
newWs.binaryType = 'arraybuffer'; // Enable binary messages
this.ws = newWs;
newWs.onopen = () => {
console.log('WebSocket Connected!');
this.updateStatus('CONNECTED');
if ((window as any).markAppAsReady) {
(window as any).markAppAsReady();
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.startHeartbeat();
resolve();
};
newWs.onmessage = (event) => {
this.lastMessageTimestamp = Date.now();
this.handleMessage(event.data);
};
newWs.onerror = (event: Event) => {
console.error('WebSocket Error:', event);
this.onSocketClosed(newWs, reject, event);
}
newWs.onclose = (event: Event) => this.onSocketClosed(newWs, reject, event);
} catch (error) {
console.error(`Failed to create WebSocket: ${error instanceof Error ? error.message : String(error)}`);
this.updateStatus('ERROR');
this.ws = null;
reject(error);
}
});
}
private onSocketClosed(wsInstance: WebSocket, reject: (reason?: any) => void, event: Event) {
if (this.ws !== wsInstance) {
return;
}
const wasConnected = this.status === 'CONNECTED';
let reason = 'Connection closed';
let code: number | undefined;
if (event instanceof CloseEvent) {
reason = event.reason || 'No reason specified';
code = event.code;
console.log(`WebSocket Disconnected: ${reason} (Code: ${code})`);
} else {
console.error('WebSocket Error:', event);
reason = 'Connection error';
}
this.ws = null;
this.stopHeartbeat();
const rejectionReason = this.isIntentionalDisconnect
? 'WebSocket disconnected intentionally'
: 'WebSocket disconnected';
this.rejectPendingRequests(rejectionReason);
if (this.isIntentionalDisconnect) {
this.updateStatus('DISCONNECTED');
if (!wasConnected) {
reject(new Error('WebSocket connection intentionally disconnected before opening.'));
}
} else {
this.updateStatus('RECONNECTING');
console.log(`WebSocket connection lost. Retrying in ${WS_RECONNECT_INTERVAL_MS / 1000} seconds...`);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.attemptConnection(), WS_RECONNECT_INTERVAL_MS);
if (!wasConnected) {
reject(new Error(`WebSocket connection failed: ${reason}${code ? ` (Code: ${code})` : ''}`));
}
}
}
disconnect(intentional: boolean = true): void {
console.log(`Disconnecting WebSocket (intentional: ${intentional})`);
this.isIntentionalDisconnect = intentional;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
} else {
console.log('No WebSocket connection to disconnect.');
if (intentional && this.status !== 'DISCONNECTED') {
this.updateStatus('DISCONNECTED');
}
}
}
private onStatusChange: StatusChangeCallback = () => { };
private updateStatus(newStatus: WsStatus): void {
if (this.status !== newStatus) {
this.status = newStatus;
this.onStatusChange(newStatus);
}
}
private rejectPendingRequests(reason: string): void {
this.pendingRequests.forEach((req) => {
req.reject(new Error(`${req.command} failed: ${reason}`));
});
this.pendingRequests.clear();
}
private startHeartbeat(): Promise<void> {
this.stopHeartbeat();
this.lastMessageTimestamp = Date.now();
return new Promise((resolve, reject) => {
this.heartbeatTimer = setInterval(async () => {
if (Date.now() - this.lastMessageTimestamp > WS_HEARTBEAT_TIMEOUT_MS) {
console.error('Heartbeat timeout. Connection lost.');
this.disconnect(false); // Trigger reconnect
} else {
try {
await this.sendRequest('ping');
resolve();
} catch (error) {
console.error("Heartbeat get_sysinfo failed:", error);
reject(error);
}
}
}, WS_HEARTBEAT_INTERVAL_MS);
});
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// --- Message Handling ---
private handleMessage(data: any): void {
try {
let response = null
try {
response = JSON.parse(data);
} catch (error) {
// console.error('Failed to parse WebSocket message or handle it:');
return;
}
if (response.id !== undefined && this.pendingRequests.has(response.id)) {
const request = this.pendingRequests.get(response.id)!;
// Command specific handling if needed (mostly unnecessary if promise matches)
if (request.command === 'get_coils' || request.command === 'get_registers') {
// logic reserved
}
this.pendingRequests.delete(response.id);
if (response.error) {
console.error(`[handleMessage] Error response for command ${request.command} (ID: ${response.id}):`, response.error);
request.reject(new Error(response.error));
} else {
request.resolve(response.data);
}
} else if (response.type && this.messageHandlers.has(response.type)) {
// Check for registered generic handlers (e.g., layout-update)
this.messageHandlers.get(response.type)?.forEach(handler => handler(response.data));
} else {
console.warn('Received unexpected WebSocket message format or type:', response);
}
} catch (error) {
console.warn('Failed to parse WebSocket message or handle it:', data, error);
}
}
private sendRequest<T>(command: string, payload: object = {}): Promise<T> {
if (!this.ws || this.status !== 'CONNECTED') {
const errorMsg = `[sendRequest] Cannot send command '${command}': WebSocket not connected or status is '${this.status}'. WS object: ${this.ws ? 'exists' : 'null'}.`;
console.warn(errorMsg);
return Promise.reject(new Error(errorMsg));
}
return new Promise((resolve, reject) => {
const id = this.messageId++;
const message = { ...payload, command, id };
try {
if (!this.ws) {
const errorMsg = `[sendRequest] Critical Error: WebSocket is null just before send for command '${command}', ID: ${id}.`;
console.error(errorMsg);
return reject(new Error(errorMsg));
}
this.ws.send(JSON.stringify(message));
this.pendingRequests.set(id, { resolve, reject, command, data: payload });
} catch (error) {
const errorMsg = `[sendRequest] WebSocket send error for command '${command}', ID: ${id}. Error: ${error instanceof Error ? error.message : String(error)}`;
console.error(errorMsg, error);
reject(new Error(errorMsg));
}
});
}
// --- Public API Methods ---
public getConnectionStatus(): WsStatus {
return this.status;
}
// Generic method for sending custom websocket commands
public async sendCommand<T>(command: string, payload: object = {}): Promise<T> {
return this.sendRequest<T>(command, payload);
}
async writeRegister(address: number, value: number): Promise<void> {
return this.sendRequest<void>('write_register', { address, value });
}
async requestCoils(): Promise<void> {
if (this.status !== 'CONNECTED') {
throw new Error('WebSocket not connected');
}
await this.sendRequest('get_coils');
}
async writeCoil(address: number, value: boolean): Promise<void> {
if (this.status !== 'CONNECTED') {
throw new Error('WebSocket not connected');
}
return this.sendRequest<void>('write_coil', { address, value });
}
async writeMultipleCoils(address: number, values: boolean[]): Promise<void> {
if (this.status !== 'CONNECTED') {
throw new Error('WebSocket not connected');
}
return this.sendRequest<void>('write_multiple_coils', { address, values });
}
async log(payload: LogPayload): Promise<void> {
return this.sendRequest<void>('log', payload);
}
public addMessageHandler(type: string, handler: (data: any) => void): () => void {
if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, new Set());
}
this.messageHandlers.get(type)!.add(handler);
// Return unsubscribe function
return () => {
const handlers = this.messageHandlers.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.messageHandlers.delete(type);
}
}
};
}
}
const modbusService = new ModbusService();
export default modbusService;

View File

@ -0,0 +1,12 @@
export type WsStatus = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'ERROR' | 'RECONNECTING';
export interface LogEntry {
logId: number;
timestamp: number;
level: string;
message: string;
id?: number;
name?: string;
}

View File

@ -0,0 +1,6 @@
declare module 'stream-browserify' {
export class Readable {}
export class Writable {}
export class Transform {}
export class Stream {}
}

94
packages/ui/src/sw.ts Normal file
View File

@ -0,0 +1,94 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
const SW_VERSION = '1.0.4-debug';
console.log(`[SW] Initializing Version: ${SW_VERSION}`);
self.addEventListener('fetch', (event) => { });
import { cleanupOutdatedCaches, cleanupOutdatedCaches as cleanupOutdatedCaches2, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { set } from 'idb-keyval'
declare let self: ServiceWorkerGlobalScope
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST)
// clean old assets
cleanupOutdatedCaches()
self.skipWaiting()
clientsClaim()
// allow only fallback in dev: we don't want to cache everything
let allowlist: undefined | RegExp[]
if (import.meta.env.DEV)
allowlist = [/^\/$/]
// Handle Share Target POST requests
// MUST be registered before NavigationRoute to take precedence
registerRoute(
({ request, url }) => request.method === 'POST' && url.pathname === '/upload-share-target',
async ({ event, request }) => {
console.log('SW: Intercepting Share Target request!');
try {
const formData = await request.formData()
const files = formData.getAll('file')
const title = formData.get('title')
const text = formData.get('text')
const url = formData.get('url')
console.log('SW: Share data received:', { filesLen: files.length, title, text, url });
// Store in IDB
await set('share-target', { files, title, text, url, timestamp: Date.now() })
console.log('SW: Data stored in IDB');
// Redirect to the app with success status
return Response.redirect('/new?shared=true&sw_status=success', 303)
} catch (err) {
console.error('SW: Share Target Error:', err);
// Safe error string
const errMsg = err instanceof Error ? err.message : String(err);
return Response.redirect('/new?shared=true&sw_status=error&sw_error=' + encodeURIComponent(errMsg), 303);
}
},
'POST'
);
import { NetworkFirst } from 'workbox-strategies';
// Navigation handler: Prefer network to get server injection, fallback to index.html
const navigationHandler = async (params: any) => {
try {
const strategy = new NetworkFirst({
cacheName: 'pages',
plugins: [
{
cacheWillUpdate: async ({ response }) => {
return response && response.status === 200 ? response : null;
}
}
]
});
return await strategy.handle(params);
} catch (error) {
return createHandlerBoundToURL('index.html')(params);
}
};
// to allow work offline
registerRoute(new NavigationRoute(
navigationHandler,
{
allowlist,
denylist: [/^\/upload-share-target/]
}
))

13
packages/ui/src/types-global.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import { FeedPost } from './types-server';
declare global {
interface Window {
__INITIAL_STATE__?: {
feed?: FeedPost[];
profile?: any; // We can strictly type this later if needed
post?: any;
};
}
}
export { };

View File

@ -0,0 +1,172 @@
export type FeedPostList = FeedPost[]
export interface FeedPost {
id: string
user_id: string
title: string
description?: string
created_at: string
updated_at: string
settings: Settings
meta: Meta
pictures: Picture[]
likes_count: number
comments_count: number
author: Author
}
export interface Settings {
visibility: string
}
export interface Meta { }
export interface Picture {
id: string
meta: Meta2
tags: any
type: string
flags: any[]
title: string
post_id: string
user_id: string
visible: boolean
position: number
image_url: string
parent_id?: string
created_at: string
updated_at: string
description?: string
is_selected: boolean
likes_count: number
thumbnail_url?: string
organization_id: any
responsive: Responsive
job?: Job
}
export interface Job {
id: string
status: string
progress: number
resultUrl?: string
error?: any
}
export interface Meta2 {
status?: string
tracks?: Track[]
duration?: number
created_at?: string
aspect_ratio?: string
mux_asset_id?: string
mux_upload_id?: string
mux_playback_id?: string
max_stored_frame_rate?: number
max_stored_resolution?: string
}
export interface Track {
id: string
refs: number
tags: Tags
type: string
index: number
level: number
width: number
height: number
pix_fmt: string
profile: string
bit_rate: number
duration: number
rotation?: number
timecode: string
codec_tag: string
max_width: number
nb_frames: number
start_pts: number
time_base: string
codec_name: string
codec_type: string
max_height: number
start_time: number
coded_width: number
color_range: string
color_space: string
disposition: Disposition
duration_ts: number
field_order: string
coded_height: number
has_b_frames: number
max_bit_rate: string
r_frame_rate: string
avg_frame_rate: string
color_transfer: string
max_frame_rate: number
nb_read_frames: string
side_data_list?: SideDataList[]
chroma_location: string
codec_long_name: string
codec_time_base: string
color_primaries: string
nb_read_packets: string
codec_tag_string: string
bits_per_raw_sample: any
sample_aspect_ratio: string
display_aspect_ratio: string
is_avc?: string
nal_length_size?: number
}
export interface Tags {
rotate?: string
language: string
handler_name?: string
creation_time?: string
encoder?: string
}
export interface Disposition {
dub: number
forced: number
lyrics: number
comment: number
default: number
karaoke: number
original: number
attached_pic: number
clean_effects: number
visual_impaired: number
hearing_impaired: number
timed_thumbnails: number
}
export interface SideDataList {
rotation: number
displaymatrix: string
side_data_type: string
}
export interface Responsive {
img: Img
sources: Source[]
}
export interface Img {
src: string
width: number
height: number
format: string
}
export interface Source {
type: string
srcset: string
}
export interface Author {
user_id: string
username: string
display_name: string
avatar_url?: string
}

1
packages/ui/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />