feat(ui): ✨ Add playback controls, progress bar with tooltips, stats grid, status card animations, and real-time sync log for video editing
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3bdebb96e0
commit
f5cc1659cf
5 changed files with 0 additions and 635 deletions
|
|
@ -1,118 +0,0 @@
|
|||
import styled from '@lilith/ui-styled-components';
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Button = styled.button<{ $variant?: 'primary' | 'secondary' | 'warning' }>`
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
${({ $variant = 'primary' }) => {
|
||||
switch ($variant) {
|
||||
case 'primary':
|
||||
return `
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
&:hover:not(:disabled) { background: #4f46e5; }
|
||||
`;
|
||||
case 'secondary':
|
||||
return `
|
||||
background: #374151;
|
||||
color: white;
|
||||
&:hover:not(:disabled) { background: #4b5563; }
|
||||
`;
|
||||
case 'warning':
|
||||
return `
|
||||
background: #d97706;
|
||||
color: white;
|
||||
&:hover:not(:disabled) { background: #b45309; }
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
&:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ControlButtonsProps {
|
||||
isSyncing: boolean;
|
||||
pendingCount: number;
|
||||
onSync: () => void;
|
||||
onUploadPending: () => void;
|
||||
onForceResync: () => void;
|
||||
onRestart: () => void;
|
||||
syncLoading: boolean;
|
||||
uploadPendingLoading: boolean;
|
||||
forceResyncLoading: boolean;
|
||||
restartLoading: boolean;
|
||||
}
|
||||
|
||||
export function ControlButtons({
|
||||
isSyncing,
|
||||
pendingCount,
|
||||
onSync,
|
||||
onUploadPending,
|
||||
onForceResync,
|
||||
onRestart,
|
||||
syncLoading,
|
||||
uploadPendingLoading,
|
||||
forceResyncLoading,
|
||||
restartLoading,
|
||||
}: ControlButtonsProps) {
|
||||
const anyLoading = syncLoading || uploadPendingLoading || forceResyncLoading;
|
||||
const isDisabled = isSyncing || anyLoading;
|
||||
|
||||
const handleRestart = () => {
|
||||
if (window.confirm('Restart the Image Assistant app?')) {
|
||||
onRestart();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
$variant="primary"
|
||||
onClick={onSync}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncLoading ? 'Starting...' : isSyncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Button>
|
||||
|
||||
{pendingCount > 0 && (
|
||||
<Button
|
||||
$variant="warning"
|
||||
onClick={onUploadPending}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{uploadPendingLoading ? 'Starting...' : `Upload ${pendingCount} Pending`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
$variant="secondary"
|
||||
onClick={onForceResync}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{forceResyncLoading ? 'Starting...' : 'Full Resync'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
$variant="secondary"
|
||||
onClick={handleRestart}
|
||||
disabled={restartLoading}
|
||||
>
|
||||
{restartLoading ? 'Restarting...' : 'Restart App'}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import styled from '@lilith/ui-styled-components';
|
||||
import { SyncStats } from '@/api/hooks';
|
||||
|
||||
const Card = styled.div`
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const PendingBadge = styled.span`
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const ProgressContainer = styled.div`
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div<{ $percent: number }>`
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1 0%, #a78bfa 100%);
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
width: ${({ $percent }) => $percent}%;
|
||||
`;
|
||||
|
||||
const ProgressText = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const UploadInfo = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadInfoItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const UploadInfoValue = styled.div`
|
||||
font-weight: 600;
|
||||
color: #a78bfa;
|
||||
`;
|
||||
|
||||
const UploadInfoLabel = styled.div`
|
||||
color: #888;
|
||||
font-size: 0.7rem;
|
||||
`;
|
||||
|
||||
interface ProgressCardProps {
|
||||
stats: SyncStats;
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
export function ProgressCard({ stats, isSyncing }: ProgressCardProps) {
|
||||
const percent = Number(stats.progressPercent) || 0;
|
||||
const showUploadInfo = isSyncing && stats.currentSessionUploaded > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Progress</CardTitle>
|
||||
{stats.pendingUpload > 0 && (
|
||||
<PendingBadge>{stats.pendingUpload} pending</PendingBadge>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<ProgressContainer>
|
||||
<ProgressBar $percent={percent} />
|
||||
<ProgressText>
|
||||
{stats.uploadedCount} / {stats.photoCount} ({percent.toFixed(1)}%)
|
||||
</ProgressText>
|
||||
</ProgressContainer>
|
||||
|
||||
{showUploadInfo && (
|
||||
<UploadInfo>
|
||||
<UploadInfoItem>
|
||||
<UploadInfoValue>{stats.uploadRate}</UploadInfoValue>
|
||||
<UploadInfoLabel>Rate</UploadInfoLabel>
|
||||
</UploadInfoItem>
|
||||
<UploadInfoItem>
|
||||
<UploadInfoValue>{stats.bytesUploaded}</UploadInfoValue>
|
||||
<UploadInfoLabel>Uploaded</UploadInfoLabel>
|
||||
</UploadInfoItem>
|
||||
<UploadInfoItem>
|
||||
<UploadInfoValue>{stats.eta || '--'}</UploadInfoValue>
|
||||
<UploadInfoLabel>ETA</UploadInfoLabel>
|
||||
</UploadInfoItem>
|
||||
<UploadInfoItem>
|
||||
<UploadInfoValue>{stats.currentSessionUploaded}</UploadInfoValue>
|
||||
<UploadInfoLabel>This Session</UploadInfoLabel>
|
||||
</UploadInfoItem>
|
||||
</UploadInfo>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import styled from '@lilith/ui-styled-components';
|
||||
import { SyncStats } from '@/api/hooks';
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
`;
|
||||
|
||||
const StatBox = styled.div<{ $error?: boolean }>`
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: ${({ $error }) =>
|
||||
$error ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 255, 255, 0.03)'};
|
||||
border-radius: 8px;
|
||||
border: ${({ $error }) =>
|
||||
$error ? '1px solid rgba(239, 68, 68, 0.3)' : 'none'};
|
||||
`;
|
||||
|
||||
const StatValue = styled.div<{ $warning?: boolean; $error?: boolean }>`
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: ${({ $warning, $error }) =>
|
||||
$error ? '#ef4444' : $warning ? '#eab308' : '#a78bfa'};
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-top: 0.25rem;
|
||||
`;
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: SyncStats;
|
||||
}
|
||||
|
||||
export function StatsGrid({ stats }: StatsGridProps) {
|
||||
const hasPending = stats.pendingUpload > 0;
|
||||
const hasFailed = stats.failedBatches > 0;
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<StatBox>
|
||||
<StatValue>{stats.photoCount}</StatValue>
|
||||
<StatLabel>Total Photos</StatLabel>
|
||||
</StatBox>
|
||||
|
||||
<StatBox>
|
||||
<StatValue>{stats.uploadedCount}</StatValue>
|
||||
<StatLabel>Uploaded</StatLabel>
|
||||
</StatBox>
|
||||
|
||||
<StatBox $error={hasPending}>
|
||||
<StatValue $warning={hasPending}>{stats.pendingUpload}</StatValue>
|
||||
<StatLabel>Pending Upload</StatLabel>
|
||||
</StatBox>
|
||||
|
||||
<StatBox>
|
||||
<StatValue>{stats.albumCount}</StatValue>
|
||||
<StatLabel>Albums</StatLabel>
|
||||
</StatBox>
|
||||
|
||||
{hasFailed && (
|
||||
<StatBox $error>
|
||||
<StatValue $error>{stats.failedBatches}</StatValue>
|
||||
<StatLabel>Failed Batches</StatLabel>
|
||||
</StatBox>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
import styled, { css, keyframes } from '@lilith/ui-styled-components';
|
||||
import { StatusResponse } from '@/api/hooks';
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
`;
|
||||
|
||||
const Card = styled.div`
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
margin: 0 0 0.75rem 0;
|
||||
`;
|
||||
|
||||
const StatusRow = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
const Badge = styled.span<{ $variant: BadgeVariant }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
${({ $variant }) => {
|
||||
switch ($variant) {
|
||||
case 'success':
|
||||
return css`
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
&::before { background: #22c55e; }
|
||||
`;
|
||||
case 'warning':
|
||||
return css`
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
&::before { background: #eab308; }
|
||||
`;
|
||||
case 'error':
|
||||
return css`
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
&::before { background: #ef4444; }
|
||||
`;
|
||||
case 'info':
|
||||
return css`
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
animation: ${pulse} 1.5s infinite;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
margin-top: 0.75rem;
|
||||
`;
|
||||
|
||||
const BackendUrl = styled.code`
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const ErrorCard = styled(Card)`
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
`;
|
||||
|
||||
const PermissionText = styled.p`
|
||||
margin: 0.5rem 0;
|
||||
color: #ccc;
|
||||
`;
|
||||
|
||||
const PermissionHint = styled.p`
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
`;
|
||||
|
||||
interface StatusCardProps {
|
||||
status: StatusResponse;
|
||||
onResetPhotosPermission: () => void;
|
||||
onOpenPhotosSettings: () => void;
|
||||
isResetting: boolean;
|
||||
}
|
||||
|
||||
export function StatusCard({
|
||||
status,
|
||||
onResetPhotosPermission,
|
||||
onOpenPhotosSettings,
|
||||
isResetting,
|
||||
}: StatusCardProps) {
|
||||
// Derive badge variants from status
|
||||
const backendVariant: BadgeVariant = status.backendReachable ? 'success' : 'error';
|
||||
const backendLabel = status.backendReachable ? 'Connected' : 'Unreachable';
|
||||
|
||||
let authVariant: BadgeVariant;
|
||||
let authLabel: string;
|
||||
if (status.isAuthenticated) {
|
||||
authVariant = 'success';
|
||||
authLabel = 'Authenticated';
|
||||
} else if (status.registrationCode) {
|
||||
authVariant = 'warning';
|
||||
authLabel = `Pending - Code: ${status.registrationCode}`;
|
||||
} else {
|
||||
authVariant = 'error';
|
||||
authLabel = 'Not registered';
|
||||
}
|
||||
|
||||
let syncVariant: BadgeVariant;
|
||||
let syncLabel: string;
|
||||
if (!status.backendReachable && !status.isSyncing) {
|
||||
syncVariant = 'error';
|
||||
syncLabel = 'Backend unavailable';
|
||||
} else if (status.isSyncing) {
|
||||
syncVariant = 'info';
|
||||
syncLabel = status.currentOperation || 'Syncing...';
|
||||
} else if (status.syncError) {
|
||||
syncVariant = 'error';
|
||||
syncLabel = status.syncError;
|
||||
} else {
|
||||
syncVariant = 'success';
|
||||
syncLabel = 'Idle';
|
||||
}
|
||||
|
||||
const photosVariant: BadgeVariant = status.photosAuthorized ? 'success' : 'warning';
|
||||
const photosLabel = status.photosAuthorized ? 'Photos: Authorized' : 'Photos: Not Granted';
|
||||
|
||||
const lastSyncFormatted = status.lastSync
|
||||
? new Date(status.lastSync).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
return (
|
||||
<>
|
||||
{!status.photosAuthorized && (
|
||||
<ErrorCard>
|
||||
<CardTitle>Photos Access Required</CardTitle>
|
||||
<PermissionText>
|
||||
This app needs permission to access your Photos library to sync images.
|
||||
</PermissionText>
|
||||
<PermissionHint>
|
||||
If permission was already granted, click "Reset & Re-request" to fix TCC signature mismatch.
|
||||
</PermissionHint>
|
||||
<StatusRow style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
onClick={onOpenPhotosSettings}
|
||||
style={{
|
||||
background: '#6366f1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={onResetPhotosPermission}
|
||||
disabled={isResetting}
|
||||
style={{
|
||||
background: '#dc2626',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '6px',
|
||||
cursor: isResetting ? 'not-allowed' : 'pointer',
|
||||
opacity: isResetting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isResetting ? 'Resetting...' : 'Reset & Re-request'}
|
||||
</button>
|
||||
</StatusRow>
|
||||
</ErrorCard>
|
||||
)}
|
||||
|
||||
<Card style={!status.backendReachable ? { borderColor: 'rgba(239, 68, 68, 0.5)', background: 'rgba(239, 68, 68, 0.05)' } : {}}>
|
||||
<CardTitle>Backend Connection</CardTitle>
|
||||
<Badge $variant={backendVariant}>{backendLabel}</Badge>
|
||||
<br />
|
||||
<BackendUrl>{status.backendURL}</BackendUrl>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<Badge $variant={authVariant}>{authLabel}</Badge>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Sync Status</CardTitle>
|
||||
<StatusRow>
|
||||
<Badge $variant={syncVariant}>{syncLabel}</Badge>
|
||||
<Badge $variant={photosVariant}>{photosLabel}</Badge>
|
||||
</StatusRow>
|
||||
<Meta>Last sync: {lastSyncFormatted}</Meta>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import styled from '@lilith/ui-styled-components';
|
||||
|
||||
const Card = styled.div`
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
margin: 0 0 0.75rem 0;
|
||||
`;
|
||||
|
||||
const LogContainer = styled.div`
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
`;
|
||||
|
||||
const LogEntry = styled.div`
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #a0a0a0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyLog = styled.div`
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
interface SyncLogProps {
|
||||
entries: string[];
|
||||
}
|
||||
|
||||
export function SyncLog({ entries }: SyncLogProps) {
|
||||
// Show most recent entries first (reversed)
|
||||
const displayEntries = [...entries].reverse();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardTitle>Sync Log</CardTitle>
|
||||
<LogContainer>
|
||||
{displayEntries.length === 0 ? (
|
||||
<EmptyLog>No log entries yet</EmptyLog>
|
||||
) : (
|
||||
displayEntries.map((entry, index) => (
|
||||
<LogEntry key={`${index}-${entry.substring(0, 20)}`}>
|
||||
{entry}
|
||||
</LogEntry>
|
||||
))
|
||||
)}
|
||||
</LogContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue