create-nevela 0.5.1 → 0.5.3

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 (31) hide show
  1. package/index.mjs +32 -2
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +16 -0
  5. package/template/laravel/src/Concerns/Trashable.php +63 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +43 -0
  7. package/template/laravel/src/Console/TrashCommand.php +33 -0
  8. package/template/laravel/src/Generator/ResourceGenerator.php +84 -1
  9. package/template/laravel/src/Http/ResourceRequest.php +108 -0
  10. package/template/laravel/src/Http/TrashController.php +190 -0
  11. package/template/laravel/src/Nevela.php +162 -15
  12. package/template/laravel/src/NevelaServiceProvider.php +19 -0
  13. package/template/laravel/src/Support/Insights.php +78 -0
  14. package/template/laravel/src/Trash/Trash.php +151 -0
  15. package/template/laravel/src/Trash/TrashedScope.php +21 -0
  16. package/template/web/app/dashboard/actions.ts +79 -0
  17. package/template/web/app/dashboard/layout.tsx +4 -2
  18. package/template/web/app/dashboard/trash/actions.ts +78 -0
  19. package/template/web/app/dashboard/trash/page.tsx +167 -0
  20. package/template/web/components/dashboard/bulk-create-grid.tsx +326 -0
  21. package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
  22. package/template/web/components/dashboard/record-actions.tsx +14 -5
  23. package/template/web/components/dashboard/record-detail.tsx +3 -0
  24. package/template/web/components/dashboard/resource-insights.tsx +219 -0
  25. package/template/web/components/dashboard/resource-table.tsx +10 -0
  26. package/template/web/components/dashboard/row-actions.tsx +12 -7
  27. package/template/web/components/dashboard/table-selection.tsx +27 -7
  28. package/template/web/components/dashboard/trash-actions.tsx +178 -0
  29. package/template/web/lib/trash-info.ts +13 -0
  30. package/template/web/lib/trash.ts +66 -0
  31. package/template/web/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Trash;
4
+
5
+ use Illuminate\Database\Eloquent\Model;
6
+ use Illuminate\Database\QueryException;
7
+ use Illuminate\Support\Carbon;
8
+ use Illuminate\Support\Facades\Gate;
9
+ use Nevela\Laravel\Nevela;
10
+ use Nevela\Laravel\Support\Descriptor;
11
+
12
+ /**
13
+ * What a delete leaves behind, and what becomes of it.
14
+ *
15
+ * A deleted record is kept, hidden from every list, count and export, for a number of
16
+ * days (config: nevela.trash.days, 30 unless changed). In that time it can be restored
17
+ * from the dashboard's Trash page. After it, the record is removed for good: by
18
+ * `nevela:trash`, which the scheduler runs each night, and whenever the trash is opened.
19
+ */
20
+ final class Trash
21
+ {
22
+ /** How many days a deleted record can be brought back, or null when it is kept until someone removes it. */
23
+ public static function days(): ?int
24
+ {
25
+ $days = config('nevela.trash.days', 30);
26
+
27
+ return is_numeric($days) && (int) $days > 0 ? (int) $days : null;
28
+ }
29
+
30
+ /**
31
+ * The resources that have a trash, with the model each one's records are.
32
+ *
33
+ * A resource is left out when its model doesn't keep deleted records: the app hasn't
34
+ * migrated yet, or its model was changed not to.
35
+ *
36
+ * @return list<array{descriptor: Descriptor, model: class-string<Model>}>
37
+ */
38
+ public static function resources(): array
39
+ {
40
+ $found = [];
41
+ foreach (Nevela::all() as $descriptor) {
42
+ $model = "App\\Models\\{$descriptor->name}";
43
+ if (class_exists($model) && method_exists($model, 'trashes') && $model::trashes()) {
44
+ $found[] = ['descriptor' => $descriptor, 'model' => $model];
45
+ }
46
+ }
47
+ usort($found, fn ($a, $b) => strcmp($a['descriptor']->pluralLabel, $b['descriptor']->pluralLabel));
48
+
49
+ return $found;
50
+ }
51
+
52
+ /** @return array{descriptor: Descriptor, model: class-string<Model>}|null */
53
+ public static function resource(string $slug): ?array
54
+ {
55
+ foreach (self::resources() as $resource) {
56
+ if ($resource['descriptor']->slug === $slug) {
57
+ return $resource;
58
+ }
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Whether the signed-in person may do something to a deleted record.
66
+ *
67
+ * Laravel's names for these are `restore` and `forceDelete`, and a generated policy
68
+ * has both. A policy written before the trash existed has neither, so it is asked
69
+ * what it would say to `delete`: whoever could delete a record can deal with it here.
70
+ */
71
+ public static function may(string $ability, Model $record): bool
72
+ {
73
+ $policy = Gate::getPolicyFor($record);
74
+
75
+ return Gate::allows($policy !== null && method_exists($policy, $ability) ? $ability : 'delete', $record);
76
+ }
77
+
78
+ /**
79
+ * Whether they may see a resource's trash at all: whoever may delete, restore or remove
80
+ * for good. Asked of a record that is nobody's in particular. A policy that keeps
81
+ * restoring for administrators still lets the people who delete see what they deleted.
82
+ */
83
+ public static function maySee(string $model): bool
84
+ {
85
+ $blank = new $model;
86
+
87
+ return self::may('delete', $blank) || self::may('restore', $blank) || self::may('forceDelete', $blank);
88
+ }
89
+
90
+ /** When a record deleted at `$deletedAt` is removed for good, or null when it is kept until someone does. */
91
+ public static function expires(?Carbon $deletedAt): ?Carbon
92
+ {
93
+ $days = self::days();
94
+
95
+ return $days === null || $deletedAt === null ? null : $deletedAt->copy()->addDays($days);
96
+ }
97
+
98
+ /**
99
+ * Remove a deleted record for good. False when the database won't let it go because
100
+ * other records, themselves in the trash, still belong to it.
101
+ */
102
+ public static function purge(Model $record): bool
103
+ {
104
+ try {
105
+ $record->forceDelete();
106
+
107
+ return true;
108
+ } catch (QueryException $e) {
109
+ // 23000 (and Postgres's 23503): a foreign key still points at this row.
110
+ if (str_starts_with((string) $e->getCode(), '23')) {
111
+ return false;
112
+ }
113
+ throw $e;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remove everything whose time in the trash has run out.
119
+ *
120
+ * @param class-string<Model>|null $only One resource's model, or all of them
121
+ * @return array<string, int> How many were removed, by resource name
122
+ */
123
+ public static function purgeExpired(?string $only = null): array
124
+ {
125
+ $days = self::days();
126
+ if ($days === null) {
127
+ return [];
128
+ }
129
+ $cutoff = now()->subDays($days);
130
+ $removed = [];
131
+ foreach (self::resources() as $resource) {
132
+ $model = $resource['model'];
133
+ if ($only !== null && $only !== $model) {
134
+ continue;
135
+ }
136
+ $count = 0;
137
+ // One at a time, so a record something still belongs to is left and the rest go.
138
+ $model::onlyTrashed()->where((new $model)->getQualifiedDeletedAtColumn(), '<', $cutoff)->orderBy((new $model)->getKeyName())
139
+ ->chunkById(200, function ($records) use (&$count) {
140
+ foreach ($records as $record) {
141
+ $count += self::purge($record) ? 1 : 0;
142
+ }
143
+ });
144
+ if ($count > 0) {
145
+ $removed[$resource['descriptor']->name] = $count;
146
+ }
147
+ }
148
+
149
+ return $removed;
150
+ }
151
+ }
@@ -0,0 +1,21 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Trash;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\Eloquent\Model;
7
+ use Illuminate\Database\Eloquent\SoftDeletingScope;
8
+
9
+ /**
10
+ * Laravel's soft-delete scope, which hides deleted rows from every query, applied only
11
+ * to a table that has the column to hide them by. See Concerns\Trashable.
12
+ */
13
+ final class TrashedScope extends SoftDeletingScope
14
+ {
15
+ public function apply(Builder $builder, Model $model): void
16
+ {
17
+ if ($model::trashes()) {
18
+ parent::apply($builder, $model);
19
+ }
20
+ }
21
+ }
@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
4
4
  import { storedFields, type PolicyAction } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { toCsv } from "@/lib/csv";
7
+ import { revalidateResource } from "@/lib/cache";
7
8
  import { fileUrl } from "@/lib/files";
9
+ import { laravel } from "@/lib/laravel";
8
10
  import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
9
11
 
10
12
  export type ActionResult<T = unknown> =
@@ -103,6 +105,83 @@ export async function createRecordAction(resourceName: string, input: unknown):
103
105
  return result;
104
106
  }
105
107
 
108
+ export type InsightsUnit = "day" | "week" | "month";
109
+
110
+ export interface InsightsData {
111
+ /** Everything the list matches, whenever it was created. */
112
+ total: number;
113
+ unit: InsightsUnit;
114
+ /** How many were created in each period, oldest first. A day or a week is "2026-10-05" (a week by its Monday), a month "2026-10". */
115
+ series: { bucket: string; count: number }[];
116
+ /** How the records split across each field that is a choice. */
117
+ breakdown: { field: string; kind: "enum" | "boolean"; slices: { value: string; count: number }[] }[];
118
+ }
119
+
120
+ /**
121
+ * The counts behind the insights panel: `GET /{slug}/_insights`.
122
+ *
123
+ * `query` is the list's own search and filters, so the charts are of the rows the table
124
+ * is showing. Asked for only when the panel is opened.
125
+ */
126
+ export async function resourceInsightsAction(resourceName: string, query: string, unit: InsightsUnit): Promise<ActionResult<InsightsData>> {
127
+ const denied = await allowed(resourceName, "read");
128
+ if (denied) return denied;
129
+
130
+ const { resource } = dashboardStore(resourceName);
131
+ const params = new URLSearchParams();
132
+ for (const [key, value] of new URLSearchParams(query)) if (key === "q" || key.startsWith("filter[")) params.append(key, value);
133
+ params.set("unit", unit);
134
+ const response = await laravel(`${resource.slug}/_insights?${params}`);
135
+ if (!response.ok) {
136
+ const body = (response.body ?? {}) as { error?: string };
137
+ // An app whose Laravel side is from before this existed has no such address.
138
+ const missing = response.status === 404 || response.status === 405;
139
+ return { ok: false, status: response.status, error: missing ? "This app's API has no insights yet. Run: nevela upgrade" : (body.error ?? "Request failed.") };
140
+ }
141
+ return { ok: true, data: response.body as InsightsData };
142
+ }
143
+
144
+ /** What was wrong with one row of several, numbered from 1 among the rows that were sent. */
145
+ export interface RowProblem {
146
+ row: number;
147
+ issues: FieldIssue[];
148
+ }
149
+
150
+ export type CreateManyResult = { ok: true; data: { created: number } } | { ok: false; status: number; error: string; rows?: RowProblem[] };
151
+
152
+ /**
153
+ * Several records at once, for the "Add several" grid: `POST /{slug}/_bulk`.
154
+ *
155
+ * Laravel checks every row with the rules it uses for one, and saves them in a single
156
+ * transaction. So the answer is all of them, or none and the rows that were wrong.
157
+ */
158
+ export async function createManyAction(resourceName: string, rows: Record<string, unknown>[]): Promise<CreateManyResult> {
159
+ const denied = await allowed(resourceName, "create");
160
+ if (denied) return denied;
161
+ if (rows.length === 0) return { ok: false, status: 400, error: "Nothing to create: every row is empty." };
162
+
163
+ const { resource } = dashboardStore(resourceName);
164
+ const response = await laravel(`${resource.slug}/_bulk`, { method: "POST", body: { items: rows } });
165
+ const body = (response.body ?? {}) as { error?: string; rows?: RowProblem[]; created?: number; data?: { id?: unknown }[] };
166
+ if (!response.ok) {
167
+ return {
168
+ ok: false,
169
+ status: response.status,
170
+ // An app whose Laravel side is from before this existed answers 404 or 405.
171
+ error: response.status === 404 || response.status === 405 ? "This app's API can't create several at once yet. Run: nevela upgrade" : (body.error ?? "Request failed."),
172
+ ...(body.rows ? { rows: body.rows } : {}),
173
+ };
174
+ }
175
+
176
+ try {
177
+ revalidateResource({ resource: resource.name, action: "create", id: String(body.data?.[0]?.id ?? "") });
178
+ } catch (error) {
179
+ console.error(`[nevela] ${resource.name} bulk create cache invalidation failed:`, error);
180
+ }
181
+ revalidatePath(resourcePath(resource));
182
+ return { ok: true, data: { created: body.created ?? rows.length } };
183
+ }
184
+
106
185
  export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
107
186
  const denied = await allowed(resourceName, "update");
108
187
  if (denied) return denied;
@@ -4,7 +4,7 @@ import { Toaster } from "@/components/ui/sonner";
4
4
  import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
5
5
  import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
6
  import { dashboardLinks } from "@/lib/dashboard-nav";
7
- import { dashboardSession, visibleResources } from "@/lib/dashboard";
7
+ import { allResources, dashboardSession, mayDo, visibleResources } from "@/lib/dashboard";
8
8
  import { fileUrl } from "@/lib/files";
9
9
  import { requireSession } from "@/lib/session";
10
10
  import { site } from "@/lib/site";
@@ -34,10 +34,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
34
34
  // The screens for managing people, for those whose roles allow it.
35
35
  const { isAdmin, may } = await dashboardSession();
36
36
  const access = { users: may("users.view"), roles: may("roles.view") };
37
+ // The trash is for those who may delete: whoever can put a record there can deal with it there.
38
+ const trash = (await Promise.all(allResources().map((resource) => mayDo(resource, "delete")))).some(Boolean);
37
39
 
38
40
  return (
39
41
  <SidebarProvider defaultOpen={!collapsed}>
40
- <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} user={account} />
42
+ <DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} trash={trash} user={account} />
41
43
  <SidebarInset>
42
44
  <DashboardHeader user={account} initialTheme={theme} notices={[]} />
43
45
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
@@ -0,0 +1,78 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { revalidateResource } from "@/lib/cache";
5
+ import { allResources, resourcePath } from "@/lib/dashboard";
6
+ import { laravel } from "@/lib/laravel";
7
+ import { getSession } from "@/lib/session";
8
+
9
+ /**
10
+ * What can be done with a deleted record: bring it back, or remove it for good. Laravel
11
+ * decides who may, by the resource's own policy, and its answer is passed back as it is.
12
+ */
13
+
14
+ export type TrashResult<T = null> = { ok: true; data: T } | { ok: false; error: string };
15
+
16
+ /** Both places a change shows: the trash it left or stayed in, and the list it came from. */
17
+ function revalidate(slug: string) {
18
+ const resource = allResources().find((item) => item.slug === slug);
19
+ if (resource) {
20
+ try {
21
+ revalidateResource({ resource: resource.name, action: "create", id: "" });
22
+ } catch (error) {
23
+ console.error(`[nevela] ${resource.name} trash cache invalidation failed:`, error);
24
+ }
25
+ revalidatePath(resourcePath(resource));
26
+ }
27
+ revalidatePath("/dashboard/trash");
28
+ }
29
+
30
+ async function send<T>(slug: string, path: string, method: "POST" | "DELETE"): Promise<TrashResult<T>> {
31
+ if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
32
+ const response = await laravel(path, { method });
33
+ if (!response.ok) {
34
+ const body = (response.body ?? {}) as { error?: string };
35
+ return { ok: false, error: body.error ?? `Something went wrong (${response.status}).` };
36
+ }
37
+ revalidate(slug);
38
+ return { ok: true, data: response.body as T };
39
+ }
40
+
41
+ /** Back into its list, as it was. */
42
+ export async function restoreRecordAction(slug: string, id: string): Promise<TrashResult<{ id: string }>> {
43
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}/restore`, "POST");
44
+ }
45
+
46
+ /**
47
+ * Several back at once: what "Undo" does after deleting a selection. Ids that were never
48
+ * deleted (the ones the delete itself refused) aren't in the trash, and are passed over.
49
+ */
50
+ export async function restoreManyAction(slug: string, ids: string[]): Promise<TrashResult<{ restored: number; refused: number; reason?: string }>> {
51
+ if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
52
+ if (ids.length > 500) return { ok: false, error: "That's more than 500 records. Restore them from the trash instead." };
53
+ let restored = 0;
54
+ let refused = 0;
55
+ let reason: string | undefined;
56
+ for (const id of ids) {
57
+ const response = await laravel(`_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}/restore`, { method: "POST" });
58
+ if (response.ok) restored++;
59
+ else if (response.status !== 404) {
60
+ refused++;
61
+ reason ??= ((response.body ?? {}) as { error?: string }).error ?? `Something went wrong (${response.status}).`;
62
+ }
63
+ }
64
+ if (restored > 0) revalidate(slug);
65
+ if (restored === 0) return { ok: false, error: reason ?? "Nothing to restore: these aren't in the trash any more." };
66
+ // Some came back and some were refused: both are said, so "Restored" is never the whole answer when it isn't.
67
+ return { ok: true, data: { restored, refused, reason } };
68
+ }
69
+
70
+ /** Gone for good. */
71
+ export async function purgeRecordAction(slug: string, id: string): Promise<TrashResult> {
72
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}/${encodeURIComponent(id)}`, "DELETE");
73
+ }
74
+
75
+ /** Everything of one resource that is in the trash, for good. */
76
+ export async function emptyTrashAction(slug: string): Promise<TrashResult<{ removed: number; kept: number }>> {
77
+ return send(slug, `_nevela/trash/${encodeURIComponent(slug)}?confirm=${encodeURIComponent(slug)}`, "DELETE");
78
+ }
@@ -0,0 +1,167 @@
1
+ import Link from "next/link";
2
+ import { LocalTime } from "@/components/dashboard/local-time";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { resourceIcon } from "@/components/dashboard/resource-icon";
5
+ import { EmptyTrashButton, TimeLeft, TrashRowActions } from "@/components/dashboard/trash-actions";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
9
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
10
+ import { requireDashboard } from "@/lib/dashboard";
11
+ import { trashBuckets, trashedRecords } from "@/lib/trash";
12
+ import { keptFor } from "@/lib/trash-info";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ export const metadata = { title: "Trash" };
16
+
17
+ type Params = { in?: string; page?: string };
18
+
19
+ /**
20
+ * Deleted records, by resource: restore one, or remove it for good.
21
+ *
22
+ * Whose trash a person sees is Laravel's to say, by each resource's own policy. Someone
23
+ * who may delete products and nothing else sees deleted products and nothing else.
24
+ */
25
+ export default async function TrashPage({ searchParams }: { searchParams: Promise<Params> }) {
26
+ await requireDashboard("/dashboard/trash");
27
+ const params = await searchParams;
28
+ const trash = await trashBuckets();
29
+ const buckets = trash?.resources ?? [];
30
+ // The one asked for, or the first with something in it.
31
+ const current = buckets.find((bucket) => bucket.slug === params.in) ?? buckets.find((bucket) => bucket.count > 0) ?? buckets[0];
32
+ let list = current ? await trashedRecords(current.slug, params.page) : null;
33
+ // The last record of the last page was just restored or removed: show the page before it.
34
+ if (current && list && list.data.length === 0 && list.meta.total > 0) list = await trashedRecords(current.slug, String(list.meta.totalPages));
35
+ const records = list?.data ?? [];
36
+ const meta = list?.meta ?? { page: 1, perPage: 25, total: 0, totalPages: 1 };
37
+ const href = (slug: string, page?: number) => `/dashboard/trash?${new URLSearchParams({ in: slug, ...(page && page > 1 ? { page: String(page) } : {}) })}`;
38
+
39
+ return (
40
+ <>
41
+ <PageHeader
42
+ title="Trash"
43
+ description={
44
+ trash
45
+ ? `Deleted records are kept here ${keptFor(trash)}${trash.days === null ? "." : ", then removed for good."} Until then, they can be restored.`
46
+ : "Deleted records, to restore or to remove for good."
47
+ }
48
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Trash" }]}
49
+ actions={current && meta.total > 0 && <EmptyTrashButton slug={current.slug} count={meta.total} noun={current.label.toLowerCase()} plural={current.pluralLabel.toLowerCase()} />}
50
+ />
51
+
52
+ {!trash ? (
53
+ <Empty className="rounded-xl border border-dashed">
54
+ <EmptyHeader>
55
+ <EmptyTitle>This app has no trash yet</EmptyTitle>
56
+ <EmptyDescription>
57
+ Its Laravel side is from before Nevela had one. Run <code>nevela upgrade</code>, then <code>nevela migrate</code>.
58
+ </EmptyDescription>
59
+ </EmptyHeader>
60
+ </Empty>
61
+ ) : buckets.length === 0 ? (
62
+ <Empty className="rounded-xl border border-dashed">
63
+ <EmptyHeader>
64
+ <EmptyTitle>Nothing here for you</EmptyTitle>
65
+ <EmptyDescription>
66
+ The trash holds what you may delete, once its table keeps deleted records. After an upgrade, <code>nevela migrate</code> is what gives each table
67
+ its trash.
68
+ </EmptyDescription>
69
+ </EmptyHeader>
70
+ </Empty>
71
+ ) : (
72
+ <>
73
+ <nav aria-label="Deleted records, by resource" className="flex flex-wrap gap-2">
74
+ {buckets.map((bucket) => {
75
+ const Icon = resourceIcon(bucket.icon ?? undefined);
76
+ const active = bucket.slug === current?.slug;
77
+ return (
78
+ <Button key={bucket.slug} variant={active ? "secondary" : "ghost"} size="sm" asChild>
79
+ <Link href={href(bucket.slug)} aria-current={active ? "page" : undefined} className={cn(!active && "text-muted-foreground")}>
80
+ <Icon data-icon="inline-start" />
81
+ {bucket.pluralLabel}
82
+ <Badge variant={bucket.count > 0 ? "default" : "outline"} className="tabular-nums">
83
+ {bucket.count.toLocaleString()}
84
+ </Badge>
85
+ </Link>
86
+ </Button>
87
+ );
88
+ })}
89
+ </nav>
90
+
91
+ {current && records.length === 0 ? (
92
+ <Empty className="rounded-xl border border-dashed">
93
+ <EmptyHeader>
94
+ <EmptyTitle>No deleted {current.pluralLabel.toLowerCase()}</EmptyTitle>
95
+ <EmptyDescription>When one is deleted, it waits here until it is restored or its time runs out.</EmptyDescription>
96
+ </EmptyHeader>
97
+ </Empty>
98
+ ) : (
99
+ current && (
100
+ <div className="overflow-x-auto rounded-xl border">
101
+ <Table>
102
+ <TableHeader>
103
+ <TableRow>
104
+ <TableHead>{current.label}</TableHead>
105
+ <TableHead>Deleted</TableHead>
106
+ <TableHead>Removed for good</TableHead>
107
+ <TableHead className="w-56">
108
+ <span className="sr-only">Actions</span>
109
+ </TableHead>
110
+ </TableRow>
111
+ </TableHeader>
112
+ <TableBody>
113
+ {records.map((record) => (
114
+ <TableRow key={record.id}>
115
+ <TableCell className="max-w-80 truncate font-medium">{record.label}</TableCell>
116
+ <TableCell className="text-sm text-muted-foreground tabular-nums">
117
+ <LocalTime value={record.deletedAt} />
118
+ </TableCell>
119
+ <TableCell className="text-sm text-muted-foreground tabular-nums">
120
+ <TimeLeft until={record.expiresAt} />
121
+ </TableCell>
122
+ <TableCell>
123
+ <TrashRowActions slug={current.slug} id={record.id} label={record.label} noun={current.label.toLowerCase()} />
124
+ </TableCell>
125
+ </TableRow>
126
+ ))}
127
+ </TableBody>
128
+ </Table>
129
+ </div>
130
+ )
131
+ )}
132
+
133
+ {current && meta.total > 0 && (
134
+ <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
135
+ <p className="tabular-nums">
136
+ {(meta.page - 1) * meta.perPage + 1}–{Math.min(meta.page * meta.perPage, meta.total)} of {meta.total.toLocaleString()} deleted{" "}
137
+ {meta.total === 1 ? current.label.toLowerCase() : current.pluralLabel.toLowerCase()}
138
+ </p>
139
+ {meta.totalPages > 1 && (
140
+ <div className="flex items-center gap-2">
141
+ {meta.page > 1 ? (
142
+ <Button variant="outline" size="sm" asChild>
143
+ <Link href={href(current.slug, meta.page - 1)}>Previous</Link>
144
+ </Button>
145
+ ) : (
146
+ <Button variant="outline" size="sm" disabled>
147
+ Previous
148
+ </Button>
149
+ )}
150
+ {meta.page < meta.totalPages ? (
151
+ <Button variant="outline" size="sm" asChild>
152
+ <Link href={href(current.slug, meta.page + 1)}>Next</Link>
153
+ </Button>
154
+ ) : (
155
+ <Button variant="outline" size="sm" disabled>
156
+ Next
157
+ </Button>
158
+ )}
159
+ </div>
160
+ )}
161
+ </div>
162
+ )}
163
+ </>
164
+ )}
165
+ </>
166
+ );
167
+ }