mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
- Remove nested git repository from bugulma/frontend/.git - Add all frontend files to main repository tracking - Convert from separate frontend/backend repos to unified monorepo - Preserve all frontend code and development history as tracked files - Eliminate nested repository complexity for simpler development workflow This creates a proper monorepo structure with frontend and backend coexisting in the same repository for easier development and deployment.
170 lines
5.9 KiB
TypeScript
170 lines
5.9 KiB
TypeScript
import { motion } from 'framer-motion';
|
|
import React from 'react';
|
|
import { useTranslation } from '@/hooks/useI18n';
|
|
import { CheckCircle, Clock, User } from 'lucide-react';
|
|
import { Flex, Stack } from '@/components/ui/layout';
|
|
|
|
export interface TimelineEntry {
|
|
id: string;
|
|
timestamp: string | Date;
|
|
title: string;
|
|
description?: string;
|
|
actor?: string;
|
|
action?: string;
|
|
status?: string;
|
|
oldValue?: string;
|
|
newValue?: string;
|
|
}
|
|
|
|
interface TimelineProps {
|
|
entries: TimelineEntry[];
|
|
className?: string;
|
|
}
|
|
|
|
const Timeline: React.FC<TimelineProps> = ({ entries, className = '' }) => {
|
|
const { t } = useTranslation();
|
|
|
|
const getActionIcon = (action?: string) => {
|
|
switch (action?.toLowerCase()) {
|
|
case 'status_change':
|
|
return <CheckCircle className="h-4 h-5 text-current text-success w-4 w-5" />;
|
|
case 'comment':
|
|
return <User className="h-4 h-5 text-current text-primary w-4 w-5" />;
|
|
case 'update':
|
|
return <Clock className="h-4 h-5 text-current text-warning w-4 w-5" />;
|
|
default:
|
|
return <Clock className="h-4 h-5 text-current text-muted-foreground w-4 w-5" />;
|
|
}
|
|
};
|
|
|
|
const getActionColor = (action?: string) => {
|
|
switch (action?.toLowerCase()) {
|
|
case 'status_change':
|
|
return 'border-success/20 bg-success/10';
|
|
case 'comment':
|
|
return 'border-primary/20 bg-primary/10';
|
|
case 'update':
|
|
return 'border-warning/20 bg-warning/10';
|
|
default:
|
|
return 'border-muted bg-muted/30';
|
|
}
|
|
};
|
|
|
|
const formatTimestamp = (timestamp: string | Date) => {
|
|
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
|
|
let relative: string;
|
|
if (diffMinutes < 1) {
|
|
relative = 'just now';
|
|
} else if (diffMinutes < 60) {
|
|
relative = `${diffMinutes} minutes ago`;
|
|
} else if (diffHours < 24) {
|
|
relative = `${diffHours} hours ago`;
|
|
} else if (diffDays < 7) {
|
|
relative = `${diffDays} days ago`;
|
|
} else {
|
|
relative = date.toLocaleDateString();
|
|
}
|
|
|
|
const absolute = date.toLocaleString();
|
|
|
|
return { relative, absolute };
|
|
};
|
|
|
|
if (!entries || entries.length === 0) {
|
|
return (
|
|
<div className={`text-center py-8 text-muted-foreground ${className}`}>
|
|
<Clock className="h-12 h-4 mb-4 mx-auto opacity-50 text-current w-12 w-4" />
|
|
<p>{t('timeline.noEntries', 'No timeline entries')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sort entries by timestamp (most recent first)
|
|
const sortedEntries = [...entries].sort((a, b) => {
|
|
const dateA = typeof a.timestamp === 'string' ? new Date(a.timestamp) : a.timestamp;
|
|
const dateB = typeof b.timestamp === 'string' ? new Date(b.timestamp) : b.timestamp;
|
|
return dateB.getTime() - dateA.getTime();
|
|
});
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
{/* Timeline line */}
|
|
<div className="absolute left-6 top-0 bottom-0 w-px bg-border" />
|
|
|
|
<Stack spacing="md">
|
|
{sortedEntries.map((entry, index) => {
|
|
const timestamp = formatTimestamp(entry.timestamp);
|
|
|
|
return (
|
|
<motion.div
|
|
key={entry.id}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
className="relative flex gap-4"
|
|
>
|
|
{/* Timeline dot */}
|
|
<div className={`relative z-10 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 ${getActionColor(entry.action)}`}>
|
|
{getActionIcon(entry.action)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0 pb-8">
|
|
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
|
<Flex align="start" justify="between" className="mb-2">
|
|
<h4 className="font-medium text-foreground">{entry.title}</h4>
|
|
<time
|
|
className="text-xs text-muted-foreground shrink-0 ml-2"
|
|
title={timestamp.absolute}
|
|
>
|
|
{timestamp.relative}
|
|
</time>
|
|
</Flex>
|
|
|
|
{entry.description && (
|
|
<p className="text-sm text-muted-foreground mb-2">
|
|
{entry.description}
|
|
</p>
|
|
)}
|
|
|
|
{entry.actor && (
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-2">
|
|
<User className="h-3 h-4 text-current w-3 w-4" />
|
|
<span>{entry.actor}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status change details */}
|
|
{entry.action === 'status_change' && entry.oldValue && entry.newValue && (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-muted-foreground">Status:</span>
|
|
<span className="line-through text-destructive">{entry.oldValue}</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="font-medium text-success">{entry.newValue}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
{entry.oldValue && entry.action !== 'status_change' && (
|
|
<div className="text-xs text-muted-foreground mt-2 p-2 bg-muted/50 rounded">
|
|
{entry.oldValue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(Timeline);
|