feat(ux): accessible confirm dialog replaces window.confirm
- new components/ui/confirm.tsx: ConfirmProvider + useConfirm() built on the existing Radix alert-dialog (focus trap, ESC, aria for free) - wired once into the app layout - all 13 native window.confirm() delete/remove guards now use the styled, accessible confirm with a destructive action button
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Pencil, Contact as ContactIcon } from 'lucide-react'
|
||||
import { createContact, updateContact, deleteContact } from '@/lib/actions'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import type { Tables } from '@/types/database'
|
||||
import { PageTitle } from '@/components/ui/page-title'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
|
||||
@@ -24,6 +25,7 @@ const GROUPS: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
export function ContactsClient({ contacts: initialContacts }: { contacts: Contact[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [contacts, setContacts] = useState<Contact[]>(initialContacts)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -84,7 +86,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this contact?')) return
|
||||
if (!(await confirm({ title: 'Delete contact', description: 'This contact will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteContact(id)
|
||||
if (!result.error) setContacts(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from 'sonner'
|
||||
import { UserCog } from 'lucide-react'
|
||||
import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
|
||||
interface AreaLead {
|
||||
@@ -26,6 +27,7 @@ interface Props {
|
||||
|
||||
export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) {
|
||||
const router = useRouter()
|
||||
const confirm = useConfirm()
|
||||
const [rows, setRows] = useState<AreaLead[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [d, setD] = useState({ area_code: '', name: '' })
|
||||
@@ -116,7 +118,7 @@ export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props)
|
||||
</select>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete area-lead role "${r.area_name}"?`)) return
|
||||
if (!(await confirm({ title: 'Delete area-lead role', description: `"${r.area_name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return
|
||||
const res = await deleteAreaLeadRole(r.id)
|
||||
if (res.error) { toast.error(res.error); return }
|
||||
setRows(prev => prev.filter(x => x.id !== r.id))
|
||||
|
||||
@@ -5,10 +5,12 @@ import { toast } from 'sonner'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { createArea, updateArea, deleteArea } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import type { Area } from './types'
|
||||
|
||||
export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; areas: Area[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [areas, setAreas] = useState<Area[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [d, setD] = useState({ code: '', name: '', icon: '' })
|
||||
@@ -57,7 +59,7 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={async () => { const res = await updateArea({ areaId: a.id, is_archived: !a.is_archived }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, is_archived: !x.is_archived } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{a.is_archived ? 'Restore' : 'Archive'}</button>
|
||||
<button onClick={async () => { if (!confirm(`Delete area "${a.name}"?`)) return; const res = await deleteArea(a.id); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.filter(x => x.id !== a.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
<button onClick={async () => { if (!(await confirm({ title: 'Delete area', description: `"${a.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteArea(a.id); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.filter(x => x.id !== a.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
|
||||
import { Ticket } from 'lucide-react'
|
||||
import { createInvite, updateInvite, deleteInvite } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import type { Invite } from './types'
|
||||
|
||||
@@ -14,6 +15,7 @@ function inviteLink(code: string) {
|
||||
}
|
||||
|
||||
export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean; invites: Invite[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [invites, setInvites] = useState<Invite[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [d, setD] = useState({ code: '', label: '', expires_at: '', max_uses: '' })
|
||||
@@ -75,7 +77,7 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean;
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button onClick={async () => { const res = await updateInvite(inv.id, { is_enabled: !inv.is_enabled }); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.map(x => x.id === inv.id ? { ...x, is_enabled: !x.is_enabled } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{inv.is_enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button onClick={async () => { if (!confirm(`Delete invite "${inv.code}"?`)) return; const res = await deleteInvite(inv.id); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.filter(x => x.id !== inv.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
<button onClick={async () => { if (!(await confirm({ title: 'Delete invite', description: `"${inv.code}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteInvite(inv.id); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.filter(x => x.id !== inv.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { toast } from 'sonner'
|
||||
import { ListChecks } from 'lucide-react'
|
||||
import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import type { Step } from './types'
|
||||
|
||||
export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [steps, setSteps] = useState<Step[]>(initial)
|
||||
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Required</span>
|
||||
</label>
|
||||
{!s.is_system && (
|
||||
<button onClick={async () => { if (!confirm(`Delete step "${s.title}"?`)) return; const res = await deleteOnboardingStep(s.id); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.filter(x => x.id !== s.id)); toast.success('Deleted') }} className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
<button onClick={async () => { if (!(await confirm({ title: 'Delete step', description: `"${s.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteOnboardingStep(s.id); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.filter(x => x.id !== s.id)); toast.success('Deleted') }} className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,13 @@ import { upsertRoleLabel, createCustomRole, updateCustomRole, deleteCustomRole }
|
||||
import { BUILTIN_ROLES, DEFAULT_ROLE_LABELS } from '@/lib/role-labels'
|
||||
import { Tags } from 'lucide-react'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import type { RoleLabelRow, CustomRole } from './types'
|
||||
|
||||
export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAdmin: boolean; roleLabels: RoleLabelRow[]; customRoles: CustomRole[] }) {
|
||||
const router = useRouter()
|
||||
const confirm = useConfirm()
|
||||
const [customRoles, setCustomRoles] = useState<CustomRole[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [draft, setDraft] = useState({ slug: '', name: '', color: '#d4ff00', description: '' })
|
||||
@@ -142,7 +144,7 @@ export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAd
|
||||
/>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={async () => { if (!confirm(`Delete role "${r.name}"?`)) return; const res = await deleteCustomRole(r.id); if (res.error) { toast.error(res.error); return } setCustomRoles(prev => prev.filter(x => x.id !== r.id)); toast.success('Deleted') }}
|
||||
onClick={async () => { if (!(await confirm({ title: 'Delete role', description: `"${r.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteCustomRole(r.id); if (res.error) { toast.error(res.error); return } setCustomRoles(prev => prev.filter(x => x.id !== r.id)); toast.success('Deleted') }}
|
||||
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -5,10 +5,12 @@ import { toast } from 'sonner'
|
||||
import { CreditCard } from 'lucide-react'
|
||||
import { createTier, updateTier, deleteTier } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import type { Tier } from './types'
|
||||
|
||||
export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tiers: Tier[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [tiers, setTiers] = useState<Tier[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [d, setD] = useState({ slug: '', name: '', dollars: '0', cadence: 'monthly' as Tier['billing_cadence'], description: '' })
|
||||
@@ -75,7 +77,7 @@ export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tier
|
||||
{t.is_system ? (
|
||||
<button onClick={async () => { const res = await updateTier(t.id, { is_archived: !t.is_archived }); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.map(x => x.id === t.id ? { ...x, is_archived: !x.is_archived } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{t.is_archived ? 'Restore' : 'Archive'}</button>
|
||||
) : (
|
||||
<button onClick={async () => { if (!confirm(`Delete tier "${t.name}"?`)) return; const res = await deleteTier(t.id); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.filter(x => x.id !== t.id)); toast.success('Tier deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
<button onClick={async () => { if (!(await confirm({ title: 'Delete tier', description: `"${t.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteTier(t.id); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.filter(x => x.id !== t.id)); toast.success('Tier deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Pin, Lock, Trash2 } from 'lucide-react'
|
||||
import { updateForumThread, deleteForumThread } from '@/lib/actions'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
|
||||
interface Props {
|
||||
threadId: string
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
|
||||
export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor }: Props) {
|
||||
const router = useRouter()
|
||||
const confirm = useConfirm()
|
||||
|
||||
async function togglePinned() {
|
||||
const result = await updateForumThread(threadId, { pinned: !pinned })
|
||||
@@ -29,7 +31,7 @@ export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Delete this thread? All comments are deleted too.')) return
|
||||
if (!(await confirm({ title: 'Delete thread', description: 'All comments are deleted too. This cannot be undone.', confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteForumThread(threadId)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
toast.success('Thread deleted')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { ConfirmProvider } from '@/components/ui/confirm'
|
||||
import { getRoleLabelMap } from '@/lib/role-labels'
|
||||
|
||||
// This layout is the auth/onboarding gate for the whole app. It must never be
|
||||
@@ -56,6 +57,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
const roleName = roleMap[member.role]?.name ?? member.role
|
||||
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
<a
|
||||
href="#main-content"
|
||||
@@ -83,5 +85,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ConfirmProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { Tables } from '@/types/database'
|
||||
import { PageTitle } from '@/components/ui/page-title'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
|
||||
type Member = Tables<'space_members'>
|
||||
type AreaLeadRole = { id: string; area_name: string; lead_id: string | null }
|
||||
@@ -29,6 +30,7 @@ const isAdmin = (role: string) => role === 'admin' || role === 'board'
|
||||
|
||||
export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [] }: Props) {
|
||||
const router = useRouter()
|
||||
const confirm = useConfirm()
|
||||
const [members, setMembers] = useState<Member[]>(initialMembers)
|
||||
|
||||
async function makeAreaLead(memberId: string, areaLeadRoleId: string) {
|
||||
@@ -105,7 +107,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
}
|
||||
|
||||
async function handleRemove(memberId: string) {
|
||||
if (!confirm('Remove this member from the space?')) return
|
||||
if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return
|
||||
const result = await removeMember(memberId)
|
||||
if (!result.error) {
|
||||
setMembers(prev => prev.filter(m => m.id !== memberId))
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createClient } from '@/lib/supabase/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { Tables, TablesInsert } from '@/types/database'
|
||||
import { PageTitle } from '@/components/ui/page-title'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
|
||||
|
||||
type KbEntry = Tables<'knowledge_base'>
|
||||
@@ -355,6 +356,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
||||
aclRoleOptions?: { value: string; label: string }[]
|
||||
aclInitial?: string[]
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -371,7 +373,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete secret "${secret.title}"? This cannot be undone.`)) return
|
||||
if (!(await confirm({ title: 'Delete secret', description: `"${secret.title}" cannot be undone.`, confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteSecret(secret.id)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
toast.success('Secret deleted')
|
||||
@@ -443,13 +445,14 @@ function KbEntryRow({
|
||||
aclRoleOptions?: { value: string; label: string }[]
|
||||
aclByEntity?: Record<string, string[]>
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
const [viewing, setViewing] = useState(false)
|
||||
const aclEntityType: 'kb' | 'process' = entry.tags?.includes('process') ? 'process' : 'kb'
|
||||
const aclInitial = aclByEntity?.[`${aclEntityType}:${entry.id}`] ?? []
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (!confirm(`Delete "${entry.title}"?`)) return
|
||||
if (!(await confirm({ title: 'Delete entry', description: `"${entry.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteKbEntry(entry.id)
|
||||
if ('error' in result && result.error) { toast.error(result.error); return }
|
||||
toast.success('Entry deleted')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import type { Tables } from '@/types/database'
|
||||
import { PageTitle } from '@/components/ui/page-title'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent } from '@/components/ui/empty'
|
||||
@@ -23,6 +24,7 @@ export function ProjectsClient({
|
||||
spaceId,
|
||||
areas,
|
||||
}: { projects: Project[]; spaceId: string; areas?: string[] }) {
|
||||
const confirm = useConfirm()
|
||||
const AREAS = areas && areas.length > 0 ? areas : DEFAULT_AREAS
|
||||
const [projects, setProjects] = useState<Project[]>(initialProjects)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -59,7 +61,7 @@ export function ProjectsClient({
|
||||
}
|
||||
|
||||
async function handleDelete(projectId: string) {
|
||||
if (!confirm('Delete this project?')) return
|
||||
if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteProject(projectId)
|
||||
if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import { addComment, deleteComment } from '@/lib/actions'
|
||||
import { Trash2, MessageCircle } from 'lucide-react'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
|
||||
export type CommentEntityType = 'forum_thread' | 'proposal' | 'incident' | 'policy'
|
||||
|
||||
@@ -31,6 +32,7 @@ interface Props {
|
||||
|
||||
export function CommentThread({ entityType, entityId, comments, currentMemberId, canModerate, locked }: Props) {
|
||||
const router = useRouter()
|
||||
const confirm = useConfirm()
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -46,7 +48,7 @@ export function CommentThread({ entityType, entityId, comments, currentMemberId,
|
||||
}
|
||||
|
||||
async function handleDelete(commentId: string) {
|
||||
if (!confirm('Delete this comment?')) return
|
||||
if (!(await confirm({ title: 'Delete comment', description: 'This comment will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteComment(commentId)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
toast.success('Comment deleted')
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
// An accessible replacement for window.confirm(). Wrap the app once in
|
||||
// <ConfirmProvider> and call `const confirm = useConfirm()` then
|
||||
// `if (!(await confirm({ title, ... }))) return` at the call site, so the
|
||||
// imperative shape stays close to the native confirm() it replaces.
|
||||
|
||||
type ConfirmOptions = {
|
||||
title: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
const ConfirmContext = React.createContext<ConfirmFn | null>(null)
|
||||
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ctx = React.useContext(ConfirmContext)
|
||||
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [opts, setOpts] = React.useState<ConfirmOptions | null>(null)
|
||||
const resolverRef = React.useRef<((v: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = React.useCallback<ConfirmFn>((next) => {
|
||||
setOpts(next)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
|
||||
const settle = React.useCallback((value: boolean) => {
|
||||
resolverRef.current?.(value)
|
||||
resolverRef.current = null
|
||||
setOpts(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<AlertDialog
|
||||
open={opts !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) settle(false)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{opts?.title}</AlertDialogTitle>
|
||||
{opts?.description ? (
|
||||
<AlertDialogDescription>{opts.description}</AlertDialogDescription>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => settle(false)}>
|
||||
{opts?.cancelText ?? 'Cancel'}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => settle(true)}
|
||||
className={
|
||||
opts?.destructive
|
||||
? buttonVariants({ variant: 'destructive' })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{opts?.confirmText ?? 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user