mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
d6eca611fc
Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes.
210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
/**
|
|
* Component: Confirm Dialog
|
|
* Documentation: documentation/frontend/components.md
|
|
*
|
|
* Reusable confirmation dialog for destructive actions.
|
|
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React, { useEffect, useRef } from 'react';
|
|
|
|
export interface ConfirmDialogProps {
|
|
isOpen: boolean;
|
|
title: string;
|
|
message: string | React.ReactNode;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
confirmVariant?: 'danger' | 'primary';
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
isOpen,
|
|
title,
|
|
message,
|
|
confirmLabel = 'Confirm',
|
|
cancelLabel = 'Cancel',
|
|
confirmVariant = 'danger',
|
|
onConfirm,
|
|
onCancel,
|
|
}: ConfirmDialogProps) {
|
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Focus the cancel button on open (safer default for destructive dialogs)
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// Small delay to let animation start before stealing focus
|
|
const t = setTimeout(() => cancelRef.current?.focus(), 50);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Escape to close + focus trap
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
// Focus trap: tab cycles only within dialog
|
|
if (e.key === 'Tab') {
|
|
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
if (!focusable || focusable.length === 0) return;
|
|
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onCancel]);
|
|
|
|
// Prevent body scroll while open
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
return () => { document.body.style.overflow = ''; };
|
|
}
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const isDestructive = confirmVariant === 'danger';
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="confirm-dialog-title"
|
|
aria-describedby="confirm-dialog-desc"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
|
onClick={onCancel}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
ref={dialogRef}
|
|
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
|
>
|
|
{/* Header */}
|
|
<div className="px-6 pt-6 pb-4">
|
|
<div className="flex items-start gap-4">
|
|
{/* Icon well */}
|
|
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
|
isDestructive
|
|
? 'bg-red-50 dark:bg-red-500/10'
|
|
: 'bg-blue-50 dark:bg-blue-500/10'
|
|
}`}>
|
|
{isDestructive ? (
|
|
<svg
|
|
className="w-5 h-5 text-red-500 dark:text-red-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth="1.75"
|
|
stroke="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth="1.75"
|
|
stroke="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
|
|
{/* Text */}
|
|
<div className="flex-1 min-w-0 pt-0.5">
|
|
<h3
|
|
id="confirm-dialog-title"
|
|
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
|
>
|
|
{title}
|
|
</h3>
|
|
<div id="confirm-dialog-desc" className="mt-1.5">
|
|
{typeof message === 'string' ? (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
|
{message}
|
|
</p>
|
|
) : (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
|
{message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action bar */}
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
|
<button
|
|
ref={cancelRef}
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
|
>
|
|
{cancelLabel}
|
|
</button>
|
|
<button
|
|
ref={confirmRef}
|
|
type="button"
|
|
onClick={onConfirm}
|
|
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
|
isDestructive
|
|
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
|
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
|
}`}
|
|
>
|
|
{confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|