feat(ui3): elegant earthy task feed redesign
This commit is contained in:
parent
9c703072d1
commit
395f90ed2a
9 changed files with 379 additions and 184 deletions
88
src/components/shared/module-card.tsx
Normal file
88
src/components/shared/module-card.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { ReactNode, MouseEventHandler } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SocialCardStatus } from '@/lib/social-cards';
|
||||
|
||||
interface ModuleCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
status?: SocialCardStatus;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<SocialCardStatus, string> = {
|
||||
ready: 'bg-emerald-500',
|
||||
in_progress: 'bg-amber-500',
|
||||
blocked: 'bg-rose-500',
|
||||
closed: 'bg-slate-500',
|
||||
};
|
||||
|
||||
const STATUS_BORDER_COLORS: Record<SocialCardStatus, string> = {
|
||||
ready: 'border-emerald-500/30',
|
||||
in_progress: 'border-amber-500/30',
|
||||
blocked: 'border-rose-500/30',
|
||||
closed: 'border-slate-500/30',
|
||||
};
|
||||
|
||||
export function ModuleCard({
|
||||
children,
|
||||
className,
|
||||
selected = false,
|
||||
status = 'ready',
|
||||
onClick
|
||||
}: ModuleCardProps) {
|
||||
// "Industrial Sci-Fi" Aesthetic
|
||||
// 1. Top status line (LED bar)
|
||||
// 2. Chamfered-feel (using borders/shadows)
|
||||
// 3. Technical containment
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.click();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
// Base Geometry
|
||||
'relative group flex flex-col',
|
||||
'bg-[#1e1e1e] overflow-hidden', // Darker, matte background
|
||||
// Borders: Tech-styled
|
||||
'border border-white/5',
|
||||
STATUS_BORDER_COLORS[status],
|
||||
|
||||
// Selection State: "Active Signal"
|
||||
selected ? 'ring-1 ring-amber-400 shadow-[0_0_20px_rgba(251,191,36,0.15)]' : 'hover:border-white/20',
|
||||
|
||||
// Layout
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// Custom clip-path for chamfered corners?
|
||||
// Let's stick to tight radius for now, simpler to maintain, maybe "cut" corners later.
|
||||
borderRadius: '4px', // Tighter radius for industrial feel
|
||||
}}
|
||||
>
|
||||
{/* Top Status Indicator Bar (The "LED Strip") */}
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 w-full h-[3px] transition-colors",
|
||||
STATUS_COLORS[status],
|
||||
selected ? 'opacity-100 shadow-[0_0_8px_currentColor]' : 'opacity-70'
|
||||
)} />
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="p-4 pt-5 flex flex-col gap-3 h-full">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Decorative "Rivets" or Tech-marks */}
|
||||
<div className="absolute top-2 right-2 w-1 h-1 rounded-full bg-white/10" />
|
||||
<div className="absolute bottom-2 right-2 w-1.5 h-1.5 border border-white/10 rounded-[1px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,25 +13,24 @@ interface SocialCardProps {
|
|||
onJumpToKanban?: (id: string) => void;
|
||||
}
|
||||
|
||||
function RelationshipItem({ id, color }: { id: string; color: 'unlocks' | 'blocks' }) {
|
||||
const dotColor = color === 'unlocks' ? 'bg-rose-400' : 'bg-amber-400';
|
||||
const borderColor = color === 'unlocks' ? 'border-rose-500/20' : 'border-amber-500/20';
|
||||
const hoverBorder = color === 'unlocks' ? 'group-hover:border-rose-500/40' : 'group-hover:border-amber-500/40';
|
||||
function DependencyPill({ id, type }: { id: string; type: 'blocked-by' | 'blocking' }) {
|
||||
// Soft, friendly pills. Rose for "blocked by", Amber for "blocking".
|
||||
const styles = type === 'blocked-by'
|
||||
? 'bg-rose-500/10 text-rose-200 hover:bg-rose-500/20'
|
||||
: 'bg-amber-500/10 text-amber-200 hover:bg-amber-500/20';
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"group flex items-center gap-2 rounded border bg-white/5 px-2.5 py-2 transition-colors",
|
||||
borderColor,
|
||||
hoverBorder,
|
||||
"hover:bg-white/10"
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-medium transition-colors cursor-default",
|
||||
styles
|
||||
)}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full shrink-0", dotColor)} />
|
||||
<span className="font-mono text-[10px] text-text-muted">{id}</span>
|
||||
</div>
|
||||
{type === 'blocked-by' ? 'Waiting on ' : 'Blocks '}
|
||||
<span className="font-mono ml-1 opacity-80">{id}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewJumpIcon({
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
|
|
@ -44,8 +43,12 @@ function ViewJumpIcon({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
className="p-2 text-text-muted hover:text-white hover:bg-white/10 rounded-full transition-all duration-200"
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
|
@ -54,53 +57,40 @@ function ViewJumpIcon({
|
|||
|
||||
function GraphIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="4" cy="4" r="2" />
|
||||
<circle cx="12" cy="4" r="2" />
|
||||
<circle cx="8" cy="12" r="2" />
|
||||
<line x1="5.5" y1="5.5" x2="7" y2="10" />
|
||||
<line x1="10.5" y1="5.5" x2="9" y2="10" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="18" cy="5" r="3"></circle>
|
||||
<circle cx="6" cy="12" r="3"></circle>
|
||||
<circle cx="18" cy="19" r="3"></circle>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="2" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="6" y="2" width="4" height="8" rx="1" />
|
||||
<rect x="10" y="2" width="4" height="6" rx="1" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandIcon() {
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles = {
|
||||
ready: 'bg-teal-500/10 text-teal-300 border-teal-500/20',
|
||||
in_progress: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20',
|
||||
blocked: 'bg-amber-500/10 text-amber-300 border-amber-500/20',
|
||||
closed: 'bg-slate-500/10 text-slate-400 border-slate-500/20',
|
||||
}[status as keyof typeof styles] || 'bg-slate-500/10 text-slate-400 border-slate-500/20';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="6" cy="6" r="4" />
|
||||
<line x1="9" y1="9" x2="12.5" y2="12.5" />
|
||||
</svg>
|
||||
<span className={cn(
|
||||
"px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border",
|
||||
styles
|
||||
)}>
|
||||
{status.replace('_', ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,106 +102,84 @@ export function SocialCard({
|
|||
onJumpToGraph,
|
||||
onJumpToKanban,
|
||||
}: SocialCardProps) {
|
||||
// NEW semantic: blocks = what I block (amber), unblocks = what blocks me (rose)
|
||||
const hasBlocks = data.blocks.length > 0;
|
||||
const hasUnblocks = data.unblocks.length > 0;
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
className={cn('min-w-[220px] max-w-[320px]', className)}
|
||||
// "Post" Styling: hover lift, soft shadow handled by BaseCard update
|
||||
className={cn('flex flex-col gap-4 p-5 min-h-[180px]', className)}
|
||||
selected={selected}
|
||||
status={data.status}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-teal-400 font-mono text-sm font-medium">
|
||||
{data.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand"
|
||||
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
|
||||
>
|
||||
<ExpandIcon />
|
||||
</button>
|
||||
{/* Header: ID & Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs font-medium text-teal-400/80">
|
||||
{data.id}
|
||||
</span>
|
||||
<StatusBadge status={data.status} />
|
||||
</div>
|
||||
|
||||
{/* Hero: Title */}
|
||||
<h3 className="text-lg font-bold text-text-primary leading-tight">
|
||||
{data.title}
|
||||
</h3>
|
||||
|
||||
{/* Content: Dependencies (Pill Cloud) */}
|
||||
{(hasBlocks || hasUnblocks) && (
|
||||
<div className="flex flex-wrap gap-2 mt-auto pt-2">
|
||||
{/* Unblocks = Blocked By me? No.
|
||||
data.unblocks = tasks blocking THIS task (upstream) -> "Waiting on"
|
||||
data.blocks = tasks THIS task blocks (downstream) -> "Blocks"
|
||||
*/}
|
||||
{data.unblocks.slice(0, 3).map((id) => (
|
||||
<DependencyPill key={id} id={id} type="blocked-by" />
|
||||
))}
|
||||
{data.blocks.slice(0, 3).map((id) => (
|
||||
<DependencyPill key={id} id={id} type="blocking" />
|
||||
))}
|
||||
{(data.unblocks.length + data.blocks.length > 6) && (
|
||||
<span className="px-2 py-1 text-[10px] text-text-muted/60 italic">
|
||||
+{data.unblocks.length + data.blocks.length - 6} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-text-strong font-semibold text-sm leading-tight line-clamp-2">
|
||||
{data.title}
|
||||
</h3>
|
||||
|
||||
{(hasBlocks || hasUnblocks) && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{/* BLOCKED BY: tasks blocking THIS task (rose) */}
|
||||
{hasUnblocks && (
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80 pl-0.5">Blocked By</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.unblocks.slice(0, 3).map((id) => (
|
||||
<RelationshipItem key={id} id={id} color="unlocks" />
|
||||
))}
|
||||
{data.unblocks.length > 3 && (
|
||||
<div className="text-[10px] text-rose-400/60 px-2 py-1 italic">
|
||||
+{data.unblocks.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BLOCKING: tasks THIS task blocks (amber) */}
|
||||
{hasBlocks && (
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80 pl-0.5">Blocking</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.blocks.slice(0, 3).map((id) => (
|
||||
<RelationshipItem key={id} id={id} color="blocks" />
|
||||
))}
|
||||
{data.blocks.length > 3 && (
|
||||
<div className="text-[10px] text-amber-400/60 px-2 py-1 italic">
|
||||
+{data.blocks.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-white/5">
|
||||
<div className="flex items-center gap-1">
|
||||
{data.agents.slice(0, 3).map((agent) => (
|
||||
{/* Footer: Agents & Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/5 mt-2">
|
||||
{/* Crew */}
|
||||
<div className="flex items-center -space-x-2 pl-1">
|
||||
{data.agents.map((agent) => (
|
||||
<div key={agent.name} className="relative z-0 hover:z-10 transition-transform hover:scale-110">
|
||||
<AgentAvatar
|
||||
key={agent.name}
|
||||
name={agent.name}
|
||||
status={agent.status as AgentStatus}
|
||||
role={agent.role}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
{data.agents.length > 3 && (
|
||||
<span className="text-text-muted text-xs ml-1">
|
||||
+{data.agents.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.agents.length === 0 && (
|
||||
<span className="text-xs text-text-muted/40 italic">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ViewJumpIcon
|
||||
icon={<GraphIcon />}
|
||||
label="View in Graph"
|
||||
onClick={() => onJumpToGraph?.(data.id)}
|
||||
/>
|
||||
<ViewJumpIcon
|
||||
icon={<KanbanIcon />}
|
||||
label="View in Kanban"
|
||||
onClick={() => onJumpToKanban?.(data.id)}
|
||||
/>
|
||||
</div>
|
||||
{/* Actions (Share/View) */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ActionButton
|
||||
icon={<GraphIcon />}
|
||||
label="View Graph"
|
||||
onClick={() => onJumpToGraph?.(data.id)}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<KanbanIcon />}
|
||||
label="View Kanban"
|
||||
onClick={() => onJumpToKanban?.(data.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,10 @@ export function SocialPage({ issues, selectedId, onSelect }: SocialPageProps) {
|
|||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top: Scrollable Grid Container (approx 4x2 visible) */}
|
||||
<div className="flex-none h-[60vh] min-h-[400px] overflow-y-auto p-6 border-b border-white/5 custom-scrollbar bg-black/10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-[1600px] mx-auto">
|
||||
<div className="flex flex-col h-full bg-earthy-gradient">
|
||||
{/* Feed Container */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 md:p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
|
||||
{cards.map((card) => (
|
||||
<SocialCard
|
||||
key={card.id}
|
||||
|
|
@ -28,20 +28,20 @@ export function SocialPage({ issues, selectedId, onSelect }: SocialPageProps) {
|
|||
/>
|
||||
))}
|
||||
{cards.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-text-muted">
|
||||
No tasks found.
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20 text-text-muted opacity-60">
|
||||
<div className="text-4xl mb-4">📭</div>
|
||||
<p>No active tasks found in stream.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Detail Area Placeholder */}
|
||||
<div className="flex-1 bg-surface-muted/30 p-6 flex items-center justify-center text-text-muted/50">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">Select a task to view details</p>
|
||||
<p className="text-xs mt-1 opacity-70">(Chat & Activity stream coming soon)</p>
|
||||
</div>
|
||||
{/* Bottom Console (Conversation Deck) - Placeholder for future chat integration */}
|
||||
<div className="flex-none h-16 border-t border-white/5 bg-black/20 backdrop-blur-md flex items-center justify-center">
|
||||
<p className="text-xs font-medium text-text-muted/60 tracking-wide uppercase">
|
||||
Select a task to view conversation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue