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,190 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Nevela\Laravel\Support\Descriptor;
9
+ use Nevela\Laravel\Trash\Trash;
10
+ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
11
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
12
+
13
+ /**
14
+ * The trash: deleted records, by resource, to restore or to remove for good.
15
+ *
16
+ * One controller for every resource, where everything else about a resource is generated.
17
+ * The trash asks the same three questions of all of them (what was deleted, bring it back,
18
+ * get rid of it), and a generated copy per resource would be the same file many times.
19
+ *
20
+ * Who may use it is the resource's own policy: `restore` and `forceDelete` where it has
21
+ * them, and what it says to `delete` where it was written before there was a trash.
22
+ */
23
+ final class TrashController
24
+ {
25
+ /**
26
+ * GET _nevela/trash/_status: which resources have a trash this person may use, and how
27
+ * long it keeps things. Nothing is counted and nothing is removed: this is asked on
28
+ * the way to drawing a delete button, to say truthfully what pressing it will do.
29
+ */
30
+ public function status(): JsonResponse
31
+ {
32
+ $slugs = [];
33
+ foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
34
+ if (Trash::maySee($model)) {
35
+ $slugs[] = $descriptor->slug;
36
+ }
37
+ }
38
+
39
+ return response()->json(['days' => Trash::days(), 'resources' => $slugs]);
40
+ }
41
+
42
+ /**
43
+ * GET _nevela/trash: how long things are kept, and how much of each resource is in it.
44
+ * Only the resources this person may deal with. Anything past its time goes first.
45
+ */
46
+ public function index(): JsonResponse
47
+ {
48
+ Trash::purgeExpired();
49
+
50
+ $resources = [];
51
+ foreach (Trash::resources() as ['descriptor' => $descriptor, 'model' => $model]) {
52
+ if (! Trash::maySee($model)) {
53
+ continue;
54
+ }
55
+ $resources[] = [
56
+ 'name' => $descriptor->name,
57
+ 'label' => $descriptor->label,
58
+ 'pluralLabel' => $descriptor->pluralLabel,
59
+ 'slug' => $descriptor->slug,
60
+ 'icon' => $descriptor->icon,
61
+ 'count' => $model::onlyTrashed()->count(),
62
+ ];
63
+ }
64
+
65
+ return response()->json(['days' => Trash::days(), 'resources' => $resources]);
66
+ }
67
+
68
+ /** GET _nevela/trash/{slug}?page=&perPage=: one resource's deleted records, the latest deletion first. */
69
+ public function show(Request $request, string $slug): JsonResponse
70
+ {
71
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
72
+ $input = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'perPage' => ['nullable', 'integer', 'min:1', 'max:100']]);
73
+ Trash::purgeExpired($model);
74
+
75
+ $instance = new $model;
76
+ $page = $model::onlyTrashed()
77
+ ->orderByDesc($instance->getQualifiedDeletedAtColumn())->orderBy($instance->getQualifiedKeyName())
78
+ ->paginate((int) ($input['perPage'] ?? 25), ['*'], 'page', (int) ($input['page'] ?? 1));
79
+
80
+ return response()->json([
81
+ 'data' => $page->getCollection()->map(fn ($record) => [
82
+ 'id' => (string) $record->getKey(),
83
+ 'label' => self::label($descriptor, $record),
84
+ 'deletedAt' => $record->{$record->getDeletedAtColumn()}?->toJSON(),
85
+ 'expiresAt' => Trash::expires($record->{$record->getDeletedAtColumn()})?->toJSON(),
86
+ ])->all(),
87
+ 'meta' => ['page' => $page->currentPage(), 'perPage' => $page->perPage(), 'total' => $page->total(), 'totalPages' => max(1, $page->lastPage())],
88
+ ]);
89
+ }
90
+
91
+ /** POST _nevela/trash/{slug}/{id}/restore: back into the list, as it was. */
92
+ public function restore(string $slug, string $id): JsonResponse
93
+ {
94
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
95
+ $record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
96
+ if (! Trash::may('restore', $record)) {
97
+ throw new AccessDeniedHttpException("You don't have access to this.");
98
+ }
99
+
100
+ // A record that must belong to another can't come back while that other is itself deleted.
101
+ foreach ($descriptor->fields as $field) {
102
+ if ($field->kind !== 'belongsTo' || ! $field->required) {
103
+ continue;
104
+ }
105
+ $parent = "App\\Models\\{$field->target}";
106
+ $key = $record->{$field->column()};
107
+ if ($key !== null && class_exists($parent) && method_exists($parent, 'trashes') && $parent::trashes() && $parent::onlyTrashed()->whereKey($key)->exists()) {
108
+ $what = strtolower($field->target);
109
+
110
+ return response()->json(['error' => "Its {$what} is in the trash too. Restore the {$what} first.", 'code' => 'PARENT_IN_TRASH'], 409);
111
+ }
112
+ }
113
+ $record->restore();
114
+
115
+ return response()->json(['id' => (string) $record->getKey(), 'restored' => true]);
116
+ }
117
+
118
+ /** DELETE _nevela/trash/{slug}/{id}: gone for good. */
119
+ public function destroy(string $slug, string $id): JsonResponse|Response
120
+ {
121
+ ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
122
+ $record = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
123
+ if (! Trash::may('forceDelete', $record)) {
124
+ throw new AccessDeniedHttpException("You don't have access to this.");
125
+ }
126
+ if (! Trash::purge($record)) {
127
+ return response()->json(['error' => 'Other records in the trash still belong to this one. Delete those for good first.', 'code' => 'STILL_REFERENCED'], 409);
128
+ }
129
+
130
+ return response()->noContent();
131
+ }
132
+
133
+ /**
134
+ * DELETE _nevela/trash/{slug}?confirm={slug}: everything of one resource, for good.
135
+ * The slug has to be said again, so that this can't be the result of a mistyped address.
136
+ */
137
+ public function empty(Request $request, string $slug): JsonResponse
138
+ {
139
+ ['model' => $model] = $this->resource($slug);
140
+ if ($request->query('confirm') !== $slug) {
141
+ return response()->json(['error' => "To empty this trash, send confirm={$slug}.", 'code' => 'CONFIRM'], 422);
142
+ }
143
+ $removed = 0;
144
+ $kept = 0;
145
+ $instance = new $model;
146
+ $model::onlyTrashed()->orderBy($instance->getQualifiedKeyName())->chunkById(200, function ($records) use (&$removed, &$kept) {
147
+ foreach ($records as $record) {
148
+ if (Trash::may('forceDelete', $record) && Trash::purge($record)) {
149
+ $removed++;
150
+ } else {
151
+ $kept++;
152
+ }
153
+ }
154
+ });
155
+
156
+ return response()->json(['removed' => $removed, 'kept' => $kept]);
157
+ }
158
+
159
+ /** @return array{descriptor: Descriptor, model: class-string<\Illuminate\Database\Eloquent\Model>} */
160
+ private function resource(string $slug): array
161
+ {
162
+ $resource = Trash::resource($slug) ?? throw new NotFoundHttpException('This resource has no trash.');
163
+ if (! Trash::maySee($resource['model'])) {
164
+ throw new AccessDeniedHttpException("You don't have access to this.");
165
+ }
166
+
167
+ return $resource;
168
+ }
169
+
170
+ private function noun(Descriptor $descriptor): string
171
+ {
172
+ return strtolower($descriptor->label);
173
+ }
174
+
175
+ /** What to call a deleted record: its title, the way a list would, and its id when it has none. */
176
+ private static function label(Descriptor $descriptor, mixed $record): string
177
+ {
178
+ // The field the descriptor names, or the first piece of text it has, as the dashboard does.
179
+ $field = $descriptor->titleField !== null ? ($descriptor->fields[$descriptor->titleField] ?? null) : null;
180
+ foreach ($field === null ? $descriptor->fields : [] as $candidate) {
181
+ if ($candidate->kind === 'string') {
182
+ $field = $candidate;
183
+ break;
184
+ }
185
+ }
186
+ $value = $field ? $record->getAttribute($field->column()) : null;
187
+
188
+ return is_scalar($value) && (string) $value !== '' ? (string) $value : (string) $record->getKey();
189
+ }
190
+ }
@@ -3,11 +3,14 @@
3
3
  namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\UniqueConstraintViolationException;
6
7
  use Illuminate\Http\JsonResponse;
7
8
  use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\DB;
8
10
  use InvalidArgumentException;
9
11
  use Nevela\Laravel\Media\Uploads;
10
12
  use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Insights;
11
14
  use Nevela\Laravel\Support\ListQuery;
12
15
  use Nevela\Laravel\Support\Naming;
13
16
 
@@ -18,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
18
21
  final class Nevela
19
22
  {
20
23
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.5.1';
24
+ public const VERSION = '0.5.3';
22
25
 
23
26
  /** @var array<string, Descriptor> */
24
27
  private static array $descriptors = [];
@@ -79,6 +82,35 @@ final class Nevela
79
82
  return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
80
83
  }
81
84
 
85
+ self::narrow($query, $resource, $list);
86
+
87
+ $query->reorder()
88
+ ->orderBy($list->sort['column'], $list->sort['direction'])
89
+ ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
90
+
91
+ $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
92
+
93
+ // One query for every file on the page, instead of one per record.
94
+ foreach ($resource->fields as $field) {
95
+ if ($field->kind === 'file') {
96
+ Uploads::preload($page->getCollection()->pluck($field->column()));
97
+ }
98
+ }
99
+
100
+ return response()->json([
101
+ 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
102
+ 'meta' => [
103
+ 'page' => $page->currentPage(),
104
+ 'perPage' => $page->perPage(),
105
+ 'total' => $page->total(),
106
+ 'totalPages' => max(1, $page->lastPage()),
107
+ ],
108
+ ]);
109
+ }
110
+
111
+ /** The search and the filters of a list request, applied. Its insights are narrowed the same way. */
112
+ private static function narrow(Builder $query, Descriptor $resource, ListQuery $list): void
113
+ {
82
114
  if ($list->q !== null) {
83
115
  $columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
84
116
  if ($columns !== []) {
@@ -96,31 +128,146 @@ final class Nevela
96
128
  ? $query->whereNull($filter['column'])
97
129
  : $query->where($filter['column'], $filter['value']);
98
130
  }
131
+ }
99
132
 
100
- $query->reorder()
101
- ->orderBy($list->sort['column'], $list->sort['direction'])
102
- ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
133
+ /**
134
+ * GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
135
+ *
136
+ * What the dashboard's insights panel draws: how many records were created in each
137
+ * period, and how they split across each field that is a choice (an enum or a yes/no).
138
+ *
139
+ * It takes the list's own search and filters, so the charts describe the rows the
140
+ * table is showing. A whole-table chart above a filtered table would be wrong in a
141
+ * prominent place.
142
+ */
143
+ public static function insights(Builder $query, string $name, Request $request): JsonResponse
144
+ {
145
+ $resource = self::resource($name);
146
+ $unit = $request->query('unit', 'day');
147
+ if (! is_string($unit) || ! isset(Insights::PERIODS[$unit])) {
148
+ return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'unit', 'message' => 'Must be day, week or month.']]], 400);
149
+ }
150
+ // The same parameters as the list. Where in the list someone is makes no difference to a count.
151
+ $params = array_diff_key($request->query(), array_flip(['unit', 'page', 'perPage', 'sort', 'columns']));
152
+ $list = ListQuery::parse($resource, $params, (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
153
+ if (! $list->ok()) {
154
+ return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
155
+ }
156
+ self::narrow($query, $resource, $list);
157
+ $base = fn () => (clone $query)->reorder();
103
158
 
104
- $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
159
+ // Counted per day by the database, one grouped query over the created_at index,
160
+ // and added up into weeks or months here: see Support\Insights.
161
+ $now = now();
162
+ $table = $query->getModel()->getTable();
163
+ $day = $query->getConnection()->getDriverName() === 'sqlsrv' ? "CAST({$table}.created_at AS date)" : "DATE({$table}.created_at)";
164
+ $perDay = $base()->toBase()
165
+ ->where("{$table}.created_at", '>=', Insights::since($unit, $now))
166
+ ->selectRaw("{$day} as day, count(*) as aggregate")
167
+ ->groupByRaw($day)
168
+ ->get()
169
+ ->map(fn ($row) => [(string) $row->day, (int) $row->aggregate]);
105
170
 
106
- // One query for every file on the page, instead of one per record.
171
+ $breakdown = [];
107
172
  foreach ($resource->fields as $field) {
108
- if ($field->kind === 'file') {
109
- Uploads::preload($page->getCollection()->pluck($field->column()));
173
+ if (! in_array($field->kind, ['enum', 'boolean'], true)) {
174
+ continue;
175
+ }
176
+ if (count($breakdown) === 4) {
177
+ break; // one grouped query each, and four charts is already a lot to read
110
178
  }
179
+ $column = $field->column();
180
+ $counts = [];
181
+ foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
182
+ $value = $row->value === null ? '' : ($field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value);
183
+ $counts[$value] = ($counts[$value] ?? 0) + (int) $row->aggregate;
184
+ }
185
+ // Every choice the field has, in its own order, so one nobody has picked shows as zero.
186
+ $values = $field->kind === 'boolean' ? ['true', 'false'] : $field->options;
187
+ $slices = array_map(fn (string $value) => ['value' => $value, 'count' => $counts[$value] ?? 0], $values);
188
+ if (($counts[''] ?? 0) > 0) {
189
+ $slices[] = ['value' => '', 'count' => $counts['']];
190
+ }
191
+ $breakdown[] = ['field' => $field->name, 'kind' => $field->kind, 'slices' => $slices];
111
192
  }
112
193
 
113
194
  return response()->json([
114
- 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
115
- 'meta' => [
116
- 'page' => $page->currentPage(),
117
- 'perPage' => $page->perPage(),
118
- 'total' => $page->total(),
119
- 'totalPages' => max(1, $page->lastPage()),
120
- ],
195
+ 'total' => $base()->count(),
196
+ 'unit' => $unit,
197
+ 'series' => Insights::series($unit, $now, $perDay),
198
+ 'breakdown' => $breakdown,
121
199
  ]);
122
200
  }
123
201
 
202
+ /**
203
+ * POST /{slug}/_bulk { items: [ … ] } → 201 { created, data }, or 422 { error, rows }.
204
+ *
205
+ * Several records in one go, for the dashboard's grid. Every row is checked before
206
+ * anything is written, and they are saved in one transaction: a mistake on row 7
207
+ * is reported against row 7 and nothing is created, where saving six and stopping
208
+ * would leave someone working out which six.
209
+ *
210
+ * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
211
+ * @param class-string<Http\ResourceRequest> $requestClass
212
+ * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
213
+ */
214
+ public static function createMany(string $modelClass, string $requestClass, string $resourceClass, Request $request): JsonResponse
215
+ {
216
+ $most = max(1, (int) config('nevela.bulk_max', 500));
217
+ $items = $request->input('items');
218
+ if (! is_array($items) || ! array_is_list($items) || $items === []) {
219
+ return response()->json(['error' => 'Send the rows to create as "items", a list with at least one row.'], 422);
220
+ }
221
+ if (count($items) > $most) {
222
+ return response()->json(['error' => 'That is '.number_format(count($items))." rows. At most {$most} can be created at once; for more, import a file."], 422);
223
+ }
224
+
225
+ $checked = (new $requestClass)->many($items);
226
+ if ($checked['problems'] !== []) {
227
+ return self::rowsRefused($checked['problems'], count($items));
228
+ }
229
+
230
+ try {
231
+ $created = DB::transaction(function () use ($modelClass, $checked) {
232
+ $created = [];
233
+ foreach ($checked['values'] as $index => $values) {
234
+ try {
235
+ $created[] = $modelClass::create($values);
236
+ } catch (UniqueConstraintViolationException $e) {
237
+ // Someone else created the same value between the check and the write.
238
+ throw new \RuntimeException((string) ($index + 1), 0, $e);
239
+ }
240
+ }
241
+
242
+ return $created;
243
+ });
244
+ } catch (\RuntimeException $e) {
245
+ if (! $e->getPrevious() instanceof UniqueConstraintViolationException) {
246
+ throw $e;
247
+ }
248
+
249
+ return self::rowsRefused([['row' => (int) $e->getMessage(), 'issues' => [['path' => '', 'message' => 'A record with one of these values was created a moment ago.']]]], count($items));
250
+ }
251
+
252
+ return response()->json([
253
+ 'created' => count($created),
254
+ 'data' => array_map(fn ($model) => (new $resourceClass($model->refresh()))->resolve($request), $created),
255
+ ], 201);
256
+ }
257
+
258
+ /** @param list<array{row: int, issues: list<array{path: string, message: string}>}> $problems */
259
+ private static function rowsRefused(array $problems, int $sent): JsonResponse
260
+ {
261
+ $wrong = count($problems);
262
+
263
+ return response()->json([
264
+ 'error' => $wrong === 1
265
+ ? "Nothing was created: row {$problems[0]['row']} needs fixing."
266
+ : "Nothing was created: {$wrong} of the {$sent} rows need fixing.",
267
+ 'rows' => $problems,
268
+ ], 422);
269
+ }
270
+
124
271
  /**
125
272
  * GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
126
273
  * What Flare's ResourceStats card shows above a table.
@@ -4,6 +4,7 @@ namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Contracts\Debug\ExceptionHandler;
6
6
  use Illuminate\Cache\RateLimiting\Limit;
7
+ use Illuminate\Console\Scheduling\Schedule;
7
8
  use Illuminate\Foundation\Console\ServeCommand;
8
9
  use Illuminate\Http\Request;
9
10
  use Illuminate\Support\Facades\Gate;
@@ -18,6 +19,7 @@ use Nevela\Laravel\Console\MakeResourceCommand;
18
19
  use Nevela\Laravel\Console\SeedCommand;
19
20
  use Nevela\Laravel\Console\SetupCommand;
20
21
  use Nevela\Laravel\Console\StatusCommand;
22
+ use Nevela\Laravel\Console\TrashCommand;
21
23
  use Nevela\Laravel\Console\UpdateCommand;
22
24
  use Nevela\Laravel\Console\UserCommand;
23
25
  use Nevela\Laravel\Console\VersionCommand;
@@ -29,6 +31,7 @@ use Nevela\Laravel\Http\Auth\AccountController;
29
31
  use Nevela\Laravel\Http\Auth\SecurityController;
30
32
  use Nevela\Laravel\Http\Auth\SignInController;
31
33
  use Nevela\Laravel\Http\TokenController;
34
+ use Nevela\Laravel\Http\TrashController;
32
35
  use Nevela\Laravel\Http\UploadController;
33
36
  use Nevela\Laravel\Media\Uploads;
34
37
 
@@ -56,7 +59,15 @@ final class NevelaServiceProvider extends ServiceProvider
56
59
  $this->commands([
57
60
  MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
58
61
  UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
62
+ TrashCommand::class,
59
63
  ]);
64
+
65
+ // What has been in the trash too long is removed each night, wherever the app's
66
+ // scheduler is running (Laravel's one cron line). Where it isn't, the same thing
67
+ // happens whenever someone opens the trash.
68
+ $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
69
+ $schedule->command(TrashCommand::class)->dailyAt('03:40')->withoutOverlapping();
70
+ });
60
71
  }
61
72
 
62
73
  $this->callAfterResolving(ExceptionHandler::class, function ($handler) {
@@ -171,6 +182,14 @@ final class NevelaServiceProvider extends ServiceProvider
171
182
  'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
172
183
  ]))->name('profiles');
173
184
 
185
+ // The trash: deleted records, to restore or to remove for good.
186
+ Route::get('_nevela/trash', [TrashController::class, 'index'])->name('trash.index');
187
+ Route::get('_nevela/trash/_status', [TrashController::class, 'status'])->name('trash.status');
188
+ Route::get('_nevela/trash/{slug}', [TrashController::class, 'show'])->name('trash.show');
189
+ Route::post('_nevela/trash/{slug}/{id}/restore', [TrashController::class, 'restore'])->name('trash.restore');
190
+ Route::delete('_nevela/trash/{slug}/{id}', [TrashController::class, 'destroy'])->name('trash.destroy');
191
+ Route::delete('_nevela/trash/{slug}', [TrashController::class, 'empty'])->name('trash.empty');
192
+
174
193
  // Users and roles, for whoever holds the permissions to manage them.
175
194
  Route::middleware(RolesAreSetUp::class)->group(function () {
176
195
  Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
@@ -0,0 +1,78 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Carbon\CarbonImmutable;
6
+ use Carbon\CarbonInterface;
7
+
8
+ /**
9
+ * The periods a "created per day, week or month" chart is drawn in.
10
+ *
11
+ * The database is only ever asked for counts per day, which every database can give
12
+ * with the same expression. Weeks and months are added up here, so a week starts on
13
+ * Monday and a month is a calendar month whichever database is underneath.
14
+ */
15
+ final class Insights
16
+ {
17
+ /** How many periods of each kind a chart shows: a month of days, half a year of weeks, a year of months. */
18
+ public const PERIODS = ['day' => 30, 'week' => 26, 'month' => 12];
19
+
20
+ /**
21
+ * The periods up to and including the one `$now` is in, oldest first.
22
+ *
23
+ * @return list<string> A day or a week as "2026-10-05" (a week by its Monday), a month as "2026-10"
24
+ */
25
+ public static function buckets(string $unit, CarbonInterface $now): array
26
+ {
27
+ $current = CarbonImmutable::instance($now)->startOfDay();
28
+ $buckets = [];
29
+ for ($back = self::PERIODS[$unit] - 1; $back >= 0; $back--) {
30
+ $buckets[] = match ($unit) {
31
+ 'day' => $current->subDays($back)->format('Y-m-d'),
32
+ 'week' => $current->startOfWeek(CarbonInterface::MONDAY)->subWeeks($back)->format('Y-m-d'),
33
+ // From the first of the month: stepping back from the 31st would skip the short months.
34
+ 'month' => $current->startOfMonth()->subMonthsNoOverflow($back)->format('Y-m'),
35
+ };
36
+ }
37
+
38
+ return $buckets;
39
+ }
40
+
41
+ /** The first moment the chart covers. */
42
+ public static function since(string $unit, CarbonInterface $now): CarbonImmutable
43
+ {
44
+ $first = self::buckets($unit, $now)[0];
45
+
46
+ return CarbonImmutable::parse($unit === 'month' ? "{$first}-01" : $first, $now->getTimezone())->startOfDay();
47
+ }
48
+
49
+ /** Which period a day ("2026-10-09") belongs to. */
50
+ public static function bucketOf(string $unit, string $day): string
51
+ {
52
+ return match ($unit) {
53
+ 'day' => substr($day, 0, 10),
54
+ 'week' => CarbonImmutable::parse(substr($day, 0, 10))->startOfWeek(CarbonInterface::MONDAY)->format('Y-m-d'),
55
+ 'month' => substr($day, 0, 7),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Counts per day, added up into the chart's periods. A period with nothing in it is
61
+ * still there, as zero: a gap in a chart should look like a gap.
62
+ *
63
+ * @param iterable<array{0: string, 1: int}> $perDay Pairs of day and count
64
+ * @return list<array{bucket: string, count: int}>
65
+ */
66
+ public static function series(string $unit, CarbonInterface $now, iterable $perDay): array
67
+ {
68
+ $counts = array_fill_keys(self::buckets($unit, $now), 0);
69
+ foreach ($perDay as [$day, $count]) {
70
+ $bucket = self::bucketOf($unit, $day);
71
+ if (isset($counts[$bucket])) {
72
+ $counts[$bucket] += $count;
73
+ }
74
+ }
75
+
76
+ return array_map(fn (string $bucket, int $count) => ['bucket' => $bucket, 'count' => $count], array_keys($counts), array_values($counts));
77
+ }
78
+ }