ui
This commit is contained in:
parent
c8d84b64cd
commit
8ec419b87e
207
packages/ui/src/App.tsx
Normal file
207
packages/ui/src/App.tsx
Normal 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;
|
||||
117
packages/ui/src/EmbedApp.tsx
Normal file
117
packages/ui/src/EmbedApp.tsx
Normal 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
181
packages/ui/src/Logger.ts
Normal 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;
|
||||
BIN
packages/ui/src/assets/hero-image.jpg
Normal file
BIN
packages/ui/src/assets/hero-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -54,12 +54,28 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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;
|
||||
|
||||
|
||||
503
packages/ui/src/components/PageActions.tsx
Normal file
503
packages/ui/src/components/PageActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -59,7 +59,6 @@ const MediaGrid = ({
|
||||
navigationSourceId,
|
||||
isOwner = false,
|
||||
onFilesDrop,
|
||||
|
||||
showVideos = true,
|
||||
sortBy = 'latest',
|
||||
supabaseClient,
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
58
packages/ui/src/components/hmi/SelectionHandler.tsx
Normal file
58
packages/ui/src/components/hmi/SelectionHandler.tsx
Normal 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
|
||||
};
|
||||
@ -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
|
||||
|
||||
183
packages/ui/src/components/playground/PlaygroundHeader.tsx
Normal file
183
packages/ui/src/components/playground/PlaygroundHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
packages/ui/src/components/playground/TemplateDialogs.tsx
Normal file
96
packages/ui/src/components/playground/TemplateDialogs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
packages/ui/src/components/sidebar/MobileTOC.tsx
Normal file
48
packages/ui/src/components/sidebar/MobileTOC.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/ui/src/components/sidebar/Sidebar.tsx
Normal file
20
packages/ui/src/components/sidebar/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
packages/ui/src/components/sidebar/TableOfContents.tsx
Normal file
108
packages/ui/src/components/sidebar/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
packages/ui/src/components/sidebar/TableOfContentsList.tsx
Normal file
145
packages/ui/src/components/sidebar/TableOfContentsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
267
packages/ui/src/components/widgets/HtmlWidget.tsx
Normal file
267
packages/ui/src/components/widgets/HtmlWidget.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -5,12 +5,10 @@ import {
|
||||
Wand2,
|
||||
Type,
|
||||
Filter,
|
||||
|
||||
Maximize,
|
||||
Minimize,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LayoutTemplate
|
||||
PanelLeftOpen
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
|
||||
95
packages/ui/src/components/widgets/PageCreationWizard.tsx
Normal file
95
packages/ui/src/components/widgets/PageCreationWizard.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
182
packages/ui/src/components/widgets/PagePickerDialog.tsx
Normal file
182
packages/ui/src/components/widgets/PagePickerDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
336
packages/ui/src/components/widgets/WidgetPropertiesForm.tsx
Normal file
336
packages/ui/src/components/widgets/WidgetPropertiesForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
packages/ui/src/components/widgets/WidgetPropertyPanel.tsx
Normal file
107
packages/ui/src/components/widgets/WidgetPropertyPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
179
packages/ui/src/contexts/WS_Socket.tsx
Normal file
179
packages/ui/src/contexts/WS_Socket.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
345
packages/ui/src/hooks/usePlaygroundLogic.tsx
Normal file
345
packages/ui/src/hooks/usePlaygroundLogic.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
@ -27,7 +27,6 @@ export const useResponsiveImage = ({
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const generateResponsiveImages = async () => {
|
||||
if (!src || !enabled) {
|
||||
if (isMounted) {
|
||||
|
||||
90
packages/ui/src/hooks/useSelection.tsx
Normal file
90
packages/ui/src/hooks/useSelection.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
118
packages/ui/src/hooks/useWidgetLoader.tsx
Normal file
118
packages/ui/src/hooks/useWidgetLoader.tsx
Normal 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 };
|
||||
}
|
||||
@ -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>> } = {};
|
||||
|
||||
458
packages/ui/src/i18n/de.json
Normal file
458
packages/ui/src/i18n/de.json
Normal 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."
|
||||
}
|
||||
458
packages/ui/src/i18n/en.json
Normal file
458
packages/ui/src/i18n/en.json
Normal 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."
|
||||
}
|
||||
458
packages/ui/src/i18n/es.json
Normal file
458
packages/ui/src/i18n/es.json
Normal 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."
|
||||
}
|
||||
458
packages/ui/src/i18n/fr.json
Normal file
458
packages/ui/src/i18n/fr.json
Normal 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."
|
||||
}
|
||||
3168
packages/ui/src/i18n/image-generation.yaml
Normal file
3168
packages/ui/src/i18n/image-generation.yaml
Normal file
File diff suppressed because it is too large
Load Diff
458
packages/ui/src/i18n/it.json
Normal file
458
packages/ui/src/i18n/it.json
Normal 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."
|
||||
}
|
||||
458
packages/ui/src/i18n/nl.json
Normal file
458
packages/ui/src/i18n/nl.json
Normal 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
330
packages/ui/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
129
packages/ui/src/lib/emailExporter.ts
Normal file
129
packages/ui/src/lib/emailExporter.ts
Normal 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}%"> </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} -->`;
|
||||
};
|
||||
@ -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
|
||||
|
||||
100
packages/ui/src/lib/layoutTemplates.ts
Normal file
100
packages/ui/src/lib/layoutTemplates.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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
103
packages/ui/src/lib/toc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
75
packages/ui/src/lib/variables.ts
Normal file
75
packages/ui/src/lib/variables.ts
Normal 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
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
13
packages/ui/src/main-embed.tsx
Normal file
13
packages/ui/src/main-embed.tsx
Normal 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
21
packages/ui/src/main.tsx
Normal 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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
247
packages/ui/src/pages/PlaygroundCanvas.tsx
Normal file
247
packages/ui/src/pages/PlaygroundCanvas.tsx
Normal 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;
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
151
packages/ui/src/player/PLAYGROUND_README.md
Normal file
151
packages/ui/src/player/PLAYGROUND_README.md
Normal 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)
|
||||
|
||||
127
packages/ui/src/player/components/SimpleVideoPlayer.tsx
Normal file
127
packages/ui/src/player/components/SimpleVideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
202
packages/ui/src/player/components/VideoActionBar.tsx
Normal file
202
packages/ui/src/player/components/VideoActionBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
304
packages/ui/src/player/components/VideoFeed.tsx
Normal file
304
packages/ui/src/player/components/VideoFeed.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
215
packages/ui/src/player/components/VideoMetadata.tsx
Normal file
215
packages/ui/src/player/components/VideoMetadata.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
86
packages/ui/src/player/components/VideoPlayer.tsx
Normal file
86
packages/ui/src/player/components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
packages/ui/src/player/components/index.ts
Normal file
8
packages/ui/src/player/components/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Components exports for TikTok Video Player
|
||||
*/
|
||||
|
||||
export * from './VideoPlayer';
|
||||
export * from './VideoActionBar';
|
||||
export * from './VideoMetadata';
|
||||
export * from './VideoFeed';
|
||||
229
packages/ui/src/player/data/mockData.ts
Normal file
229
packages/ui/src/player/data/mockData.ts
Normal 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[]>);
|
||||
5
packages/ui/src/player/hooks/index.ts
Normal file
5
packages/ui/src/player/hooks/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Hooks exports for TikTok Video Player
|
||||
*/
|
||||
|
||||
export * from './useInfiniteScroll';
|
||||
204
packages/ui/src/player/hooks/useInfiniteScroll.ts
Normal file
204
packages/ui/src/player/hooks/useInfiniteScroll.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
22
packages/ui/src/player/index.ts
Normal file
22
packages/ui/src/player/index.ts
Normal 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';
|
||||
2639
packages/ui/src/player/player.md
Normal file
2639
packages/ui/src/player/player.md
Normal file
File diff suppressed because it is too large
Load Diff
299
packages/ui/src/player/types/index.ts
Normal file
299
packages/ui/src/player/types/index.ts
Normal 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;
|
||||
}
|
||||
294
packages/ui/src/player/utils/index.ts
Normal file
294
packages/ui/src/player/utils/index.ts
Normal 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;
|
||||
}
|
||||
248
packages/ui/src/player/utils/webvtt.ts
Normal file
248
packages/ui/src/player/utils/webvtt.ts
Normal 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;
|
||||
}
|
||||
1
packages/ui/src/services/index.ts
Normal file
1
packages/ui/src/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./modbusService.js"
|
||||
474
packages/ui/src/services/modbusService.ts
Normal file
474
packages/ui/src/services/modbusService.ts
Normal 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;
|
||||
12
packages/ui/src/services/types.ts
Normal file
12
packages/ui/src/services/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
6
packages/ui/src/stream-browserify.d.ts
vendored
Normal file
6
packages/ui/src/stream-browserify.d.ts
vendored
Normal 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
94
packages/ui/src/sw.ts
Normal 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
13
packages/ui/src/types-global.d.ts
vendored
Normal 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 { };
|
||||
172
packages/ui/src/types-server.ts
Normal file
172
packages/ui/src/types-server.ts
Normal 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
1
packages/ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Loading…
Reference in New Issue
Block a user