create-nevela 0.7.1 → 0.8.0

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 (50) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Concerns/Trashable.php +23 -0
  7. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  8. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  9. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  10. package/template/laravel/src/Console/SeedCommand.php +3 -1
  11. package/template/laravel/src/Console/TrashCommand.php +8 -0
  12. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  13. package/template/laravel/src/Http/ActionController.php +222 -0
  14. package/template/laravel/src/Http/PublicController.php +210 -0
  15. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  16. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  17. package/template/laravel/src/Http/TrashController.php +18 -2
  18. package/template/laravel/src/Http/UploadController.php +42 -1
  19. package/template/laravel/src/Media/Uploads.php +111 -0
  20. package/template/laravel/src/Nevela.php +54 -2
  21. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  22. package/template/laravel/src/Support/Action.php +168 -0
  23. package/template/laravel/src/Support/Adoption.php +352 -0
  24. package/template/laravel/src/Support/Descriptor.php +59 -4
  25. package/template/laravel/src/Support/Fake.php +15 -1
  26. package/template/laravel/src/Support/Field.php +74 -3
  27. package/template/laravel/src/Support/Html.php +185 -0
  28. package/template/laravel/src/Support/PublicAccess.php +133 -0
  29. package/template/web/app/api/[...path]/route.ts +42 -9
  30. package/template/web/app/dashboard/actions.ts +34 -0
  31. package/template/web/app/globals.css +46 -0
  32. package/template/web/components/access/role-form.tsx +40 -5
  33. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  34. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  35. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  36. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  37. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  38. package/template/web/components/dashboard/record-actions.tsx +7 -2
  39. package/template/web/components/dashboard/record-detail.tsx +9 -2
  40. package/template/web/components/dashboard/resource-action.tsx +281 -0
  41. package/template/web/components/dashboard/resource-table.tsx +15 -2
  42. package/template/web/components/dashboard/row-actions.tsx +24 -0
  43. package/template/web/components/dashboard/table-selection.tsx +19 -1
  44. package/template/web/lib/access.ts +3 -1
  45. package/template/web/lib/dashboard.ts +10 -0
  46. package/template/web/lib/extras.ts +66 -0
  47. package/template/web/lib/files.ts +14 -0
  48. package/template/web/lib/laravel.ts +60 -0
  49. package/template/web/lib/rich.ts +79 -0
  50. package/template/web/package.json +1 -1
package/index.mjs CHANGED
@@ -75,6 +75,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
75
75
  nevela dev Run the API and the dashboard
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
+ nevela adopt <table> Make a resource of a table you already have
78
79
  nevela seed | user | generate | migrate | trash | artisan <command>
79
80
 
80
81
  ${bold('Options')}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user', 'trash'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', 'generate', 'seed', 'user', 'trash'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -131,6 +131,19 @@ return [
131
131
  'days' => 30,
132
132
  ],
133
133
 
134
+ /*
135
+ | Rich text fields (body:richtext). Whatever is sent is parsed and written out again
136
+ | with only these tags and attributes: nothing the editor, a script or a visitor sends
137
+ | is trusted. A tag that isn't listed goes and its words stay. The list matches the
138
+ | dashboard editor's toolbar; add to it only what you would let a stranger put on a page.
139
+ | A link may use these schemes, or none (/about).
140
+ */
141
+ 'rich_text' => [
142
+ 'tags' => ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'],
143
+ 'attributes' => ['a' => ['href']],
144
+ 'schemes' => ['http', 'https', 'mailto'],
145
+ ],
146
+
134
147
  /*
135
148
  | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
136
149
  | grid). They are saved in one transaction, so this is also how long one can run.
@@ -142,14 +155,23 @@ return [
142
155
  |
143
156
  | Files go on a disk from config/filesystems.php and are handed out by Nevela's own
144
157
  | route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
145
- | are unguessable, but anyone who has one can fetch the file: don't use these fields
146
- | for documents that must stay private. If a CDN or your web server serves the disk,
147
- | put its address in NEVELA_UPLOADS_URL and the API's links point there.
158
+ | are unguessable, but anyone who has one can fetch the file. If a CDN or your web
159
+ | server serves the disk, put its address in NEVELA_UPLOADS_URL and the API's links
160
+ | point there.
161
+ |
162
+ | A document that must stay private goes in a private file field ("private": true in
163
+ | the descriptor, or cv:file(pdf|private)). It is kept on private_disk, which must not
164
+ | be one the web server serves, and is handed out by
165
+ | GET {prefix}/_nevela/private/{slug}/{id}/{field} to someone signed in who may view
166
+ | that record. Its key opens nothing.
148
167
  */
149
168
  'uploads' => [
150
169
  'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
151
170
  'url' => env('NEVELA_UPLOADS_URL'),
152
171
 
172
+ /* Where private files are kept. Never served: not "public", and not a bucket anyone can read. */
173
+ 'private_disk' => env('NEVELA_PRIVATE_DISK', 'local'),
174
+
153
175
  /* Where untouched originals are kept, so an image can be optimised again later. Never served. */
154
176
  'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
155
177
 
@@ -29,6 +29,14 @@ final class Permissions
29
29
  /** @var list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>|null */
30
30
  private static ?array $catalog = null;
31
31
 
32
+ /**
33
+ * The permissions that are a resource's own actions, such as "internship_applications.accept".
34
+ * They are the only ones outside the four, and are only ever given by name.
35
+ *
36
+ * @var array<string, true>
37
+ */
38
+ private static array $own = [];
39
+
32
40
  /**
33
41
  * The catalog, shaped for the roles screen: modules, their groups, their features.
34
42
  * Only the feature and the action appear in a permission; the rest is for reading.
@@ -41,6 +49,7 @@ final class Permissions
41
49
  return self::$catalog;
42
50
  }
43
51
 
52
+ self::$own = [];
44
53
  $taken = ['users' => true, 'roles' => true];
45
54
  $modules = [[
46
55
  'key' => 'access',
@@ -64,7 +73,14 @@ final class Permissions
64
73
  $taken[$descriptor->table] = true;
65
74
  $heading = trim((string) ($descriptor->group ?? '')) ?: 'Resources';
66
75
  $groups[$heading] ??= ['key' => self::slug($heading), 'name' => $heading, 'features' => []];
67
- $groups[$heading]['features'][] = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
76
+ $feature = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
77
+ // The resource's own actions, after the four: one permission each, with the name its button has.
78
+ foreach ($descriptor->actions as $action) {
79
+ $feature['actions'][] = $action->key();
80
+ $feature['labels'][$action->key()] = $action->label;
81
+ self::$own["{$descriptor->table}.{$action->key()}"] = true;
82
+ }
83
+ $groups[$heading]['features'][] = $feature;
68
84
  }
69
85
  if ($groups !== []) {
70
86
  $modules[] = ['key' => 'resources', 'name' => 'Resources', 'groups' => array_values($groups)];
@@ -92,6 +108,7 @@ final class Permissions
92
108
  public static function forget(): void
93
109
  {
94
110
  self::$catalog = null;
111
+ self::$own = [];
95
112
  }
96
113
 
97
114
  /**
@@ -119,7 +136,16 @@ final class Permissions
119
136
  /** Whether `ability` has the shape of a permission, so the gate answers for it. */
120
137
  public static function isPermission(string $ability): bool
121
138
  {
122
- return (bool) preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability);
139
+ if (preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability)) {
140
+ return true;
141
+ }
142
+ // Or one of a resource's own actions. Anything else with a dot in it is the app's to define.
143
+ if (! str_contains($ability, '.')) {
144
+ return false;
145
+ }
146
+ self::catalog();
147
+
148
+ return isset(self::$own[$ability]);
123
149
  }
124
150
 
125
151
  /**
@@ -144,6 +170,11 @@ final class Permissions
144
170
  * A pattern is checked to its end: "products.*" covers "products.view", and "*.view"
145
171
  * covers "products.view" and nothing else of products. Stopping at the first "*"
146
172
  * would turn "*.view" into "everything".
173
+ *
174
+ * In the second half, "*" is the four things every feature has: create, view, edit and
175
+ * delete. A resource's own action, such as accepting an application, is covered only
176
+ * by its own name or by "*" alone. Someone given "everything to do with the records"
177
+ * has not thereby been given the button that sends a letter.
147
178
  */
148
179
  public static function matches(string $pattern, string $key): bool
149
180
  {
@@ -165,7 +196,7 @@ final class Permissions
165
196
  return false;
166
197
  }
167
198
 
168
- return $action === '*' || $action === $k[1];
199
+ return $action === '*' ? in_array($k[1], self::ACTIONS, true) : $action === $k[1];
169
200
  }
170
201
 
171
202
  /**
@@ -236,10 +267,10 @@ final class Permissions
236
267
  if ($grant === '*') {
237
268
  return true;
238
269
  }
239
- if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|create|view|edit|delete)$/', $grant)) {
270
+ if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|[a-z][a-z0-9_]*)$/', $grant, $m)) {
240
271
  return false;
241
272
  }
242
- if (str_starts_with($grant, '@')) {
273
+ if (str_starts_with($grant, '@') && in_array($m[2], ['*', ...self::ACTIONS], true)) {
243
274
  return in_array(substr(explode('.', $grant)[0], 1), ['access', 'resources', 'app'], true);
244
275
  }
245
276
 
@@ -4,6 +4,9 @@ namespace Nevela\Laravel\Concerns;
4
4
 
5
5
  use Illuminate\Database\Eloquent\SoftDeletes;
6
6
  use Illuminate\Support\Facades\Schema;
7
+ use InvalidArgumentException;
8
+ use Nevela\Laravel\Media\Uploads;
9
+ use Nevela\Laravel\Nevela;
7
10
  use Nevela\Laravel\Trash\TrashedScope;
8
11
 
9
12
  /**
@@ -59,5 +62,25 @@ trait Trashable
59
62
  $model->forceDeleting = true;
60
63
  }
61
64
  });
65
+
66
+ // A private file goes when its record goes for good. In the trash the record still
67
+ // exists, and so does its file, unreachable, for the day it is restored.
68
+ static::deleted(function (self $model) {
69
+ if ($model->exists) {
70
+ return;
71
+ }
72
+ try {
73
+ $resource = Nevela::resource(class_basename($model));
74
+ } catch (InvalidArgumentException) {
75
+ return; // a model that uses the trash without being a resource
76
+ }
77
+ foreach ($resource->fields as $field) {
78
+ $key = $field->kind === 'file' && $field->private ? $model->getAttribute($field->column()) : null;
79
+ // Not while another record of the table still points at the same file.
80
+ if (is_string($key) && $key !== '' && ! $model->newQueryWithoutScopes()->where($field->column(), $key)->exists()) {
81
+ Uploads::discard($key);
82
+ }
83
+ }
84
+ });
62
85
  }
63
86
  }
@@ -0,0 +1,139 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use InvalidArgumentException;
7
+ use Nevela\Laravel\Generator\GeneratedFile;
8
+ use Nevela\Laravel\Generator\ResourceGenerator;
9
+ use Nevela\Laravel\Generator\Writer;
10
+ use Nevela\Laravel\Nevela;
11
+ use Nevela\Laravel\Support\Adoption;
12
+ use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Naming;
14
+
15
+ final class AdoptCommand extends Command
16
+ {
17
+ protected $signature = 'nevela:adopt
18
+ {table : The table to make a resource of, e.g. posts}
19
+ {--name= : The resource\'s name, when it isn\'t the table\'s own (posts is Post)}
20
+ {--icon= : Lucide icon name for the dashboard, e.g. newspaper}
21
+ {--group= : Sidebar group in the dashboard}
22
+ {--yes : Write it without asking}
23
+ {--force : Replace the descriptor this table already has}';
24
+
25
+ protected $description = 'Make a resource of a table you already have: its descriptor is read from the table, which is left as it is';
26
+
27
+ public function handle(): int
28
+ {
29
+ $table = (string) $this->argument('table');
30
+ if (str_starts_with($table, 'nevela_') || $table === 'migrations') {
31
+ $this->components->error("{$table} is a table Nevela or Laravel keeps for itself.");
32
+
33
+ return self::FAILURE;
34
+ }
35
+
36
+ Nevela::forget();
37
+ try {
38
+ $all = Nevela::all();
39
+ // The descriptor this table has already, if it is being adopted again.
40
+ $others = array_values(array_filter($all, fn (Descriptor $d) => $d->table !== $table));
41
+ $existing = array_values(array_filter($all, fn (Descriptor $d) => $d->table === $table))[0] ?? null;
42
+ if ($existing !== null && ! $this->option('force')) {
43
+ $this->components->error("{$table} is a resource already. Edit its descriptor and run: php artisan nevela:generate. To read it from the table again, add --force.");
44
+
45
+ return self::FAILURE;
46
+ }
47
+ $name = $this->option('name');
48
+ // Read again, it stays the resource it is. Under another name the first descriptor
49
+ // would still be there, and the table would be two resources.
50
+ if ($existing !== null && $name !== null && Naming::pascal($name) !== $existing->name) {
51
+ $this->components->error("{$table} is the {$existing->name} resource, and --force reads it again under that name. To call it something else, remove nevela/resources/".Naming::kebab($existing->name).'.json and the code generated for it, then adopt the table again.');
52
+
53
+ return self::FAILURE;
54
+ }
55
+ $adoption = Adoption::of($table, $others, $existing?->name ?? $name, $this->option('icon') ?? $existing?->icon, $this->option('group') ?? $existing?->group);
56
+ } catch (InvalidArgumentException $e) {
57
+ $this->components->error($e->getMessage());
58
+
59
+ return self::FAILURE;
60
+ }
61
+ $descriptor = $adoption->descriptor;
62
+
63
+ $dir = rtrim(config('nevela.descriptors_path'), '/\\');
64
+ $path = $dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json';
65
+ foreach ($others as $other) {
66
+ if ($other->name === $descriptor->name) {
67
+ $this->components->error("There is a {$descriptor->name} already, on the {$other->table} table. Give this one another name: --name=…");
68
+
69
+ return self::FAILURE;
70
+ }
71
+ }
72
+ if ($taken = $this->ownFiles($descriptor, [...$others, $descriptor])) {
73
+ $this->components->error("The name {$descriptor->name} is taken by code of your own, which Nevela won't write over:");
74
+ $this->components->bulletList($taken);
75
+ $this->line(" Adopt the table under another name (<fg=cyan>--name=…</>), or move those files aside first.");
76
+
77
+ return self::FAILURE;
78
+ }
79
+
80
+ $this->components->info("{$table} as {$descriptor->name}, with ".($descriptor->idType === 'int' ? 'integer' : 'UUID').' ids');
81
+ foreach ($adoption->fields as $column => $field) {
82
+ $this->components->twoColumnDetail($column, "{$field->name}:".Adoption::spec($field));
83
+ }
84
+ foreach ($adoption->left as $left) {
85
+ $this->components->twoColumnDetail($left['column'], "<fg=gray>left alone: {$left['why']}</>");
86
+ }
87
+ if ($adoption->notes !== []) {
88
+ $this->newLine();
89
+ $this->components->bulletList($adoption->notes);
90
+ }
91
+
92
+ if (! $this->option('yes') && ! $this->components->confirm("Make {$descriptor->name} a resource? The table is not changed", true)) {
93
+ $this->components->info('Nothing was written.');
94
+
95
+ return self::SUCCESS;
96
+ }
97
+
98
+ if (! is_dir($dir)) {
99
+ mkdir($dir, 0775, true);
100
+ }
101
+ file_put_contents($path, $descriptor->toJson());
102
+ $this->components->info("Described {$descriptor->name} in ".str_replace(base_path().DIRECTORY_SEPARATOR, '', $path));
103
+
104
+ Nevela::forget();
105
+ $status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
106
+
107
+ $this->newLine();
108
+ $this->line(' Next: <fg=cyan>php artisan migrate</> gives the table a trash (a deleted_at column), then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
109
+ $this->line(' Every column came in as the plainest type that holds it. Where one is an email, a slug or a choice, say so in the descriptor and run <fg=cyan>php artisan nevela:generate</>.');
110
+
111
+ return $status;
112
+ }
113
+
114
+ /**
115
+ * Files this resource would be written to that are there already and aren't Nevela's:
116
+ * a model of the same name, most often. Their generated block has nowhere to go, and a
117
+ * resource whose model lacks it only half works.
118
+ *
119
+ * @param list<Descriptor> $all
120
+ * @return list<string>
121
+ */
122
+ private function ownFiles(Descriptor $descriptor, array $all): array
123
+ {
124
+ $roots = [GeneratedFile::TARGET_API => base_path()];
125
+ if (($web = config('nevela.web_path')) && is_dir($web)) {
126
+ $roots[GeneratedFile::TARGET_WEB] = $web;
127
+ }
128
+ $taken = [];
129
+ foreach ((new ResourceGenerator)->forResource($descriptor, null, $all) as $file) {
130
+ $root = $roots[$file->target] ?? null;
131
+ $absolute = $root === null ? null : rtrim($root, '/\\').DIRECTORY_SEPARATOR.$file->path;
132
+ if ($file->mode === GeneratedFile::MODE_BLOCK && $absolute !== null && is_file($absolute) && Writer::block((string) file_get_contents($absolute)) === null) {
133
+ $taken[] = $file->path;
134
+ }
135
+ }
136
+
137
+ return $taken;
138
+ }
139
+ }
@@ -28,10 +28,11 @@ final class GenerateCommand extends Command
28
28
  * @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
29
29
  * @param bool $written Whether an add_trash_to_… migration exists for the table already
30
30
  * @param bool $columnExists Whether the table has deleted_at in the database now
31
+ * @param bool $adopted Whether the table was there before Nevela: then there may be no migration of that name to read
31
32
  */
32
- public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists): bool
33
+ public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists, bool $adopted = false): bool
33
34
  {
34
- if ($createMigrations === []) {
35
+ if ($createMigrations === [] && ! $adopted) {
35
36
  return false;
36
37
  }
37
38
  foreach ($createMigrations as $contents) {
@@ -127,11 +128,12 @@ final class GenerateCommand extends Command
127
128
  // resource's own migration, written by the line above, already has the column.
128
129
  $made = array_map(fn (string $file) => (string) file_get_contents($file), glob(database_path("migrations/*_create_{$descriptor->table}_table.php")) ?: []);
129
130
  $written = (glob(database_path("migrations/*_add_trash_to_{$descriptor->table}_table.php")) ?: []) !== [];
130
- if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table))) {
131
+ if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table), $descriptor->adopted)) {
131
132
  $files[] = $generator->trashMigrationFile($descriptor);
132
133
  }
133
134
  }
134
135
  $files[] = $generator->routes($all);
136
+ $files[] = $generator->publicRoutes($all);
135
137
  $files[] = $generator->registry($all);
136
138
  $files[] = $generator->authConfig((array) config('nevela.auth', []));
137
139
 
@@ -168,6 +170,14 @@ final class GenerateCommand extends Command
168
170
  if ($edited) {
169
171
  $command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
170
172
  }
173
+ // A model written before its resource was made public has no scope saying which
174
+ // records are, and without one none are. Said, so an empty public list isn't a puzzle.
175
+ foreach ($queue as $descriptor) {
176
+ $model = base_path("app/Models/{$descriptor->name}.php");
177
+ if ($descriptor->public?->read !== null && is_file($model) && ! str_contains((string) file_get_contents($model), 'scopePublicQuery')) {
178
+ $command->components->warn("{$descriptor->name} can be read publicly, but its model doesn't say which records may be, so none are. Add to app/Models/{$descriptor->name}.php: public function scopePublicQuery(\\Illuminate\\Database\\Eloquent\\Builder \$query): void { … }");
179
+ }
180
+ }
171
181
 
172
182
  return self::SUCCESS;
173
183
  }
@@ -15,6 +15,7 @@ final class MakeResourceCommand extends Command
15
15
  {--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
16
16
  {--icon= : Lucide icon name for the dashboard, e.g. package}
17
17
  {--group= : Sidebar group in the dashboard}
18
+ {--id=uuid : What a record\'s id is: uuid, or int for the numbered ids of $table->id()}
18
19
  {--force : Overwrite an existing descriptor}
19
20
  {--migrate : Run the new migration straight away}
20
21
  {--seed= : Fill the new resource with records too (25, or the number you give). Runs the migration first}';
@@ -29,6 +30,7 @@ final class MakeResourceCommand extends Command
29
30
  (string) ($this->option('fields') ?? $this->ask('Fields (e.g. name:string, price:money, active:boolean)')),
30
31
  $this->option('icon'),
31
32
  $this->option('group'),
33
+ (string) $this->option('id'),
32
34
  );
33
35
  } catch (InvalidArgumentException $e) {
34
36
  $this->components->error($e->getMessage());
@@ -175,7 +175,9 @@ final class SeedCommand extends Command
175
175
  for ($i = $done; $i < min($count, $done + $chunk); $i++) {
176
176
  // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
177
177
  $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
178
- $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
178
+ // An integer id is the database's to give.
179
+ $id = $resource->idType === 'int' ? [] : ['id' => (string) Str::uuid7()];
180
+ $rows[] = $id + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
179
181
  }
180
182
  DB::table($resource->table)->insert($rows);
181
183
  $bar->advance(count($rows));
@@ -3,6 +3,7 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
+ use Nevela\Laravel\Media\Uploads;
6
7
  use Nevela\Laravel\Trash\Trash;
7
8
 
8
9
  final class TrashCommand extends Command
@@ -13,6 +14,13 @@ final class TrashCommand extends Command
13
14
 
14
15
  public function handle(): int
15
16
  {
17
+ // Files visitors uploaded for a form they never sent. Nothing to do with the trash,
18
+ // but the same kind of job, on the same night.
19
+ $unclaimed = Uploads::purgeUnclaimed();
20
+ if ($unclaimed > 0) {
21
+ $this->components->info('Removed '.number_format($unclaimed).' file(s) uploaded through a public form and never used.');
22
+ }
23
+
16
24
  $days = Trash::days();
17
25
  if ($days === null) {
18
26
  $this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');