diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..5d75610 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' +import React from 'react' + +interface FooterProps { + LinkComponent?: any + appName?: string +} + +export function Footer({ LinkComponent = Link, appName = 'Pulse' }: FooterProps) { + const Component = LinkComponent + + return ( + + ) +} diff --git a/components/LoadingOverlay.tsx b/components/LoadingOverlay.tsx new file mode 100644 index 0000000..342cc27 --- /dev/null +++ b/components/LoadingOverlay.tsx @@ -0,0 +1,42 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +interface LoadingOverlayProps { + logoSrc?: string + title?: string +} + +export default function LoadingOverlay({ + logoSrc = "/ciphera_icon_no_margins.png", + title = "Pulse" +}: LoadingOverlayProps) { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + return () => setMounted(false) + }, []) + + if (!mounted) return null + + return createPortal( +
+
+
+ {typeof + + CipheraPulse + +
+
+
+
, + document.body + ) +} diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx new file mode 100644 index 0000000..db9b651 --- /dev/null +++ b/components/PasswordInput.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' + +interface PasswordInputProps { + value: string + onChange: (value: string) => void + label?: string + placeholder?: string + error?: string | null + disabled?: boolean + required?: boolean + className?: string + id?: string + autoComplete?: string + minLength?: number + onFocus?: () => void + onBlur?: () => void +} + +export default function PasswordInput({ + value, + onChange, + label = 'Password', + placeholder = 'Enter password', + error, + disabled = false, + required = false, + className = '', + id, + autoComplete, + minLength, + onFocus, + onBlur +}: PasswordInputProps) { + const [showPassword, setShowPassword] = useState(false) + const inputId = id || 'password-input' + const errorId = `${inputId}-error` + + return ( +
+ {label && ( + + )} +
+ onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + autoComplete={autoComplete} + minLength={minLength} + onFocus={onFocus} + onBlur={onBlur} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + className={`w-full pl-11 pr-12 py-3 border rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900 + transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white + ${error + ? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10' + : 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10' + }`} + /> + + {/* Lock Icon (Left) */} +
+ +
+ + {/* Toggle Visibility Button (Right) */} + +
+ {error && ( + + )} +
+ ) +} diff --git a/components/ReplayPlayerControls.tsx b/components/ReplayPlayerControls.tsx new file mode 100644 index 0000000..a110f35 --- /dev/null +++ b/components/ReplayPlayerControls.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + PlayIcon, + PauseIcon, + EnterFullScreenIcon, + ExitFullScreenIcon, +} from '@radix-ui/react-icons' + +/** Formats milliseconds as mm:ss. */ +function formatTime(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0:00' + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + return `${m}:${String(s % 60).padStart(2, '0')}` +} + +const SPEED_OPTIONS = [1, 2, 4, 8] as const + +export type ReplayPlayerControlsProps = { + isPlaying: boolean + onPlayPause: () => void + currentTimeMs: number + totalTimeMs: number + onSeek: (fraction: number) => void + speed: number + onSpeedChange: (speed: number) => void + skipInactive: boolean + onSkipInactiveChange: () => void + onFullscreenRequest: () => void +} + +/** + * Custom session replay player controls with Ciphera branding. + * Matches design: brand orange #FD5E0F, Plus Jakarta Sans, rounded-xl, neutral greys. + */ +export default function ReplayPlayerControls({ + isPlaying, + onPlayPause, + currentTimeMs, + totalTimeMs, + onSeek, + speed, + onSpeedChange, + skipInactive, + onSkipInactiveChange, + onFullscreenRequest, +}: ReplayPlayerControlsProps) { + const [isFullscreen, setIsFullscreen] = useState(false) + const [isSeeking, setIsSeeking] = useState(false) + const [seekValue, setSeekValue] = useState(0) + + const totalSec = totalTimeMs / 1000 + const currentSec = currentTimeMs / 1000 + const fraction = totalSec > 0 ? Math.min(1, Math.max(0, currentSec / totalSec)) : 0 + const displayFraction = isSeeking ? seekValue : fraction + + useEffect(() => { + const onFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => document.removeEventListener('fullscreenchange', onFullscreenChange) + }, []) + + const handleSeekChange = (e: React.ChangeEvent) => { + const v = parseFloat(e.target.value) + const p = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0 + setSeekValue(p) + onSeek(p) + } + const handleSeekPointerDown = () => { + setSeekValue(fraction) + setIsSeeking(true) + } + const handleSeekPointerUp = () => setIsSeeking(false) + + return ( +
+ {/* * Progress bar / timeline */} +
+ + {formatTime(currentTimeMs)} + +
+
+ {displayFraction > 0 && displayFraction < 1 && ( +
+ )} +
+ +
+ + {formatTime(totalTimeMs)} + +
+ + {/* * Buttons row */} +
+
+ {/* * Play / Pause */} + + + {/* * Speed pills */} +
+ {SPEED_OPTIONS.map((s) => ( + + ))} +
+ + {/* * Skip inactive toggle */} + +
+ + {/* * Fullscreen */} + +
+
+ ) +} diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx new file mode 100644 index 0000000..29c2466 --- /dev/null +++ b/components/WorkspaceSwitcher.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons' +import { switchContext, OrganizationMember } from '@/lib/api/organization' +import { setSessionAction } from '@/app/actions/auth' +import Link from 'next/link' + +export default function WorkspaceSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) { + const router = useRouter() + const [switching, setSwitching] = useState(null) + + const handleSwitch = async (orgId: string | null) => { + console.log('Switching to workspace:', orgId) + setSwitching(orgId || 'personal') + try { + // * If orgId is null, we can't switch context via API in the same way if strict mode is on + // * BUT, Pulse doesn't support personal workspace. + // * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced. + // * However, to match Drop exactly, we might want to show it but have it fail or redirect? + // * Let's assume for now we want to match Drop's UI structure. + + if (!orgId) { + // * Pulse doesn't support personal context. + // * We could redirect to onboarding or show an error. + // * For now, let's just return to avoid breaking. + return + } + + const { access_token } = await switchContext(orgId) + + // * Update session cookie via server action + // * Note: switchContext only returns access_token, we keep existing refresh token + await setSessionAction(access_token) + + // Force reload to pick up new permissions + window.location.reload() + + } catch (err) { + console.error('Failed to switch workspace', err) + setSwitching(null) + } + } + + return ( +
+
+ Workspaces +
+ + {/* Personal Workspace - HIDDEN IN PULSE (Strict Mode) */} + {/* + + */} + + {/* Organization Workspaces */} + {orgs.map((org) => ( + + ))} + + {/* Create New */} + +
+ +
+ Create Organization + +
+ ) +}