betterstart-cli 0.0.99 → 0.0.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/adapters/next/templates/init/api/upload-route.ts +2 -1
  2. package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +2 -2
  3. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sign-out.tsx +27 -8
  4. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +3 -1
  5. package/dist/assets/adapters/next/templates/init/components/shared/delete-dialog.tsx +3 -2
  6. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-version-restore-dialog.tsx +4 -3
  7. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-button.tsx +5 -2
  8. package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +11 -5
  9. package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-dialog.tsx +6 -4
  10. package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-drawer.tsx +12 -8
  11. package/dist/assets/adapters/next/templates/init/components/shared/media/media-empty-state.tsx +6 -5
  12. package/dist/assets/adapters/next/templates/init/components/shared/media/media-grid-pagination.tsx +1 -1
  13. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +5 -3
  14. package/dist/assets/adapters/next/templates/init/components/shared/search-input.tsx +2 -0
  15. package/dist/assets/adapters/next/templates/init/components/shared/sort-order-dialog.tsx +12 -7
  16. package/dist/assets/adapters/next/templates/init/hooks/use-entity-versions.ts +2 -1
  17. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +2 -1
  18. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +2 -1
  19. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +2 -1
  20. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +2 -1
  21. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +2 -1
  22. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +2 -1
  23. package/dist/assets/adapters/next/templates/init/lib/actions/profile/change-own-password.ts +2 -1
  24. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +2 -1
  25. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +2 -2
  26. package/dist/assets/adapters/next/templates/init/lib/actions/users/change-user-password.ts +2 -1
  27. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +2 -1
  28. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +2 -1
  29. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +2 -1
  30. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +2 -1
  31. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +2 -1
  32. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +2 -1
  33. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +2 -1
  34. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +2 -1
  35. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +2 -1
  36. package/dist/assets/adapters/next/templates/init/pages/forgot-password-form.tsx +7 -7
  37. package/dist/assets/adapters/next/templates/init/pages/login-form.tsx +7 -7
  38. package/dist/assets/adapters/next/templates/init/pages/login-page.tsx +1 -1
  39. package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +5 -4
  40. package/dist/assets/adapters/next/templates/init/pages/profile/profile-form.tsx +13 -10
  41. package/dist/assets/adapters/next/templates/init/pages/reset-password-form.tsx +5 -5
  42. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +3 -1
  43. package/dist/assets/adapters/next/templates/init/pages/settings/forms/form-notifications-drawer.tsx +10 -5
  44. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +19 -10
  45. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +9 -4
  46. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +26 -20
  47. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +4 -4
  48. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +3 -1
  49. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +3 -1
  50. package/dist/assets/adapters/next/templates/init/pages/users/change-password-dialog.tsx +6 -5
  51. package/dist/assets/adapters/next/templates/init/pages/users/create-user-dialog.tsx +5 -5
  52. package/dist/assets/adapters/next/templates/init/pages/users/delete-user-dialog.tsx +6 -4
  53. package/dist/assets/adapters/next/templates/init/pages/users/edit-role-dialog.tsx +9 -5
  54. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +1 -1
  55. package/dist/assets/adapters/next/templates/init/utils/error/action-error.ts +35 -0
  56. package/dist/assets/shared-assets/react-admin/schema.json +8 -0
  57. package/dist/assets/shared-assets/react-admin/ui/sonner.tsx +3 -8
  58. package/dist/assets/shared-assets/react-admin/ui/spinner.tsx +1 -1
  59. package/dist/{chunk-SO2CAJFC.js → chunk-45Z6XDG5.js} +418 -79
  60. package/dist/chunk-45Z6XDG5.js.map +1 -0
  61. package/dist/cli.js +2027 -2391
  62. package/dist/cli.js.map +1 -1
  63. package/dist/{template-reader-XMWPFYIK.js → template-reader-J6G7N5V6.js} +2 -2
  64. package/package.json +1 -1
  65. package/dist/chunk-SO2CAJFC.js.map +0 -1
  66. /package/dist/{template-reader-XMWPFYIK.js.map → template-reader-J6G7N5V6.js.map} +0 -0
@@ -8,6 +8,7 @@ import db from '@admin/db'
8
8
  import { adminMedia } from '@admin/db/schema'
9
9
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
10
10
  import { getTouchedFieldNames } from '@admin/utils/audit/audit'
11
+ import { toActionError } from '@admin/utils/error/action-error'
11
12
  import { mapMediaRow, selectMedia } from '@admin/utils/media/query'
12
13
  import { validateFiles } from '@admin/utils/validation/validation'
13
14
  import { eq } from 'drizzle-orm'
@@ -151,7 +152,7 @@ export async function POST(request: NextRequest) {
151
152
 
152
153
  return NextResponse.json({ success: true, files: uploadedFiles })
153
154
  } catch (error) {
154
- const message = error instanceof Error ? error.message : 'Failed to upload files'
155
+ const message = toActionError(error, 'Failed to upload files')
155
156
  return NextResponse.json({ success: false, error: message }, { status: 500 })
156
157
  }
157
158
  }
@@ -23,12 +23,12 @@ const settingsNav = [
23
23
  exact: true
24
24
  },
25
25
  {
26
- label: 'Webhook endpoints',
26
+ label: 'Webhooks',
27
27
  href: '/admin/settings/webhooks',
28
28
  exact: true
29
29
  },
30
30
  {
31
- label: 'Webhook logs',
31
+ label: 'Deliveries',
32
32
  href: '/admin/settings/webhooks/logs',
33
33
  exact: true
34
34
  }
@@ -1,8 +1,10 @@
1
1
  'use client'
2
2
 
3
+ import * as React from 'react'
3
4
  import { signOut } from '@admin/auth/client'
4
5
  import { Button } from '@admin/components/ui/button'
5
6
  import { DropdownMenuItem } from '@admin/components/ui/dropdown-menu'
7
+ import { Spinner } from '@admin/components/ui/spinner'
6
8
  import { cn } from '@admin/utils/shared/cn'
7
9
  import { LogOut } from 'lucide-react'
8
10
  import { useRouter } from 'next/navigation'
@@ -15,10 +17,13 @@ export function AdminSignOut({
15
17
  mode?: 'menu-item' | 'button'
16
18
  }) {
17
19
  const router = useRouter()
20
+ const [isPending, startTransition] = React.useTransition()
18
21
 
19
- async function handleSignOut() {
20
- await signOut()
21
- router.push('/admin/login')
22
+ function handleSignOut() {
23
+ startTransition(async () => {
24
+ await signOut()
25
+ router.push('/admin/login')
26
+ })
22
27
  }
23
28
 
24
29
  if (mode === 'button') {
@@ -27,18 +32,32 @@ export function AdminSignOut({
27
32
  type="button"
28
33
  variant="ghost"
29
34
  onClick={handleSignOut}
35
+ disabled={isPending}
36
+ aria-busy={isPending}
30
37
  className={cn('justify-between', className)}
31
38
  >
32
- <span>Log Out</span>
33
- <LogOut strokeWidth={1.2} className="size-4" />
39
+ <span>{isPending ? 'Signing out…' : 'Sign out'}</span>
40
+ {isPending ? (
41
+ <Spinner className="size-4" />
42
+ ) : (
43
+ <LogOut strokeWidth={1.2} className="size-4" />
44
+ )}
34
45
  </Button>
35
46
  )
36
47
  }
37
48
 
38
49
  return (
39
- <DropdownMenuItem onClick={handleSignOut} className={className}>
40
- <span>Log Out</span>
41
- <LogOut strokeWidth={1.2} className="size-4" />
50
+ <DropdownMenuItem
51
+ onSelect={(event) => {
52
+ event.preventDefault()
53
+ handleSignOut()
54
+ }}
55
+ disabled={isPending}
56
+ aria-busy={isPending}
57
+ className={className}
58
+ >
59
+ <span>{isPending ? 'Signing out…' : 'Sign out'}</span>
60
+ {isPending ? <Spinner className="size-4" /> : <LogOut strokeWidth={1.2} className="size-4" />}
42
61
  </DropdownMenuItem>
43
62
  )
44
63
  }
@@ -186,7 +186,9 @@ export function DataTable<TData extends RowData, TValue>({
186
186
  className="flex h-24 items-center justify-center text-center"
187
187
  style={{ gridColumn: '1 / -1' }}
188
188
  >
189
- <div className="text-destructive">Error: {error.message}</div>
189
+ <div className="text-destructive">
190
+ Failed to load these results. Refresh to try again.
191
+ </div>
190
192
  </DataGridCell>
191
193
  </DataGridRow>
192
194
  ) : table.getRowModel().rows?.length ? (
@@ -46,7 +46,7 @@ export function DeleteDialog({
46
46
  Delete {count} {label}?
47
47
  </AlertDialogTitle>
48
48
  <AlertDialogDescription>
49
- This will permanently delete {targetLabel}. This action cannot be undone.
49
+ This permanently deletes {targetLabel} and cannot be undone.
50
50
  </AlertDialogDescription>
51
51
  </AlertDialogHeader>
52
52
  <AlertDialogFooter>
@@ -57,8 +57,9 @@ export function DeleteDialog({
57
57
  onConfirm()
58
58
  }}
59
59
  disabled={isPending}
60
+ aria-busy={isPending}
60
61
  >
61
- {isPending ? 'Deleting...' : 'Delete'}
62
+ {isPending ? 'Deleting' : `Delete ${count} ${label}`}
62
63
  </AlertDialogAction>
63
64
  </AlertDialogFooter>
64
65
  </AlertDialogContent>
@@ -57,8 +57,8 @@ export function EntityVersionRestoreDialog({
57
57
  <AlertDialogHeader>
58
58
  <AlertDialogTitle>Restore v{version.version}?</AlertDialogTitle>
59
59
  <AlertDialogDescription>
60
- This will replace the current entity content with v{version.version}. The current
61
- content will be saved as a new version before restoring.
60
+ This replaces the current content with v{version.version}. The current content is saved
61
+ as a new version first.
62
62
  </AlertDialogDescription>
63
63
  </AlertDialogHeader>
64
64
  <AlertDialogFooter>
@@ -70,8 +70,9 @@ export function EntityVersionRestoreDialog({
70
70
  handleRestore()
71
71
  }}
72
72
  disabled={isPending}
73
+ aria-busy={isPending}
73
74
  >
74
- {isPending ? 'Restoring...' : 'Restore'}
75
+ {isPending ? 'Restoring' : `Restore v${version.version}`}
75
76
  </AlertDialogAction>
76
77
  </AlertDialogFooter>
77
78
  </AlertDialogContent>
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import type { EntityVersionWithAuthor } from '@admin/actions/entity-versions'
5
5
  import { EntityVersionsDrawer } from '@admin/components/shared/entity-versions/entity-versions-drawer'
6
6
  import { Button } from '@admin/components/ui/button'
7
+ import { Spinner } from '@admin/components/ui/spinner'
7
8
  import { useEntityVersions } from '@admin/hooks/use-entity-versions'
8
9
  import { History } from 'lucide-react'
9
10
 
@@ -53,10 +54,12 @@ export function EntityVersionsButton({
53
54
  variant="ghost"
54
55
  size="sm"
55
56
  onClick={() => handleOpenChange(true)}
57
+ disabled={isPending}
58
+ aria-busy={isPending}
56
59
  className="ml-auto shrink-0"
57
60
  >
58
- <History className="size-4" />
59
- Versions
61
+ {isPending ? <Spinner className="size-4" /> : <History className="size-4" />}
62
+ {isPending ? 'Loading Versions…' : 'Versions'}
60
63
  </Button>
61
64
  <EntityVersionsDrawer
62
65
  open={open}
@@ -10,9 +10,10 @@ import { Card, CardContent, CardFooter } from '@admin/components/ui/card'
10
10
  import { DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from '@admin/components/ui/drawer'
11
11
  import { Form, FormControl, FormField, FormItem, FormLabel } from '@admin/components/ui/form'
12
12
  import { Input } from '@admin/components/ui/input'
13
+ import { Spinner } from '@admin/components/ui/spinner'
13
14
  import { useCopyToClipboard } from '@admin/hooks/use-copy-to-clipboard'
14
15
  import { format } from 'date-fns'
15
- import { Check, ClockPlus, Copy, Link2, LoaderCircle, UserRoundCheck } from 'lucide-react'
16
+ import { Check, ClockPlus, Copy, Link2, UserRoundCheck } from 'lucide-react'
16
17
  import Link from 'next/link'
17
18
  import { useForm } from 'react-hook-form'
18
19
  import { toast } from 'sonner'
@@ -97,7 +98,7 @@ export function EditMediaDialogContent({
97
98
  />
98
99
  <div className="relative z-10 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
99
100
  <DrawerHeader>
100
- <DrawerTitle>Media file</DrawerTitle>
101
+ <DrawerTitle>Edit media</DrawerTitle>
101
102
  </DrawerHeader>
102
103
  <Form {...form}>
103
104
  <form
@@ -196,9 +197,14 @@ export function EditMediaDialogContent({
196
197
  </Form>
197
198
 
198
199
  <DrawerFooter>
199
- <Button type="submit" form={formId} disabled={isPending || isDeleting || !isDirty}>
200
- {isPending && <LoaderCircle className="animate-spin" />}
201
- Save
200
+ <Button
201
+ type="submit"
202
+ form={formId}
203
+ disabled={isPending || isDeleting || !isDirty}
204
+ aria-busy={isPending}
205
+ >
206
+ {isPending && <Spinner />}
207
+ {isPending ? 'Saving…' : 'Save'}
202
208
  </Button>
203
209
  <MediaDeleteDrawer
204
210
  media={media}
@@ -42,9 +42,10 @@ export function MediaDeleteDialog({ media, disabled = false, onDeleted }: MediaD
42
42
  return
43
43
  }
44
44
 
45
- toast.error(result.error ?? 'Failed to delete media')
45
+ toast.error(result.error ?? 'Failed to delete this media')
46
46
  } catch (error) {
47
- toast.error(error instanceof Error ? error.message : 'Failed to delete media')
47
+ console.error(error)
48
+ toast.error('Failed to delete this media. Try again.')
48
49
  }
49
50
  })
50
51
  }
@@ -61,7 +62,7 @@ export function MediaDeleteDialog({ media, disabled = false, onDeleted }: MediaD
61
62
  <AlertDialogHeader>
62
63
  <AlertDialogTitle>Delete this media?</AlertDialogTitle>
63
64
  <AlertDialogDescription>
64
- This removes {media.filename} from the media library. Storage files are not removed.
65
+ This deletes {media.filename} from the media library. The stored file is not deleted.
65
66
  </AlertDialogDescription>
66
67
  </AlertDialogHeader>
67
68
  <AlertDialogFooter>
@@ -69,12 +70,13 @@ export function MediaDeleteDialog({ media, disabled = false, onDeleted }: MediaD
69
70
  <AlertDialogAction
70
71
  variant="destructive"
71
72
  disabled={isPending}
73
+ aria-busy={isPending}
72
74
  onClick={(event) => {
73
75
  event.preventDefault()
74
76
  void handleDelete()
75
77
  }}
76
78
  >
77
- {isPending ? 'Deleting...' : 'Delete'}
79
+ {isPending ? 'Deleting' : 'Delete media'}
78
80
  </AlertDialogAction>
79
81
  </AlertDialogFooter>
80
82
  </AlertDialogContent>
@@ -4,7 +4,8 @@ import * as React from 'react'
4
4
  import type { AdminMedia } from '@admin/types'
5
5
  import { deleteMedia } from '@admin/actions/media'
6
6
  import { Button } from '@admin/components/ui/button'
7
- import { LoaderCircle, Trash2, TriangleAlert } from 'lucide-react'
7
+ import { Spinner } from '@admin/components/ui/spinner'
8
+ import { Trash2, TriangleAlert } from 'lucide-react'
8
9
  import { toast } from 'sonner'
9
10
  import { Drawer as DrawerPrimitive } from 'vaul'
10
11
 
@@ -72,9 +73,10 @@ export function MediaDeleteDrawer({
72
73
  return
73
74
  }
74
75
 
75
- toast.error(result.error ?? 'Failed to delete media')
76
+ toast.error(result.error ?? 'Failed to delete this media')
76
77
  } catch (error) {
77
- toast.error(error instanceof Error ? error.message : 'Failed to delete media')
78
+ console.error(error)
79
+ toast.error('Failed to delete this media. Try again.')
78
80
  }
79
81
  })
80
82
  }
@@ -98,7 +100,7 @@ export function MediaDeleteDrawer({
98
100
  <DrawerPrimitive.Trigger asChild>
99
101
  <Button variant="destructive" disabled={disabled || isPending || !container}>
100
102
  <Trash2 />
101
- Delete File
103
+ Delete media
102
104
  </Button>
103
105
  </DrawerPrimitive.Trigger>
104
106
  <DrawerPrimitive.Portal>
@@ -117,10 +119,11 @@ export function MediaDeleteDrawer({
117
119
  <TriangleAlert className="size-5" />
118
120
  </div>
119
121
  <DrawerPrimitive.Title className="font-heading text-base font-medium text-foreground">
120
- Delete this file?
122
+ Delete this media?
121
123
  </DrawerPrimitive.Title>
122
124
  <DrawerPrimitive.Description className="mt-1 text-sm text-muted-foreground">
123
- This removes {media.filename} from the media library. Storage files are not removed.
125
+ This deletes {media.filename} from the media library. The stored file is not
126
+ deleted.
124
127
  </DrawerPrimitive.Description>
125
128
  <div className="mt-5 grid grid-cols-2 gap-2">
126
129
  <Button
@@ -134,9 +137,10 @@ export function MediaDeleteDrawer({
134
137
  variant="destructive"
135
138
  onClick={() => void handleDelete()}
136
139
  disabled={isPending}
140
+ aria-busy={isPending}
137
141
  >
138
- {isPending && <LoaderCircle className="animate-spin" />}
139
- {isPending ? 'Deleting...' : 'Delete'}
142
+ {isPending && <Spinner />}
143
+ {isPending ? 'Deleting' : 'Delete media'}
140
144
  </Button>
141
145
  </div>
142
146
  </div>
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { PlaceholderCard } from '@admin/components/custom/placeholder-card'
4
4
  import { Button } from '@admin/components/ui/button'
5
- import { ImageIcon, LoaderCircle } from 'lucide-react'
5
+ import { Spinner } from '@admin/components/ui/spinner'
6
+ import { ImageIcon } from 'lucide-react'
6
7
 
7
8
  interface MediaEmptyStateProps {
8
9
  variant: 'no-results' | 'loading' | 'empty'
@@ -13,7 +14,7 @@ export function MediaEmptyState({ variant, onClearSearch }: MediaEmptyStateProps
13
14
  if (variant === 'loading') {
14
15
  return (
15
16
  <div className="flex h-48 items-center justify-center">
16
- <LoaderCircle className="animate-spin" />
17
+ <Spinner />
17
18
  </div>
18
19
  )
19
20
  }
@@ -22,8 +23,8 @@ export function MediaEmptyState({ variant, onClearSearch }: MediaEmptyStateProps
22
23
  return (
23
24
  <PlaceholderCard
24
25
  icon={<ImageIcon className="size-10 text-muted-foreground" strokeWidth={1} />}
25
- title="No files uploaded yet"
26
- description="Upload your first file to get started."
26
+ title="No media yet"
27
+ description="Upload an image, video, or document to get started."
27
28
  />
28
29
  )
29
30
  }
@@ -31,7 +32,7 @@ export function MediaEmptyState({ variant, onClearSearch }: MediaEmptyStateProps
31
32
  return (
32
33
  <PlaceholderCard
33
34
  icon={<ImageIcon className="size-10 text-foreground" strokeWidth={1} />}
34
- title="No files match your search"
35
+ title="No media matches your search"
35
36
  description="Try adjusting your search or filters."
36
37
  action={
37
38
  onClearSearch ? (
@@ -38,7 +38,7 @@ export function MediaGridPagination({
38
38
  const pageSizeValue = String(pageSize)
39
39
  const isFirstPage = page <= 1
40
40
  const isLastPage = page >= pageCount
41
- const perPage = 'Items per page'
41
+ const perPage = 'Rows per page'
42
42
 
43
43
  const handlePageSizeChange = React.useCallback(
44
44
  (value: string) => {
@@ -9,10 +9,11 @@ import {
9
9
  InputGroupButton,
10
10
  InputGroupInput
11
11
  } from '@admin/components/ui/input-group'
12
+ import { Spinner } from '@admin/components/ui/spinner'
12
13
  import { getMediaUrlHelpText, getMediaUrlPlaceholder } from '@admin/utils/media/media'
13
14
  import { cn } from '@admin/utils/shared/cn'
14
15
  import { isValidUrl } from '@admin/utils/validation/validation'
15
- import { LoaderCircle, Upload } from 'lucide-react'
16
+ import { Upload } from 'lucide-react'
16
17
 
17
18
  interface MediaUrlImporterProps extends React.HTMLAttributes<HTMLDivElement> {
18
19
  onImportFromUrl: (url: string) => Promise<UploadFileResult>
@@ -119,10 +120,11 @@ export function MediaUrlImporter({ onImportFromUrl, accept, className }: MediaUr
119
120
  variant="outline"
120
121
  onClick={() => void handleImport()}
121
122
  disabled={isImporting}
123
+ aria-busy={isImporting}
122
124
  className="h-full px-5"
123
125
  >
124
- {isImporting ? <LoaderCircle className="animate-spin" /> : <Upload />}
125
- Import
126
+ {isImporting ? <Spinner /> : <Upload />}
127
+ {isImporting ? 'Importing…' : 'Import'}
126
128
  </InputGroupButton>
127
129
  </InputGroupAddon>
128
130
  </InputGroup>
@@ -74,6 +74,8 @@ export function SearchInput({
74
74
  size="icon-sm"
75
75
  className="absolute top-1/2 right-2 size-7 -translate-y-1/2 text-foreground"
76
76
  disabled={isPending}
77
+ aria-busy={isPending}
78
+ aria-label="Submit search"
77
79
  >
78
80
  <CornerDownLeft className="size-4" />
79
81
  </Button>
@@ -14,6 +14,7 @@ import {
14
14
  DrawerTitle
15
15
  } from '@admin/components/ui/drawer'
16
16
  import { ScrollArea } from '@admin/components/ui/scroll-area'
17
+ import { Spinner } from '@admin/components/ui/spinner'
17
18
  import {
18
19
  closestCenter,
19
20
  DndContext,
@@ -29,7 +30,6 @@ import {
29
30
  verticalListSortingStrategy
30
31
  } from '@dnd-kit/sortable'
31
32
  import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
32
- import { LoaderCircle } from 'lucide-react'
33
33
  import { toast } from 'sonner'
34
34
  import { DragOverlayItem } from './sort-order-drag-overlay-item'
35
35
  import { SortableItem } from './sort-order-item'
@@ -171,7 +171,7 @@ export function SortOrderDialog({
171
171
  toast.error(result.error || 'Failed to save sort order')
172
172
  }
173
173
  } catch {
174
- toast.error('An error occurred while saving')
174
+ toast.error('Failed to save the new order. Try again.')
175
175
  }
176
176
  })
177
177
  }, [hasChanges, isSaving, orderedItems, onSave, onOpenChange])
@@ -200,7 +200,7 @@ export function SortOrderDialog({
200
200
  </DrawerHeader>
201
201
  {isLoading ? (
202
202
  <div className="flex min-h-0 flex-1 items-center justify-center">
203
- <LoaderCircle className="animate-spin" />
203
+ <Spinner />
204
204
  </div>
205
205
  ) : (
206
206
  <ScrollArea className="min-h-0 flex-1">
@@ -215,16 +215,21 @@ export function SortOrderDialog({
215
215
  </SortableContext>
216
216
  {hasNextPage && (
217
217
  <div ref={sentinelRef} className="flex items-center justify-center py-3">
218
- {isFetchingNextPage && <LoaderCircle className="animate-spin" />}
218
+ {isFetchingNextPage && <Spinner />}
219
219
  </div>
220
220
  )}
221
221
  </div>
222
222
  </ScrollArea>
223
223
  )}
224
224
  <DrawerFooter>
225
- <Button onClick={handleSave} disabled={!hasChanges || isSaving} size="lg">
226
- {isSaving && <LoaderCircle className="animate-spin" />}
227
- {isSaving ? 'Saving...' : 'Save'}
225
+ <Button
226
+ onClick={handleSave}
227
+ disabled={!hasChanges || isSaving}
228
+ aria-busy={isSaving}
229
+ size="lg"
230
+ >
231
+ {isSaving && <Spinner />}
232
+ {isSaving ? 'Saving…' : 'Save'}
228
233
  </Button>
229
234
  <DrawerClose asChild>
230
235
  <Button variant="outline">Close</Button>
@@ -19,7 +19,8 @@ export function useEntityVersions({ entityId, getVersionsAction }: UseEntityVers
19
19
  const nextVersions = await getVersionsAction(entityId)
20
20
  setVersions(nextVersions)
21
21
  } catch (error) {
22
- toast.error(error instanceof Error ? error.message : 'Failed to load versions')
22
+ console.error(error)
23
+ toast.error('Failed to load versions. Try again.')
23
24
  }
24
25
  })
25
26
  }, [entityId, getVersionsAction])
@@ -6,6 +6,7 @@ import { requireRole, UserRole } from '@admin/auth/middleware'
6
6
  import db from '@admin/db'
7
7
  import { auditLog } from '@admin/db/schema'
8
8
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
9
+ import { toActionError } from '@admin/utils/error/action-error'
9
10
  import { lt } from 'drizzle-orm'
10
11
  import { getAuditRequestMetadata } from './request-metadata'
11
12
 
@@ -57,7 +58,7 @@ export async function cleanupAuditLogs(): Promise<CleanupAuditLogsResult> {
57
58
  } catch (error) {
58
59
  return {
59
60
  success: false,
60
- error: error instanceof Error ? error.message : 'Failed to clean up audit logs'
61
+ error: toActionError(error, 'Failed to clean up audit logs')
61
62
  }
62
63
  }
63
64
  }
@@ -4,6 +4,7 @@ import type { FormSettingsData, FormSettingsResult, UpsertFormSettingsInput } fr
4
4
  import { requireRole, UserRole } from '@admin/auth/middleware'
5
5
  import db from '@admin/db'
6
6
  import { formSettings } from '@admin/db/schema'
7
+ import { toActionError } from '@admin/utils/error/action-error'
7
8
  import { updateTag } from 'next/cache'
8
9
  import { formSettingsCacheTags } from './types'
9
10
 
@@ -35,7 +36,7 @@ export async function upsertFormSettings(
35
36
  console.error(`Error upserting form settings for ${formName}:`, error)
36
37
  return {
37
38
  success: false,
38
- error: error instanceof Error ? error.message : 'Failed to save form settings'
39
+ error: toActionError(error, 'Failed to save form settings')
39
40
  }
40
41
  }
41
42
  }
@@ -7,6 +7,7 @@ import db from '@admin/db'
7
7
  import { adminMedia } from '@admin/db/schema'
8
8
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
9
9
  import { getTouchedFieldNames } from '@admin/utils/audit/audit'
10
+ import { toActionError } from '@admin/utils/error/action-error'
10
11
  import { updateTag } from 'next/cache'
11
12
  import { getMediaById } from './get-media-by-id'
12
13
  import { mediaCacheTags } from './types'
@@ -53,7 +54,7 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
53
54
  } catch (error) {
54
55
  return {
55
56
  success: false,
56
- error: error instanceof Error ? error.message : 'Failed to create media record'
57
+ error: toActionError(error, 'Failed to create media record')
57
58
  }
58
59
  }
59
60
  }
@@ -6,6 +6,7 @@ import { requireRole, UserRole } from '@admin/auth/middleware'
6
6
  import db from '@admin/db'
7
7
  import { adminMedia } from '@admin/db/schema'
8
8
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
9
+ import { toActionError } from '@admin/utils/error/action-error'
9
10
  import { inArray } from 'drizzle-orm'
10
11
  import { updateTag } from 'next/cache'
11
12
  import { mediaCacheTags } from './types'
@@ -46,7 +47,7 @@ export async function deleteMediaBulk(ids: string[]): Promise<DeleteMediaBulkRes
46
47
  } catch (error) {
47
48
  return {
48
49
  success: false,
49
- error: error instanceof Error ? error.message : 'Failed to delete media'
50
+ error: toActionError(error, 'Failed to delete media')
50
51
  }
51
52
  }
52
53
  }
@@ -6,6 +6,7 @@ import { requireRole, UserRole } from '@admin/auth/middleware'
6
6
  import db from '@admin/db'
7
7
  import { adminMedia } from '@admin/db/schema'
8
8
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
9
+ import { toActionError } from '@admin/utils/error/action-error'
9
10
  import { eq } from 'drizzle-orm'
10
11
  import { updateTag } from 'next/cache'
11
12
  import { mediaCacheTags } from './types'
@@ -37,7 +38,7 @@ export async function deleteMedia(id: string): Promise<DeleteMediaResult> {
37
38
  } catch (error) {
38
39
  return {
39
40
  success: false,
40
- error: error instanceof Error ? error.message : 'Failed to delete media'
41
+ error: toActionError(error, 'Failed to delete media')
41
42
  }
42
43
  }
43
44
  }
@@ -7,6 +7,7 @@ import db from '@admin/db'
7
7
  import { adminMedia } from '@admin/db/schema'
8
8
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
9
9
  import { getChangedFieldNames } from '@admin/utils/audit/audit'
10
+ import { toActionError } from '@admin/utils/error/action-error'
10
11
  import { eq } from 'drizzle-orm'
11
12
  import { updateTag } from 'next/cache'
12
13
  import { mediaCacheTags } from './types'
@@ -51,7 +52,7 @@ export async function updateMedia(
51
52
  } catch (error) {
52
53
  return {
53
54
  success: false,
54
- error: error instanceof Error ? error.message : 'Failed to update media'
55
+ error: toActionError(error, 'Failed to update media')
55
56
  }
56
57
  }
57
58
  }
@@ -5,6 +5,7 @@ import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
5
5
  import { auth } from '@admin/auth'
6
6
  import { getSession } from '@admin/auth/middleware'
7
7
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
8
+ import { toActionError } from '@admin/utils/error/action-error'
8
9
  import { headers } from 'next/headers'
9
10
  import { invalidateUsersCache } from './invalidate-users-cache'
10
11
 
@@ -53,7 +54,7 @@ export async function changeOwnPassword(
53
54
  } catch (error) {
54
55
  return {
55
56
  success: false,
56
- error: error instanceof Error ? error.message : 'Failed to change password'
57
+ error: toActionError(error, 'Failed to change password')
57
58
  }
58
59
  }
59
60
  }
@@ -7,6 +7,7 @@ import { getSession } from '@admin/auth/middleware'
7
7
  import db from '@admin/db'
8
8
  import { user } from '@admin/db/schema'
9
9
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
10
+ import { toActionError } from '@admin/utils/error/action-error'
10
11
  import { eq } from 'drizzle-orm'
11
12
  import { headers } from 'next/headers'
12
13
  import { invalidateUsersCache } from './invalidate-users-cache'
@@ -68,7 +69,7 @@ export async function updateEmail(
68
69
  } catch (error) {
69
70
  return {
70
71
  success: false,
71
- error: error instanceof Error ? error.message : 'Failed to update email'
72
+ error: toActionError(error, 'Failed to update email')
72
73
  }
73
74
  }
74
75
  }
@@ -33,7 +33,7 @@ export async function uploadMediaFromUrl(
33
33
  if (!response.ok) {
34
34
  return {
35
35
  success: false,
36
- error: `Failed to fetch media: ${response.status}`
36
+ error: "Couldn't reach that URL. Check the link and try again."
37
37
  }
38
38
  }
39
39
 
@@ -43,7 +43,7 @@ export async function uploadMediaFromUrl(
43
43
  .toLowerCase()
44
44
 
45
45
  if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) {
46
- return { success: false, error: `Invalid content type: ${contentType}` }
46
+ return { success: false, error: 'That link is not an image or video.' }
47
47
  }
48
48
 
49
49
  const filename = getRemoteFilename(url, contentType)