Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="/src/index.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>app</title>
Expand Down
189 changes: 189 additions & 0 deletions app/src/components/CommentBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Clock, Pencil, Trash2 } from 'lucide-react';

interface CommentBoxProps {
comment: {
id: number;
comment_text: string;
email: string;
role: string;
created_at: string;
};
isMyComment?: boolean;
isEditing?: boolean;
isBusy?: boolean;
editExpired?: boolean;
editingText?: string;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSaveEdit?: () => void;
onDeleteComment?: () => void;
onEditingTextChange?: (text: string) => void;
}

function formatDateTime(iso: string) {
return new Date(iso).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

function getRoleConfig(role: string) {
const norm = (role || '').toLowerCase();
if (norm.includes('faculty')) {
return {
label: 'Faculty',
badgeClass: 'bg-violet-50/80 text-violet-700 border-violet-100',
};
}
if (norm.includes('warden')) {
return {
label: 'Warden',
badgeClass: 'bg-amber-50/80 text-amber-700 border-amber-100',
};
}
if (norm.includes('centrehead') || norm.includes('centre_head')) {
return {
label: 'Centre Head',
badgeClass: 'bg-sky-50/80 text-sky-700 border-sky-100',
};
}
if (norm.includes('xen')) {
return {
label: 'XEN Admin',
badgeClass: 'bg-emerald-50/80 text-emerald-700 border-emerald-100',
};
}
if (norm.includes('ae')) {
return {
label: 'Assistant Engineer',
badgeClass: 'bg-indigo-50/80 text-indigo-700 border-indigo-100',
};
}
if (norm.includes('je')) {
return {
label: 'Junior Engineer',
badgeClass: 'bg-rose-50/80 text-rose-700 border-rose-100',
};
}
return {
label: role ? role.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Staff',
badgeClass: 'bg-zinc-50 text-zinc-700 border-zinc-200',
};
}

export function CommentBox({
comment,
isMyComment = false,
isEditing = false,
isBusy = false,
editExpired = true,
editingText = '',
onStartEdit,
onCancelEdit,
onSaveEdit,
onDeleteComment,
onEditingTextChange,
}: CommentBoxProps) {
const config = getRoleConfig(comment.role);

return (
<div className="relative pl-10 group/item">
{/* Precision-aligned Connector Dot */}
<div className="absolute left-[10px] top-[26px] w-3 h-3 rounded-full border-[2.5px] border-white bg-zinc-300 group-hover/item:bg-zinc-800 transition-colors duration-300 z-10 shadow-sm" />

{/* Unified Professional Comment Box */}
<div className="bg-white border border-zinc-200/70 rounded-xl p-5 hover:border-zinc-300 hover:shadow-md/5 hover:shadow-sm transition-all duration-300">

{/* Info & Content */}
<div className="space-y-2">

{/* Header metadata row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-zinc-800 tracking-tight">
{config.label}
</span>
<span className={`inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded border uppercase tracking-wider ${config.badgeClass}`}>
{comment.role || 'Staff'}
</span>
{comment.email && (
<span className="text-[10px] text-zinc-400 font-medium">
({comment.email})
</span>
)}
</div>

{/* Time label & Action Buttons */}
<div className="inline-flex items-center gap-3 shrink-0">
<div className="inline-flex items-center gap-1.5 text-xs text-zinc-500 font-normal">
<Clock className="w-3.5 h-3.5 text-zinc-400" />
{formatDateTime(comment.created_at)}
</div>

{/* Action Buttons (Edit/Delete) */}
{isMyComment && !isEditing && !editExpired && (
<span className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity duration-200">
<button
onClick={onStartEdit}
disabled={isBusy}
className="p-1 rounded text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition cursor-pointer"
title="Edit comment"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={onDeleteComment}
disabled={isBusy}
className="p-1 rounded text-zinc-400 hover:text-rose-600 hover:bg-rose-50 transition cursor-pointer"
title="Delete comment"
>
<Trash2 className="w-3 h-3" />
</button>
</span>
)}
</div>
</div>

{/* Message Content */}
<div className="text-[12.5px] text-zinc-600 leading-relaxed whitespace-pre-wrap font-normal select-text">
{isEditing ? (
<div className="mt-1">
<textarea
value={editingText}
onChange={(e) => onEditingTextChange?.(e.target.value)}
disabled={isBusy}
rows={2}
className="w-full text-[12.5px] text-zinc-800 bg-white border border-zinc-200 rounded-lg px-3 py-2.5 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 focus:border-zinc-400 transition resize-none"
/>
<div className="mt-2.5 flex justify-end gap-2">
<button
onClick={onCancelEdit}
disabled={isBusy}
className="border border-zinc-200 text-zinc-500 hover:bg-zinc-50 font-semibold text-xs px-3 py-1.5 rounded-lg transition cursor-pointer"
>
Cancel
</button>
<button
onClick={onSaveEdit}
disabled={isBusy || !editingText.trim()}
className="bg-zinc-900 text-white hover:bg-zinc-800 font-semibold text-xs px-4.5 py-1.5 rounded-lg transition disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1.5"
>
{isBusy && <span className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />}
Save
</button>
</div>
</div>
) : (
comment.comment_text
)}
</div>

</div>

</div>
</div>
);
}
115 changes: 66 additions & 49 deletions app/src/components/ComplaintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Zap, Hammer, Calendar, MapPin, BedDouble, MessageSquare, ChevronRight,
} from 'lucide-react';


// ── Types ─────────────────────────────────────────────────────────────────────

export type Role = 'faculty' | 'warden' | 'centrehead';
Expand Down Expand Up @@ -49,8 +48,6 @@ interface ComplaintCardProps {
onCancelEdit?: () => void;
onSaveEdit?: (postId: number) => void;
onDelete?: (postId: number) => void;
// Called after the author successfully posts a comment, so the parent can
// refetch and surface the new comment.
onCommentPosted?: () => void;
}

Expand All @@ -59,22 +56,21 @@ interface ComplaintCardProps {
interface StatusStyle { label: string; pill: string; dot: string }

const STATUS_CONFIG: Record<string, StatusStyle> = {
pending_xen: { label: 'Pending · XEN', pill: 'bg-amber-100 text-amber-800 border-amber-300', dot: 'bg-amber-400' },
pending_ae: { label: 'Pending · AE', pill: 'bg-sky-100 text-sky-800 border-sky-300', dot: 'bg-sky-400' },
resolved_ae: { label: 'Resolved · AE', pill: 'bg-teal-100 text-teal-800 border-teal-300', dot: 'bg-teal-400' },
pending_je: { label: 'Pending · JE', pill: 'bg-violet-100 text-violet-800 border-violet-300', dot: 'bg-violet-400' },
resolved_je: { label: 'Resolved · JE', pill: 'bg-teal-100 text-teal-800 border-teal-300', dot: 'bg-teal-400' },
resolved_all: { label: 'Resolved · All', pill: 'bg-emerald-100 text-emerald-800 border-emerald-300', dot: 'bg-emerald-500' },
pending_xen: { label: 'Pending · XEN', pill: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500' },
pending_ae: { label: 'Pending · AE', pill: 'bg-sky-50 text-sky-700 border-sky-200', dot: 'bg-sky-500' },
resolved_ae: { label: 'Resolved · AE', pill: 'bg-teal-50 text-teal-700 border-teal-200', dot: 'bg-teal-500' },
pending_je: { label: 'Pending · JE', pill: 'bg-violet-50 text-violet-700 border-violet-200', dot: 'bg-violet-500' },
resolved_je: { label: 'Resolved · JE', pill: 'bg-teal-50 text-teal-700 border-teal-200', dot: 'bg-teal-500' },
resolved_all: { label: 'Resolved · All', pill: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
};

const FALLBACK: StatusStyle = { label: 'Unknown', pill: 'bg-gray-100 text-gray-600 border-gray-300', dot: 'bg-gray-400' };
const FALLBACK: StatusStyle = { label: 'Unknown', pill: 'bg-gray-50 text-gray-600 border-gray-200', dot: 'bg-gray-400' };

function statusStyle(s: string): StatusStyle {
const norm = s.toLowerCase();
return STATUS_CONFIG[norm] ?? { ...FALLBACK, label: s.replace(/_/g, ' ') };
}


function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
Expand All @@ -83,11 +79,20 @@ function formatDate(iso: string) {

function typeTheme(isElectrical: boolean) {
return isElectrical
? { cardBg: 'bg-amber-50', accentBar: 'bg-amber-400', headerBg: 'bg-amber-100/70', iconColor: 'text-amber-600', badge: 'bg-amber-200 text-amber-900 border-amber-400', stageDone: 'bg-amber-500 border-amber-500' }
: { cardBg: 'bg-sky-50', accentBar: 'bg-sky-500', headerBg: 'bg-sky-100/70', iconColor: 'text-sky-600', badge: 'bg-sky-200 text-sky-900 border-sky-400', stageDone: 'bg-sky-600 border-sky-600' };
? {
cardBg: 'bg-white hover:bg-amber-50/10',
accentBar: 'bg-gradient-to-b from-amber-400 to-amber-500',
iconColor: 'text-amber-500',
badge: 'bg-amber-50/80 text-amber-700 border-amber-200/60',
}
: {
cardBg: 'bg-white hover:bg-sky-50/10',
accentBar: 'bg-gradient-to-b from-sky-400 to-sky-500',
iconColor: 'text-sky-500',
badge: 'bg-sky-50/80 text-sky-700 border-sky-200/60',
};
}


// ── Card (compact preview) ─────────────────────────────────────────────────────

export function ComplaintCard({
Expand All @@ -105,58 +110,70 @@ export function ComplaintCard({
return (
<div
onClick={() => navigate(`/post/${role}/${post.id}`)}
className={`${theme.cardBg} border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-gray-300 transition-all cursor-pointer group`}
className={`${theme.cardBg} relative border border-gray-200/80 rounded-xl shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 cursor-pointer group flex min-h-[140px]`}
>
{/* Top accent */}
<div className={`h-1 w-full ${theme.accentBar}`} />

<div className="px-4 py-4">
{/* Row 1: badges */}
<div className="flex items-center gap-2 mb-3">
<span className="text-[11px] font-mono font-bold text-gray-400">#{post.id}</span>

<span className={`inline-flex items-center gap-1 text-[11px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full border ${theme.badge}`}>
{isElectrical ? <Zap className="w-3 h-3" /> : <Hammer className="w-3 h-3" />}
{post.type_of_post}
</span>

<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold px-2 py-0.5 rounded-full border ${status.pill}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.label}
</span>
</div>
{/* Left accent bar (Linear / Vercel style) */}
<div className={`w-1 shrink-0 ${theme.accentBar}`} />

{/* Main card content */}
<div className="flex-1 p-5 flex flex-col justify-between">

{/* Top line: Complaint ID, Category Badge, Status Badge */}
<div>
<div className="flex items-center justify-between gap-2 mb-2.5">
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-bold text-gray-400">#{post.id}</span>

<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-md border ${theme.badge}`}>
{isElectrical ? <Zap className="w-2.5 h-2.5" /> : <Hammer className="w-2.5 h-2.5" />}
{post.type_of_post}
</span>
</div>

<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold px-2.5 py-0.5 rounded-md border ${status.pill}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.label}
</span>
</div>

{/* Title + description */}
<h4 className="text-sm font-bold text-gray-900 leading-snug mb-1.5">{post.title}</h4>
<p className="text-xs text-gray-500 leading-relaxed line-clamp-2">{post.description}</p>
{/* Title & Description */}
<h4 className="text-sm font-semibold text-gray-800 leading-snug group-hover:text-black transition-colors mb-1">
{post.title}
</h4>
<p className="text-xs text-gray-500 leading-relaxed line-clamp-2">
{post.description}
</p>
</div>

{/* Footer row */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-200/60">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<Calendar className="w-3 h-3" /> {formatDate(post.created_at)}
{/* Footer row: Date, Info fields, Comments and hover arrow */}
<div className="flex items-center justify-between mt-4 pt-3.5 border-t border-zinc-100">
<div className="flex items-center gap-4 text-zinc-500">
<span className="inline-flex items-center gap-1.5 text-xs font-normal">
<Calendar className="w-3.5 h-3.5 text-zinc-400" /> {formatDate(post.created_at)}
</span>

{isFaculty && post.place && (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<MapPin className="w-3 h-3" /> {post.place}
<span className="inline-flex items-center gap-1.5 text-xs font-normal">
<MapPin className="w-3.5 h-3.5 text-zinc-400" /> {post.place}
</span>
)}

{isWarden && post.room_number && (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<BedDouble className="w-3 h-3" /> {post.room_number}
<span className="inline-flex items-center gap-1.5 text-xs font-normal">
<BedDouble className="w-3.5 h-3.5 text-zinc-400" /> Room {post.room_number}
</span>
)}
</div>

<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
{comments.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<MessageSquare className="w-3 h-3" /> {comments.length}
<span className="inline-flex items-center gap-1 text-xs text-gray-400 font-medium">
<MessageSquare className="w-3.5 h-3.5 text-gray-400" /> {comments.length}
</span>
)}
<ChevronRight className={`w-4 h-4 text-gray-300 group-hover:text-gray-500 group-hover:translate-x-0.5 transition-all ${theme.iconColor} opacity-0 group-hover:opacity-100`} />
</div>
</div>

</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import "tailwindcss";

@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--color-primary: #003366; /* Deep Blue typical of NITH */
--color-primary-light: #00509e;
--color-secondary: #f0f4f8; /* Light gray for backgrounds */
Expand Down
Loading
Loading