diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 87f2a19..cb94715 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -112,7 +112,7 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ] + $this->detailRules()); + ] + $this->detailRules(Auth::id())); foreach (array_keys(Product::BADGES) as $badge) { $validated[$badge] = $request->boolean($badge); @@ -150,7 +150,7 @@ class ProductController extends Controller abort(404); } - $product->load(['user', 'category', 'tags', 'comments.user', 'upvotes', 'bookmarks']); + $product->load(['user', 'category', 'tags', 'members', 'comments.user', 'upvotes', 'bookmarks']); return view('products.show', compact('product')); } @@ -182,7 +182,7 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ] + $this->detailRules()); + ] + $this->detailRules($product->user_id)); // Badges are locked for non-moderators once the product is approved if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) { @@ -218,7 +218,7 @@ class ProductController extends Controller /** * Validation rules for the product detail fields. */ - protected function detailRules(): array + protected function detailRules(int $ownerId): array { return [ 'free_tier' => 'boolean', @@ -234,6 +234,9 @@ class ProductController extends Controller 'plans.*.price' => 'required|numeric|min:0|max:99999', 'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)], 'plans.*.description' => 'nullable|string|max:255', + 'members' => 'nullable|array', + 'members.*.user_id' => ['required', 'distinct', 'exists:users,id', Rule::notIn([$ownerId])], + 'members.*.role' => 'required|string|max:100', ]; } @@ -254,6 +257,13 @@ class ProductController extends Controller ]); } + // The submitted member list replaces all existing members + $members = []; + foreach ((array) $request->input('members', []) as $member) { + $members[$member['user_id']] = ['role' => $member['role']]; + } + $product->members()->sync($members); + // Only rows belonging to this product may be removed $ids = array_filter((array) $request->input('remove_screenshots', [])); if ($ids) { diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php new file mode 100644 index 0000000..b1ec877 --- /dev/null +++ b/app/Http/Controllers/UserSearchController.php @@ -0,0 +1,34 @@ +query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json([]); + } + + $escaped = addcslashes($q, '\\%_'); + + return response()->json( + User::query() + ->where('name', 'like', "%{$escaped}%") + ->orderBy('name') + ->limit(10) + ->get(['id', 'name', 'slug']) + ->map(fn (User $u) => ['id' => $u->id, 'name' => $u->name, 'slug' => $u->slug]) + ->values() + ); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 30894fa..dd279e0 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -138,6 +138,17 @@ class Product extends Model return $this->hasMany(PricingPlan::class)->orderBy('position')->orderBy('id'); } + /** + * Get the team members of the product (besides the submitter). + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'product_members') + ->withPivot('role') + ->withTimestamps() + ->orderBy('product_members.id'); + } + /** * Get the user that owns the product. */ diff --git a/database/migrations/2026_10_17_130000_create_product_members_table.php b/database/migrations/2026_10_17_130000_create_product_members_table.php new file mode 100644 index 0000000..f697a43 --- /dev/null +++ b/database/migrations/2026_10_17_130000_create_product_members_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role', 100); + $table->timestamps(); + + $table->unique(['product_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_members'); + } +}; diff --git a/resources/views/components/product/team-form.blade.php b/resources/views/components/product/team-form.blade.php new file mode 100644 index 0000000..e8ca9ac --- /dev/null +++ b/resources/views/components/product/team-form.blade.php @@ -0,0 +1,60 @@ +@props(['members' => []]) + +@php + $initial = collect(old('members', collect($members)->map(fn ($m) => [ + 'user_id' => $m->id, + 'name' => $m->name, + 'role' => $m->pivot->role, + ])->all()))->map(fn ($m) => [ + 'user_id' => $m['user_id'] ?? '', + 'name' => $m['name'] ?? ('User #' . ($m['user_id'] ?? '')), + 'role' => $m['role'] ?? '', + ])->values()->all(); + $input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm'; +@endphp + + +
{{ $messages[0] }}
+ @endforeach + @error('members') +{{ $message }}
+ @enderror +