(
WrappedComponent: React.ComponentType,
- fallback?: ReactNode
+ fallback?: ReactNode,
+ context?: Record
): React.ComponentType {
const name = WrappedComponent.name !== '' ? WrappedComponent.name : undefined
const displayName = WrappedComponent.displayName ?? name ?? 'Component'
const ComponentWithErrorBoundary = (props: P) => (
-
+
)
diff --git a/frontends/nextjs/src/components/LoadingIndicator.tsx b/frontends/nextjs/src/components/LoadingIndicator.tsx
new file mode 100644
index 000000000..1a750cf0f
--- /dev/null
+++ b/frontends/nextjs/src/components/LoadingIndicator.tsx
@@ -0,0 +1,294 @@
+'use client'
+
+import React from 'react'
+
+/**
+ * Loading Indicator Component
+ *
+ * Shows progress during async operations.
+ * Supports different display modes: spinner, bar, dots, etc.
+ */
+
+export interface LoadingIndicatorProps {
+ /**
+ * Whether to show the loading indicator
+ * @default true
+ */
+ show?: boolean
+
+ /**
+ * Loading message to display
+ */
+ message?: string
+
+ /**
+ * Variant: 'spinner', 'bar', 'dots', 'pulse'
+ * @default 'spinner'
+ */
+ variant?: 'spinner' | 'bar' | 'dots' | 'pulse'
+
+ /**
+ * Size of the indicator: 'small', 'medium', 'large'
+ * @default 'medium'
+ */
+ size?: 'small' | 'medium' | 'large'
+
+ /**
+ * Whether to show full page overlay
+ * @default false
+ */
+ fullPage?: boolean
+
+ /**
+ * CSS class name for custom styling
+ */
+ className?: string
+
+ /**
+ * Custom style overrides
+ */
+ style?: React.CSSProperties
+}
+
+export function LoadingIndicator({
+ show = true,
+ message,
+ variant = 'spinner',
+ size = 'medium',
+ fullPage = false,
+ className,
+ style,
+}: LoadingIndicatorProps) {
+ if (!show) {
+ return null
+ }
+
+ const sizeMap = {
+ small: '24px',
+ medium: '40px',
+ large: '60px',
+ }
+
+ const containerStyle: React.CSSProperties = fullPage
+ ? {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ zIndex: 9999,
+ ...style,
+ }
+ : {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '20px',
+ ...style,
+ }
+
+ return (
+
+ {variant === 'spinner' &&
}
+ {variant === 'bar' &&
}
+ {variant === 'dots' &&
}
+ {variant === 'pulse' &&
}
+
+ {message && (
+
+ {message}
+
+ )}
+
+ )
+}
+
+/**
+ * Spinner icon component
+ */
+interface IconProps {
+ size: string
+}
+
+function SpinnerIcon({ size }: IconProps) {
+ return (
+
+ )
+}
+
+function PulseIcon({ size }: IconProps) {
+ return (
+
+ )
+}
+
+/**
+ * Progress bar component
+ */
+interface ProgressBarProps {
+ size: 'small' | 'medium' | 'large'
+}
+
+function ProgressBar({ size }: ProgressBarProps) {
+ const heightMap = {
+ small: '2px',
+ medium: '4px',
+ large: '6px',
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * Animated dots component
+ */
+interface DotsAnimationProps {
+ size: 'small' | 'medium' | 'large'
+}
+
+function DotsAnimation({ size }: DotsAnimationProps) {
+ const dotMap = {
+ small: '6px',
+ medium: '10px',
+ large: '14px',
+ }
+
+ const dotSize = dotMap[size]
+
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ )
+}
+
+/**
+ * Inline loading spinner for buttons and text
+ */
+export interface InlineLoaderProps {
+ loading?: boolean
+ size?: 'small' | 'medium'
+ style?: React.CSSProperties
+}
+
+export function InlineLoader({ loading = true, size = 'small', style }: InlineLoaderProps) {
+ if (!loading) {
+ return null
+ }
+
+ const sizeMap = {
+ small: '16px',
+ medium: '20px',
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * Loading state for async operations with skeleton fallback
+ */
+export interface AsyncLoadingProps {
+ isLoading: boolean
+ error?: Error | string | null
+ children: React.ReactNode
+ skeletonComponent?: React.ReactNode
+ errorComponent?: React.ReactNode
+ loadingMessage?: string
+}
+
+export function AsyncLoading({
+ isLoading,
+ error,
+ children,
+ skeletonComponent,
+ errorComponent,
+ loadingMessage,
+}: AsyncLoadingProps) {
+ if (isLoading) {
+ return skeletonComponent ??
+ }
+
+ if (error) {
+ return errorComponent ?? Error loading content
+ }
+
+ return <>{children}>
+}
diff --git a/frontends/nextjs/src/components/Skeleton.tsx b/frontends/nextjs/src/components/Skeleton.tsx
new file mode 100644
index 000000000..afd29a647
--- /dev/null
+++ b/frontends/nextjs/src/components/Skeleton.tsx
@@ -0,0 +1,177 @@
+'use client'
+
+import React from 'react'
+
+/**
+ * Skeleton Component for Loading States
+ *
+ * Creates animated placeholder content while data is loading.
+ * Use for tables, cards, lists, and other async-loaded content.
+ */
+
+export interface SkeletonProps {
+ /**
+ * Width of the skeleton (can be percentage or fixed value)
+ * @default '100%'
+ */
+ width?: string | number
+
+ /**
+ * Height of the skeleton (can be percentage or fixed value)
+ * @default '20px'
+ */
+ height?: string | number
+
+ /**
+ * Border radius for rounded corners
+ * @default '4px'
+ */
+ borderRadius?: string | number
+
+ /**
+ * Whether to show animation
+ * @default true
+ */
+ animate?: boolean
+
+ /**
+ * CSS class name for custom styling
+ */
+ className?: string
+
+ /**
+ * Custom style overrides
+ */
+ style?: React.CSSProperties
+}
+
+/**
+ * Single skeleton line/block
+ */
+export function Skeleton({
+ width = '100%',
+ height = '20px',
+ borderRadius = '4px',
+ animate = true,
+ className,
+ style,
+}: SkeletonProps) {
+ const widthStyle = typeof width === 'number' ? `${width}px` : width
+ const heightStyle = typeof height === 'number' ? `${height}px` : height
+ const radiusStyle = typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius
+
+ return (
+
+ )
+}
+
+/**
+ * Table skeleton with rows and columns
+ */
+export interface TableSkeletonProps {
+ rows?: number
+ columns?: number
+ className?: string
+}
+
+export function TableSkeleton({ rows = 5, columns = 4, className }: TableSkeletonProps) {
+ return (
+
+
+
+
+ {Array.from({ length: columns }).map((_, i) => (
+ |
+
+ |
+ ))}
+
+
+
+ {Array.from({ length: rows }).map((_, rowIdx) => (
+
+ {Array.from({ length: columns }).map((_, colIdx) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+/**
+ * Card skeleton layout
+ */
+export interface CardSkeletonProps {
+ count?: number
+ className?: string
+}
+
+export function CardSkeleton({ count = 3, className }: CardSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+/**
+ * List item skeleton
+ */
+export interface ListSkeletonProps {
+ count?: number
+ className?: string
+}
+
+export function ListSkeleton({ count = 8, className }: ListSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/frontends/nextjs/src/components/index.ts b/frontends/nextjs/src/components/index.ts
new file mode 100644
index 000000000..87b6b398c
--- /dev/null
+++ b/frontends/nextjs/src/components/index.ts
@@ -0,0 +1,52 @@
+/**
+ * Component Export Index
+ *
+ * Centralized exports for all reusable components.
+ */
+
+// Loading & Skeletons
+export { Skeleton, TableSkeleton, CardSkeleton, ListSkeleton } from './Skeleton'
+export type { SkeletonProps, TableSkeletonProps, CardSkeletonProps, ListSkeletonProps } from './Skeleton'
+
+// Empty States
+export {
+ EmptyState,
+ NoDataFound,
+ NoResultsFound,
+ NoItemsYet,
+ AccessDeniedState,
+ ErrorState,
+} from './EmptyState'
+export type { EmptyStateProps } from './EmptyState'
+
+// Loading Indicators
+export {
+ LoadingIndicator,
+ InlineLoader,
+ AsyncLoading,
+} from './LoadingIndicator'
+export type {
+ LoadingIndicatorProps,
+ InlineLoaderProps,
+ AsyncLoadingProps,
+} from './LoadingIndicator'
+
+// Error Boundary
+export {
+ ErrorBoundary,
+ withErrorBoundary,
+} from './ErrorBoundary'
+export type { ErrorBoundaryProps } from './ErrorBoundary'
+
+// Access Control
+export { AccessDenied } from './AccessDenied'
+
+// Component Rendering
+export { JSONComponentRenderer } from './JSONComponentRenderer'
+
+// Pagination
+export {
+ PaginationControls,
+ PaginationInfo,
+ ItemsPerPageSelector,
+} from './pagination'
diff --git a/frontends/nextjs/src/lib/config/prisma.ts b/frontends/nextjs/src/lib/config/prisma.ts
index 6870641cc..4bf4e6abf 100644
--- a/frontends/nextjs/src/lib/config/prisma.ts
+++ b/frontends/nextjs/src/lib/config/prisma.ts
@@ -40,9 +40,9 @@ const createIntegrationPrisma = (): PrismaClient => {
const createProductionPrisma = (): PrismaClient => {
// CRITICAL: Validate DATABASE_URL is set and properly formatted
- const databaseUrl = (process.env.DATABASE_URL !== undefined && process.env.DATABASE_URL.length > 0)
- ? process.env.DATABASE_URL
- : 'file:../../prisma/prisma/dev.db'
+ const databaseUrl = (process.env.DATABASE_URL !== undefined && process.env.DATABASE_URL.length > 0)
+ ? process.env.DATABASE_URL
+ : 'file:../../../dbal/shared/prisma/dev.db'
console.warn('[Prisma] Creating production Prisma client')
console.warn('[Prisma] DATABASE_URL from env:', process.env.DATABASE_URL)
diff --git a/frontends/nextjs/src/lib/error-reporting.ts b/frontends/nextjs/src/lib/error-reporting.ts
new file mode 100644
index 000000000..8d72a5865
--- /dev/null
+++ b/frontends/nextjs/src/lib/error-reporting.ts
@@ -0,0 +1,180 @@
+/**
+ * Error Reporting & Logging System
+ *
+ * Centralized error handling, logging, and user-friendly error messages.
+ * Supports both development and production error reporting.
+ */
+
+export interface ErrorReportContext {
+ component?: string
+ userId?: string
+ tenantId?: string
+ action?: string
+ timestamp?: Date
+ [key: string]: unknown
+}
+
+export interface ErrorReport {
+ id: string
+ message: string
+ code?: string
+ statusCode?: number
+ stack?: string
+ context: ErrorReportContext
+ timestamp: Date
+ isDevelopment: boolean
+}
+
+class ErrorReportingService {
+ private errors: ErrorReport[] = []
+ private maxErrors = 100 // Keep last 100 errors in memory
+
+ /**
+ * Report an error with context
+ */
+ reportError(error: Error | string, context: ErrorReportContext = {}): ErrorReport {
+ const report: ErrorReport = {
+ id: this.generateId(),
+ message: typeof error === 'string' ? error : error.message,
+ stack: error instanceof Error ? error.stack : undefined,
+ context: {
+ ...context,
+ timestamp: new Date(),
+ },
+ timestamp: new Date(),
+ isDevelopment: process.env.NODE_ENV === 'development',
+ }
+
+ this.errors.push(report)
+
+ // Keep only last N errors
+ if (this.errors.length > this.maxErrors) {
+ this.errors = this.errors.slice(-this.maxErrors)
+ }
+
+ // Log in development
+ if (process.env.NODE_ENV === 'development') {
+ console.error('[ErrorReporting]', {
+ message: report.message,
+ context: report.context,
+ stack: report.stack,
+ })
+ }
+
+ // Send to monitoring in production (placeholder)
+ if (process.env.NODE_ENV === 'production') {
+ this.sendToMonitoring(report)
+ }
+
+ return report
+ }
+
+ /**
+ * Get user-friendly error message
+ */
+ getUserMessage(error: Error | string): string {
+ if (typeof error === 'string') {
+ return error
+ }
+
+ // Extract status code from common error patterns
+ const statusMatch = error.message.match(/(\d{3})/)?.[1]
+ if (statusMatch) {
+ return this.getHttpErrorMessage(parseInt(statusMatch, 10))
+ }
+
+ // Return generic message in production, detailed in development
+ if (process.env.NODE_ENV === 'development') {
+ return error.message
+ }
+
+ // Check for common error patterns
+ if (error.message.includes('network') || error.message.includes('fetch')) {
+ return 'Network error. Please check your connection and try again.'
+ }
+
+ if (error.message.includes('timeout')) {
+ return 'Request timed out. Please try again.'
+ }
+
+ if (error.message.includes('permission')) {
+ return 'You do not have permission to perform this action.'
+ }
+
+ return 'An error occurred. Please try again later.'
+ }
+
+ /**
+ * Get user message for HTTP error codes
+ */
+ private getHttpErrorMessage(statusCode: number): string {
+ const messages: Record = {
+ 400: 'Invalid request. Please check your input.',
+ 401: 'Unauthorized. Please log in again.',
+ 403: 'You do not have permission to access this resource.',
+ 404: 'The requested resource was not found.',
+ 409: 'This resource already exists.',
+ 429: 'Too many requests. Please try again later.',
+ 500: 'Server error. Please try again later.',
+ 502: 'Bad gateway. Please try again later.',
+ 503: 'Service unavailable. Please try again later.',
+ 504: 'Gateway timeout. Please try again later.',
+ }
+
+ return messages[statusCode] ?? 'An error occurred. Please try again.'
+ }
+
+ /**
+ * Get all reported errors (development only)
+ */
+ getErrors(): ErrorReport[] {
+ if (process.env.NODE_ENV !== 'development') {
+ return []
+ }
+ return [...this.errors]
+ }
+
+ /**
+ * Clear error history
+ */
+ clearErrors(): void {
+ this.errors = []
+ }
+
+ /**
+ * Send error to monitoring service (placeholder)
+ */
+ private sendToMonitoring(report: ErrorReport): void {
+ // TODO: Implement actual monitoring integration (e.g., Sentry, DataDog)
+ // Example:
+ // fetch('/api/monitoring/errors', {
+ // method: 'POST',
+ // headers: { 'Content-Type': 'application/json' },
+ // body: JSON.stringify(report),
+ // }).catch(() => {})
+ }
+
+ /**
+ * Generate unique ID for error report
+ */
+ private generateId(): string {
+ return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+ }
+}
+
+// Singleton instance
+export const errorReporting = new ErrorReportingService()
+
+/**
+ * Hook for React components to report errors
+ */
+export function useErrorReporting() {
+ return {
+ reportError: (error: Error | string, context: ErrorReportContext) => {
+ return errorReporting.reportError(error, context)
+ },
+ getUserMessage: (error: Error | string) => {
+ return errorReporting.getUserMessage(error)
+ },
+ }
+}
diff --git a/frontends/nextjs/src/main.scss b/frontends/nextjs/src/main.scss
index 52811f18e..1a185fe33 100644
--- a/frontends/nextjs/src/main.scss
+++ b/frontends/nextjs/src/main.scss
@@ -8,5 +8,219 @@
@use './styles/core/theme' as *;
// FakeMUI component styles
-@import '../../../fakemui/styles/base.scss';
-@import '../../../fakemui/styles/components.scss';
+@use '../../../fakemui/styles/base';
+@use '../../../fakemui/styles/components';
+
+// ========================================
+// UX Polish: Loading & Animation Styles
+// ========================================
+
+// Skeleton loading animation
+@keyframes skeleton-pulse {
+ 0% {
+ background-color: #e0e0e0;
+ }
+ 50% {
+ background-color: #f0f0f0;
+ }
+ 100% {
+ background-color: #e0e0e0;
+ }
+}
+
+.skeleton {
+ display: inline-block;
+ &.skeleton-animate {
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
+ }
+}
+
+// Page transition fade-in
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.page-transition {
+ animation: fade-in 0.3s ease-in;
+}
+
+// Smooth hover effects
+@keyframes button-hover {
+ from {
+ transform: translateY(0);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+ to {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+ }
+}
+
+button,
+a[role='button'] {
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+
+ &:hover {
+ animation: button-hover 0.2s ease forwards;
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
+
+// Loading spinner
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.loading-spinner {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 2px solid #e0e0e0;
+ border-top: 2px solid #228be6;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+// Staggered list animations
+@keyframes slide-in {
+ from {
+ opacity: 0;
+ transform: translateX(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.list-item-animated {
+ animation: slide-in 0.3s ease forwards;
+
+ @for $i from 1 through 20 {
+ &:nth-child(#{$i}) {
+ animation-delay: #{($i - 1) * 50}ms;
+ }
+ }
+}
+
+// Empty state styling
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: #868e96;
+
+ .empty-state-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+ opacity: 0.5;
+ }
+
+ .empty-state-title {
+ font-size: 20px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: #495057;
+ }
+
+ .empty-state-message {
+ font-size: 14px;
+ margin-bottom: 24px;
+ max-width: 400px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .empty-state-action {
+ margin-top: 16px;
+ }
+}
+
+// Accessibility focus states
+button,
+a,
+input,
+select,
+textarea {
+ &:focus-visible {
+ outline: 2px solid #228be6;
+ outline-offset: 2px;
+ }
+}
+
+// Progress bar animation
+@keyframes progress-animation {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 50% {
+ transform: translateX(100%);
+ }
+ 100% {
+ transform: translateX(100%);
+ }
+}
+
+// Dots loading animation
+@keyframes dots-animation {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.5;
+ transform: scale(0.8);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+// Pulse animation
+@keyframes pulse-animation {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+// Print styles
+@media print {
+ .no-print,
+ button,
+ .loading-spinner,
+ .page-transition,
+ .loading-indicator {
+ display: none;
+ }
+
+ .print-only {
+ display: block !important;
+ }
+}
+
+// Accessibility: Reduce motion
+@media (prefers-reduced-motion: reduce) {
+ .skeleton-animate,
+ .loading-spinner,
+ .page-transition,
+ .list-item-animated,
+ button {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/services/media_daemon/build-config/CMakeUserPresets.json b/services/media_daemon/build-config/CMakeUserPresets.json
new file mode 100644
index 000000000..889fff90c
--- /dev/null
+++ b/services/media_daemon/build-config/CMakeUserPresets.json
@@ -0,0 +1,9 @@
+{
+ "version": 4,
+ "vendor": {
+ "conan": {}
+ },
+ "include": [
+ "build/build/Release/generators/CMakePresets.json"
+ ]
+}
\ No newline at end of file