Skip to content

Commit 55d43ae

Browse files
committed
feat: toast on modal close
1 parent 6b0a6ca commit 55d43ae

23 files changed

Lines changed: 266 additions & 30 deletions
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Check, X } from "lucide-react"
2+
import {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useMemo,
7+
useState,
8+
type ReactNode,
9+
} from "react"
10+
import { createPortal } from "react-dom"
11+
import { cn } from "../../lib/utils"
12+
import { Button } from "./button"
13+
14+
type ToastTone = "success" | "info"
15+
16+
type ToastNotice = {
17+
id: number
18+
message: string
19+
tone: ToastTone
20+
}
21+
22+
type ToastContextValue = {
23+
showToast: (message: string, options?: { tone?: ToastTone }) => void
24+
}
25+
26+
const ToastContext = createContext<ToastContextValue | null>(null)
27+
const TOAST_DISMISS_MS = 3_500
28+
29+
export function ToastProvider({ children }: { children: ReactNode }) {
30+
const [toasts, setToasts] = useState<ToastNotice[]>([])
31+
32+
const dismissToast = useCallback((id: number) => {
33+
setToasts((current) => current.filter((toast) => toast.id !== id))
34+
}, [])
35+
36+
const showToast = useCallback(
37+
(message: string, options: { tone?: ToastTone } = {}) => {
38+
const id = Date.now() + Math.floor(Math.random() * 1_000)
39+
const notice = { id, message, tone: options.tone ?? "success" }
40+
41+
setToasts((current) => [...current, notice])
42+
window.setTimeout(() => dismissToast(id), TOAST_DISMISS_MS)
43+
},
44+
[dismissToast],
45+
)
46+
47+
const value = useMemo(() => ({ showToast }), [showToast])
48+
49+
return (
50+
<ToastContext.Provider value={value}>
51+
{children}
52+
{typeof document !== "undefined"
53+
? createPortal(
54+
<div className="toast toast-top toast-end z-[90] w-full max-w-sm p-4 sm:w-auto">
55+
{toasts.map((toast) => (
56+
<Toast
57+
key={toast.id}
58+
message={toast.message}
59+
tone={toast.tone}
60+
onDismiss={() => dismissToast(toast.id)}
61+
/>
62+
))}
63+
</div>,
64+
document.body,
65+
)
66+
: null}
67+
</ToastContext.Provider>
68+
)
69+
}
70+
71+
export function useToast() {
72+
const context = useContext(ToastContext)
73+
if (!context) throw new Error("useToast must be used inside ToastProvider")
74+
return context
75+
}
76+
77+
export function Toast({
78+
message,
79+
onDismiss,
80+
tone = "success",
81+
}: {
82+
message: string
83+
onDismiss?: () => void
84+
tone?: ToastTone
85+
}) {
86+
return (
87+
<div
88+
className={cn(
89+
"alert flex items-start justify-between gap-3 border shadow-lg",
90+
tone === "success"
91+
? "alert-success border-success/40 text-success-content"
92+
: "alert-info border-info/40 text-info-content",
93+
)}
94+
role="status"
95+
aria-live="polite"
96+
>
97+
<div className="flex items-start gap-2">
98+
<Check className="mt-0.5 h-4 w-4 shrink-0" />
99+
<span>{message}</span>
100+
</div>
101+
{onDismiss ? (
102+
<Button
103+
type="button"
104+
variant="ghost"
105+
size="icon"
106+
className="btn-xs -mr-2 -mt-1 text-current"
107+
aria-label="Dismiss notification"
108+
onClick={onDismiss}
109+
>
110+
<X className="h-3.5 w-3.5" />
111+
</Button>
112+
) : null}
113+
</div>
114+
)
115+
}

‎assets/react/src/lib/utils.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export function compactNumber(value: number | null | undefined) {
1515
return new Intl.NumberFormat(undefined, { notation: "compact" }).format(value || 0)
1616
}
1717

18+
export function pluralize(count: number, singular: string, plural = `${singular}s`) {
19+
return `${count} ${count === 1 ? singular : plural}`
20+
}
21+
1822
export function present<T>(value: T | null | undefined): value is T {
1923
return value != null
2024
}

‎assets/react/src/main.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"
44
import "./pwa"
55
import { initializeNativeBackButton } from "./lib/native-back"
66
import { ThemeProvider } from "./lib/theme"
7+
import { ToastProvider } from "./components/ui/toast"
78
import { routeTree } from "./routeTree.gen"
89
import { initializeNativeSharedImport, type NativeOpenPayload } from "./lib/native-shared-import"
910
import { nativeAppPath, parseNativeRoute, type NativeRoute } from "./lib/native-open"
@@ -86,7 +87,9 @@ void initializeNativeBackButton({
8687
createRoot(document.getElementById("manavault-root")!).render(
8788
<QueryClientProvider client={queryClient}>
8889
<ThemeProvider>
89-
<RouterProvider router={router} />
90+
<ToastProvider>
91+
<RouterProvider router={router} />
92+
</ToastProvider>
9093
</ThemeProvider>
9194
</QueryClientProvider>,
9295
)

‎assets/react/src/pages/cards/add-card-to-deck-dialog.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111
DialogTitle,
1212
} from "../../components/ui/dialog"
1313
import { Input } from "../../components/ui/input"
14+
import { useToast } from "../../components/ui/toast"
1415
import { request } from "../../lib/graphql"
15-
import { present, titleize } from "../../lib/utils"
16+
import { pluralize, present, titleize } from "../../lib/utils"
1617
import { AddCardToDeckDocument, CardDeckOptionsDocument } from "./data"
1718

1819
export type CardDeckTarget = {
@@ -39,6 +40,7 @@ export function AddCatalogCardToDeckDialog({
3940
}) {
4041
const queryClient = useQueryClient()
4142
const navigate = useNavigate()
43+
const { showToast } = useToast()
4244
const [deckId, setDeckId] = useState("")
4345
const [quantity, setQuantity] = useState(1)
4446
const [zone, setZone] = useState<CardDeckZone>("mainboard")
@@ -82,7 +84,8 @@ export function AddCatalogCardToDeckDialog({
8284
queryKey: ["deck-buylist", addedDeckId],
8385
})
8486
}
85-
close()
87+
showToast(`${pluralize(quantity, "card")} added to deck`)
88+
onOpenChange(false)
8689
if (addedDeckId) navigate({ to: "/decks/$id", params: { id: addedDeckId } })
8790
},
8891
onError: (error) =>

‎assets/react/src/pages/collection/add-item-dialog.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
DialogTitle,
1313
} from "../../components/ui/dialog"
1414
import { Input } from "../../components/ui/input"
15+
import { useToast } from "../../components/ui/toast"
1516
import { request } from "../../lib/graphql"
16-
import { present, titleize } from "../../lib/utils"
17+
import { pluralize, present, titleize } from "../../lib/utils"
1718
import { COLLECTION_CONDITIONS, COLLECTION_FINISHES, MODAL_SEARCH_DEBOUNCE_MS } from "./constants"
1819
import {
1920
CollectionItemFormOptionsDocument,
@@ -46,6 +47,7 @@ export function AddCollectionItemDialog({
4647
open: boolean
4748
}) {
4849
const queryClient = useQueryClient()
50+
const { showToast } = useToast()
4951
const [search, setSearch] = useState("")
5052
const [selectedPrinting, setSelectedPrinting] =
5153
useState<AddCollectionItemPrintingSelection | null>(null)
@@ -119,6 +121,7 @@ export function AddCollectionItemDialog({
119121
queryClient.invalidateQueries({ queryKey: ["collection"] })
120122
queryClient.invalidateQueries({ queryKey: ["collection-items"] })
121123
queryClient.invalidateQueries({ queryKey: ["home"] })
124+
showToast(`${pluralize(quantity, "card")} added to collection`)
122125
close(true)
123126
},
124127
onError: (error) =>

‎assets/react/src/pages/collection/add-location-dialog.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DialogTitle,
1313
} from "../../components/ui/dialog"
1414
import { Input } from "../../components/ui/input"
15+
import { useToast } from "../../components/ui/toast"
1516
import { request } from "../../lib/graphql"
1617
import { present, titleize } from "../../lib/utils"
1718
import { LOCATION_KINDS, MODAL_SEARCH_DEBOUNCE_MS } from "./constants"
@@ -27,6 +28,7 @@ export function AddLocationDialog({
2728
open: boolean
2829
}) {
2930
const queryClient = useQueryClient()
31+
const { showToast } = useToast()
3032
const [name, setName] = useState("")
3133
const [kind, setKind] = useState<(typeof LOCATION_KINDS)[number]>("box")
3234
const [description, setDescription] = useState("")
@@ -74,6 +76,7 @@ export function AddLocationDialog({
7476
queryKey: ["collection-item-form-options"],
7577
})
7678
queryClient.invalidateQueries({ queryKey: ["home"] })
79+
showToast(`Created location ${name.trim()}`)
7780
reset()
7881
onOpenChange(false)
7982
},

‎assets/react/src/pages/collection/add-to-deck-dialog.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
DialogHeader,
1010
DialogTitle,
1111
} from "../../components/ui/dialog"
12+
import { useToast } from "../../components/ui/toast"
1213
import { request } from "../../lib/graphql"
13-
import { present, titleize } from "../../lib/utils"
14+
import { pluralize, present, titleize } from "../../lib/utils"
1415
import {
1516
BulkAddCollectionItemsToDeckDocument,
1617
CollectionItemDeckOptionsDocument,
@@ -31,6 +32,7 @@ export function AddCollectionItemToDeckDialog({
3132
onOpenChange: (open: boolean) => void
3233
}) {
3334
const queryClient = useQueryClient()
35+
const { showToast } = useToast()
3436
const [deckId, setDeckId] = useState("")
3537
const [zone, setZone] = useState("mainboard")
3638
const [error, setError] = useState<string | null>(null)
@@ -59,8 +61,9 @@ export function AddCollectionItemToDeckDialog({
5961
},
6062
onSuccess: () => {
6163
queryClient.invalidateQueries({ queryKey: ["decks"] })
64+
showToast(`${pluralize(targetCount, "card")} added to deck`)
6265
onDone()
63-
close()
66+
onOpenChange(false)
6467
},
6568
onError: (error) =>
6669
setError(error instanceof Error ? error.message : "Could not add cards to deck"),

‎assets/react/src/pages/collection/bulk-edit-item-dialog.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
DialogTitle,
1111
} from "../../components/ui/dialog"
1212
import { Input } from "../../components/ui/input"
13+
import { useToast } from "../../components/ui/toast"
1314
import type { CollectionItemUpdateInput } from "../../gql/graphql"
1415
import { request } from "../../lib/graphql"
16+
import { pluralize } from "../../lib/utils"
1517
import { COLLECTION_FINISHES } from "./constants"
1618
import { BulkUpdateCollectionItemsDocument } from "./documents"
1719
import { buildBulkCollectionItemUpdateInput } from "./bulk-edit-input"
@@ -33,6 +35,7 @@ export function BulkEditCollectionItemsDialog({
3335
onDone: () => void
3436
onOpenChange: (open: boolean) => void
3537
}) {
38+
const { showToast } = useToast()
3639
const targetItems = useMemo(() => collectionTargetItems(item), [item])
3740
const targetCount = targetItems.length
3841
const commonFinish = useMemo(() => commonCollectionFinish(targetItems), [targetItems])
@@ -53,8 +56,9 @@ export function BulkEditCollectionItemsDialog({
5356
})
5457
},
5558
onSuccess: () => {
59+
showToast(`${pluralize(targetCount, "card")} edited`)
5660
onDone()
57-
close()
61+
onOpenChange(false)
5862
},
5963
onError: (error) =>
6064
setError(error instanceof Error ? error.message : "Could not update collection items"),

‎assets/react/src/pages/collection/collection-page.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { EmptyState } from "../../components/card-image"
88
import { CardNameSearchField } from "../../components/card-name-search-field"
99
import { Button } from "../../components/ui/button"
1010
import { ConfirmDialog } from "../../components/ui/confirm-dialog"
11+
import { useToast } from "../../components/ui/toast"
1112
import {
1213
buildCollectionFilterQuery,
1314
combineCollectionQueries,
@@ -22,7 +23,7 @@ import {
2223
type SharedImportPayload,
2324
} from "../../lib/native-shared-import"
2425
import { useLocalStorageState } from "../../lib/use-local-storage"
25-
import { present } from "../../lib/utils"
26+
import { pluralize, present } from "../../lib/utils"
2627
import { AutoSortSetupDialog, hasEnabledAutoSortRules } from "./auto-sort-setup-dialog"
2728
import { AutoSortSummaryDialog } from "./auto-sort-summary-dialog"
2829
import { CollectionLocationsSection } from "./collection-locations-section"
@@ -129,6 +130,7 @@ export function CollectionPage({ importFile = false }: { importFile?: boolean })
129130
},
130131
)
131132
const queryClient = useQueryClient()
133+
const { showToast } = useToast()
132134
const navigate = useNavigate()
133135
const deleteLocation = useMutation({
134136
mutationFn: (locationId: string) => request(DeleteLocationDocument, { id: locationId }),
@@ -184,8 +186,11 @@ export function CollectionPage({ importFile = false }: { importFile?: boolean })
184186
invalidateCollectionViews(queryClient)
185187
queryClient.invalidateQueries({ queryKey: ["location"] })
186188
selection.clearSelection()
189+
showToast(`${pluralize(result?.movedCount ?? 0, "card")} auto-sorted`)
190+
setAutoSortResult(null)
191+
} else {
192+
setAutoSortResult(result ?? null)
187193
}
188-
setAutoSortResult(result ?? null)
189194
setAutoSortError(null)
190195
},
191196
onError: (error) => {
@@ -324,7 +329,10 @@ export function CollectionPage({ importFile = false }: { importFile?: boolean })
324329

325330
function deleteSelectedLocation() {
326331
if (!deletingLocation) return
327-
deleteLocation.mutate(deletingLocation.id)
332+
const locationName = deletingLocation.name
333+
deleteLocation.mutate(deletingLocation.id, {
334+
onSuccess: () => showToast(`Deleted location ${locationName}`),
335+
})
328336
if (editingLocation?.id === deletingLocation.id) setEditingLocation(null)
329337
if (exportingLocation?.location.id === deletingLocation.id) setExportingLocation(null)
330338
}

‎assets/react/src/pages/collection/delete-item-dialog.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
DialogHeader,
1010
DialogTitle,
1111
} from "../../components/ui/dialog"
12+
import { useToast } from "../../components/ui/toast"
1213
import { request } from "../../lib/graphql"
14+
import { pluralize } from "../../lib/utils"
1315
import { DeleteCollectionItemDocument } from "./documents"
1416
import {
1517
collectionTargetItems,
@@ -26,6 +28,7 @@ export function DeleteCollectionItemDialog({
2628
onDone: () => void
2729
onOpenChange: (open: boolean) => void
2830
}) {
31+
const { showToast } = useToast()
2932
const [error, setError] = useState<string | null>(null)
3033
const targetItems = collectionTargetItems(item)
3134
const targetCount = targetItems.length
@@ -40,8 +43,9 @@ export function DeleteCollectionItemDialog({
4043
)
4144
},
4245
onSuccess: () => {
46+
showToast(`${pluralize(targetCount, "card")} deleted`)
4347
onDone()
44-
close()
48+
onOpenChange(false)
4549
},
4650
onError: (error) =>
4751
setError(error instanceof Error ? error.message : "Could not delete collection items"),

0 commit comments

Comments
 (0)