feat(frontend): React UI — pages, forms, audit log section, Tailwind, Vite build
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Button from './ui/Button';
|
||||
import FormField, { Input } from './ui/FormField';
|
||||
import Alert from './ui/Alert';
|
||||
import { animalsApi } from '../api/client';
|
||||
|
||||
export default function AnimalForm({ existing, experimentId, onSuccess, onCancel }) {
|
||||
const [fields, setFields] = useState({
|
||||
animal_id_string: existing?.animal_id_string ?? '',
|
||||
animal_name: existing?.animal_name ?? '',
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFields({
|
||||
animal_id_string: existing?.animal_id_string ?? '',
|
||||
animal_name: existing?.animal_name ?? '',
|
||||
});
|
||||
setErrors({});
|
||||
setApiError(null);
|
||||
}, [existing]);
|
||||
|
||||
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!fields.animal_id_string.trim()) e.animal_id_string = 'Animal ID is required';
|
||||
if (!fields.animal_name.trim()) e.animal_name = 'Animal name is required';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const payload = {
|
||||
animal_id_string: fields.animal_id_string.trim(),
|
||||
animal_name: fields.animal_name.trim(),
|
||||
};
|
||||
const result = existing
|
||||
? await animalsApi.update(existing.id, payload)
|
||||
: await animalsApi.create({ ...payload, experiment_id: experimentId });
|
||||
onSuccess(result);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
{apiError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError.message}
|
||||
details={apiError.details}
|
||||
onDismiss={() => setApiError(null)}
|
||||
/>
|
||||
)}
|
||||
<FormField label="Animal ID" name="animal_id_string" error={errors.animal_id_string} required>
|
||||
<Input
|
||||
name="animal_id_string"
|
||||
value={fields.animal_id_string}
|
||||
onChange={set('animal_id_string')}
|
||||
placeholder="e.g. M-001"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Animal Name" name="animal_name" error={errors.animal_name} required>
|
||||
<Input
|
||||
name="animal_name"
|
||||
value={fields.animal_name}
|
||||
onChange={set('animal_name')}
|
||||
placeholder="e.g. Whiskers"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{existing ? 'Save Changes' : 'Add Animal'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { auditLogsApi } from '../api/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function DiffView({ changes }) {
|
||||
const { before, after } = changes || {};
|
||||
if (!before && after) {
|
||||
return <span className="text-green-700 text-xs">Created: {JSON.stringify(after, null, 2)}</span>;
|
||||
}
|
||||
if (before && !after) {
|
||||
return <span className="text-red-700 text-xs">Deleted record</span>;
|
||||
}
|
||||
if (before && after) {
|
||||
const keys = Object.keys({ ...before, ...after }).filter(
|
||||
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k])
|
||||
);
|
||||
if (keys.length === 0) return <span className="text-gray-400 text-xs">No field changes detected</span>;
|
||||
return (
|
||||
<dl className="text-xs space-y-1">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="grid grid-cols-3 gap-2">
|
||||
<dt className="font-medium text-gray-600">{k}</dt>
|
||||
<dd className="text-red-600 line-through col-span-1 truncate">{String(before[k] ?? '')}</dd>
|
||||
<dd className="text-green-700 col-span-1 truncate">{String(after[k] ?? '')}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ACTION_BADGE = {
|
||||
CREATE: 'bg-green-100 text-green-800',
|
||||
UPDATE: 'bg-yellow-100 text-yellow-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export default function AuditLogSection({ tableName, recordId }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
auditLogsApi
|
||||
.list({ tableName, recordId, limit: 50 })
|
||||
.then(({ logs, total }) => {
|
||||
setLogs(logs);
|
||||
setTotal(total);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [tableName, recordId]);
|
||||
|
||||
return (
|
||||
<section aria-label="Modification History" className="mt-8">
|
||||
<h3 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Modification History
|
||||
{total > 0 && (
|
||||
<span className="text-xs font-normal text-gray-500">({total} entries)</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{loading && <p className="text-sm text-gray-500">Loading history…</p>}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{!loading && !error && logs.length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic">No modification history yet.</p>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="bg-white">
|
||||
<button
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
||||
aria-expanded={expanded === log.id}
|
||||
>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${ACTION_BADGE[log.action] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 truncate flex-1">
|
||||
{log.table_name} / {log.record_id}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${expanded === log.id ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded === log.id && (
|
||||
<div className="px-4 pb-3 bg-gray-50 border-t border-gray-100">
|
||||
<DiffView changes={log.changes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import Button from './ui/Button';
|
||||
import FormField, { Input, Textarea } from './ui/FormField';
|
||||
import Alert from './ui/Alert';
|
||||
import { dailyStatusesApi } from '../api/client';
|
||||
|
||||
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const [fields, setFields] = useState({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFields({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
setErrors({});
|
||||
setApiError(null);
|
||||
}, [existing]);
|
||||
|
||||
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!fields.date) {
|
||||
e.date = 'Date is required';
|
||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
|
||||
e.date = 'Date must be in YYYY-MM-DD format';
|
||||
} else {
|
||||
const d = new Date(fields.date);
|
||||
if (isNaN(d.getTime())) e.date = 'Invalid date';
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const payload = {
|
||||
date: fields.date,
|
||||
experiment_description: fields.experiment_description || null,
|
||||
vitals: fields.vitals || null,
|
||||
treatment: fields.treatment || null,
|
||||
notes: fields.notes || null,
|
||||
};
|
||||
const result = existing
|
||||
? await dailyStatusesApi.update(existing.id, payload)
|
||||
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||
onSuccess(result);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
{apiError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError.message}
|
||||
details={apiError.details}
|
||||
onDismiss={() => setApiError(null)}
|
||||
/>
|
||||
)}
|
||||
<FormField label="Date" name="date" error={errors.date} required>
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
value={fields.date}
|
||||
onChange={set('date')}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Experiment Description" name="experiment_description">
|
||||
<Textarea
|
||||
name="experiment_description"
|
||||
value={fields.experiment_description}
|
||||
onChange={set('experiment_description')}
|
||||
placeholder="Describe the experimental conditions…"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Vitals" name="vitals">
|
||||
<Input
|
||||
name="vitals"
|
||||
value={fields.vitals}
|
||||
onChange={set('vitals')}
|
||||
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Treatment" name="treatment">
|
||||
<Input
|
||||
name="treatment"
|
||||
value={fields.treatment}
|
||||
onChange={set('treatment')}
|
||||
placeholder="e.g. Drug X — 10mg/kg oral"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Notes" name="notes">
|
||||
<Textarea
|
||||
name="notes"
|
||||
value={fields.notes}
|
||||
onChange={set('notes')}
|
||||
placeholder="Any additional observations…"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{existing ? 'Save Changes' : 'Log Status'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Button from './ui/Button';
|
||||
import FormField, { Input } from './ui/FormField';
|
||||
import Alert from './ui/Alert';
|
||||
import { experimentsApi } from '../api/client';
|
||||
|
||||
export default function ExperimentForm({ existing, onSuccess, onCancel }) {
|
||||
const [title, setTitle] = useState(existing?.title ?? '');
|
||||
const [errors, setErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(existing?.title ?? '');
|
||||
setErrors({});
|
||||
setApiError(null);
|
||||
}, [existing]);
|
||||
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!title.trim()) e.title = 'Title is required';
|
||||
else if (title.trim().length > 255) e.title = 'Title must be 255 characters or fewer';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const result = existing
|
||||
? await experimentsApi.update(existing.id, { title: title.trim() })
|
||||
: await experimentsApi.create({ title: title.trim() });
|
||||
onSuccess(result);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
{apiError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError.message}
|
||||
details={apiError.details}
|
||||
onDismiss={() => setApiError(null)}
|
||||
/>
|
||||
)}
|
||||
<FormField label="Experiment Title" name="title" error={errors.title} required>
|
||||
<Input
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Drug Efficacy Study – Q3"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{existing ? 'Save Changes' : 'Create Experiment'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
const styles = {
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
};
|
||||
|
||||
export default function Alert({ type = 'error', message, details, onDismiss }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div role="alert" className={`rounded-md border px-4 py-3 text-sm ${styles[type]}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{message}</p>
|
||||
{details && (
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5 text-xs opacity-80">
|
||||
{details.map((d, i) => (
|
||||
<li key={i}>{d.field ? `${d.field}: ${d.message}` : d.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button onClick={onDismiss} className="shrink-0 opacity-60 hover:opacity-100" aria-label="Dismiss">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
|
||||
secondary: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 text-gray-600 border-transparent',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onClick,
|
||||
type = 'button',
|
||||
className = '',
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-md border font-medium
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]} ${sizes[size]} ${className}
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import Modal from './Modal';
|
||||
import Button from './Button';
|
||||
|
||||
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function FormField({ label, name, error, required, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={name} className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ id, name, type = 'text', value, onChange, placeholder, required, disabled, className = '', ...rest }) {
|
||||
return (
|
||||
<input
|
||||
id={id || name}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({ id, name, value, onChange, placeholder, rows = 3, disabled, className = '' }) {
|
||||
return (
|
||||
<textarea
|
||||
id={id || name}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed resize-y
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div className={`bg-white rounded-xl shadow-2xl w-full mx-4 ${widths[size]} max-h-[90vh] flex flex-col`}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 rounded p-1 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user