diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 8eec78a..b52b24c 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\Product; +use App\Models\Tag; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; @@ -84,7 +85,8 @@ class ProductController extends Controller public function create() { $categories = Category::all(); - return view('products.create', compact('categories')); + $tags = Tag::all(); + return view('products.create', compact('categories', 'tags')); } /** @@ -100,6 +102,8 @@ class ProductController extends Controller 'website_url' => 'required|url', 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'category_id' => 'required|exists:categories,id', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $validated['user_id'] = Auth::id(); @@ -113,6 +117,11 @@ class ProductController extends Controller $product = Product::create($validated); + // Attach tags if provided + if ($request->has('tags')) { + $product->tags()->attach($request->tags); + } + return redirect()->route('products.show', $product) ->with('success', 'Product submitted successfully! It will be reviewed by our moderators.'); } @@ -140,7 +149,8 @@ class ProductController extends Controller Gate::authorize('edit-product', $product); $categories = Category::all(); - return view('products.edit', compact('product', 'categories')); + $tags = Tag::all(); + return view('products.edit', compact('product', 'categories', 'tags')); } /** @@ -156,6 +166,8 @@ class ProductController extends Controller 'website_url' => 'required|url', 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'category_id' => 'required|exists:categories,id', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); // Handle thumbnail upload @@ -171,6 +183,9 @@ class ProductController extends Controller $product->update($validated); + // Sync tags (will remove old tags and add new ones) + $product->tags()->sync($request->tags ?? []); + return redirect()->route('products.show', $product) ->with('success', 'Product updated successfully!'); } diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php new file mode 100644 index 0000000..de2826e --- /dev/null +++ b/app/Http/Controllers/TagController.php @@ -0,0 +1,106 @@ +filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + $tags = $query->latest()->paginate(20); + + return view('tags.index', compact('tags')); + } + + /** + * Show the form for creating a new tag. + */ + public function create() + { + return view('tags.create'); + } + + /** + * Store a newly created tag. + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:tags,name'], + 'color' => ['required', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'description' => ['nullable', 'string', 'max:1000'], + ]); + + // Generate unique slug + $validated['slug'] = Tag::generateUniqueSlug($validated['name']); + + Tag::create($validated); + + return redirect()->route('tags.index') + ->with('success', 'Tag created successfully.'); + } + + /** + * Show the form for editing a tag. + */ + public function edit(Tag $tag) + { + return view('tags.edit', compact('tag')); + } + + /** + * Update the specified tag. + */ + public function update(Request $request, Tag $tag) + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', Rule::unique('tags')->ignore($tag->id)], + 'color' => ['required', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'description' => ['nullable', 'string', 'max:1000'], + ]); + + // Regenerate slug if name changed + if ($tag->name !== $validated['name']) { + $validated['slug'] = Tag::generateUniqueSlug($validated['name'], $tag->id); + } + + $tag->update($validated); + + return redirect()->route('tags.index') + ->with('success', 'Tag updated successfully.'); + } + + /** + * Remove the specified tag. + */ + public function destroy(Tag $tag) + { + // Check if tag has products + if ($tag->products()->count() > 0) { + return redirect()->route('tags.index') + ->with('error', "Cannot delete tag '{$tag->name}' because it is used by {$tag->products()->count()} products. Please remove it from those products first."); + } + + $tag->delete(); + + return redirect()->route('tags.index') + ->with('success', 'Tag deleted successfully.'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index f8a66b4..fd34885 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Str; class Product extends Model @@ -115,6 +116,14 @@ class Product extends Model return $this->belongsTo(Category::class); } + /** + * Get the tags for the product. + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } + /** * Scope a query to only include approved products. */ diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..70b9309 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,65 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'color', + 'description', + ]; + + /** + * Get the products that have this tag. + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class); + } + + /** + * Generate a unique slug for the tag based on its name. + */ + public static function generateUniqueSlug(string $name, ?int $excludeId = null): string + { + $slug = Str::slug($name); + $originalSlug = $slug; + $count = 1; + + // Keep appending numbers until we find a unique slug + $query = static::where('slug', $slug); + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + while ($query->exists()) { + $slug = $originalSlug . '-' . $count; + $count++; + $query = static::where('slug', $slug); + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + } + + return $slug; + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/database/migrations/2026_02_21_135233_create_tags_table.php b/database/migrations/2026_02_21_135233_create_tags_table.php new file mode 100644 index 0000000..b2f06af --- /dev/null +++ b/database/migrations/2026_02_21_135233_create_tags_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->string('color')->default('#3B82F6'); // Default blue color + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index('name'); + $table->index('slug'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_02_21_135238_create_product_tag_table.php b/database/migrations/2026_02_21_135238_create_product_tag_table.php new file mode 100644 index 0000000..1594700 --- /dev/null +++ b/database/migrations/2026_02_21_135238_create_product_tag_table.php @@ -0,0 +1,30 @@ +foreignId('product_id')->constrained()->onDelete('cascade'); + $table->foreignId('tag_id')->constrained()->onDelete('cascade'); + + $table->primary(['product_id', 'tag_id']); + $table->index('tag_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_tag'); + } +}; diff --git a/resources/views/components/layout/navbar.blade.php b/resources/views/components/layout/navbar.blade.php index 318f5dc..81d80b0 100644 --- a/resources/views/components/layout/navbar.blade.php +++ b/resources/views/components/layout/navbar.blade.php @@ -164,6 +164,16 @@ Manage Categories + +
+ + + + Manage Tags +
+
@@ -244,6 +254,10 @@ class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('categories.*') ? 'border-primary-500 text-primary-700 bg-primary-50 dark:bg-primary-900 dark:text-primary-200' : 'border-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300' }}"> Manage Categories + + Manage Tags + Moderate Comments diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php index e046a76..073a862 100644 --- a/resources/views/products/create.blade.php +++ b/resources/views/products/create.blade.php @@ -96,6 +96,43 @@ + + @if($tags->count() > 0) +
+ +
+
+ @foreach($tags as $tag) + + @endforeach +
+ @error('tags') +

{{ $message }}

+ @enderror +

Select all tags that apply to your product.

+
+
+ @endif +
+ + @if($tags->count() > 0) +
+ +
+
+ @foreach($tags as $tag) + @php + $isChecked = old('tags') + ? in_array($tag->id, old('tags')) + : $product->tags->contains($tag->id); + @endphp + + @endforeach +
+ @error('tags') +

{{ $message }}

+ @enderror +

Select all tags that apply to your product.

+
+
+ @endif + @if($product->thumbnail_url)
diff --git a/resources/views/tags/create.blade.php b/resources/views/tags/create.blade.php new file mode 100644 index 0000000..137f00b --- /dev/null +++ b/resources/views/tags/create.blade.php @@ -0,0 +1,157 @@ +@extends('layouts.app') + +@section('content') +
+
+ + + + +
+ @csrf + +
+
+ +
+ + + @error('name') +

{{ $message }}

+ @enderror +

+ A unique, descriptive name for the tag +

+
+ + +
+ +
+ +
+ +
+
+ @error('color') +

{{ $message }}

+ @enderror +

+ Choose a color to help visually identify this tag +

+
+ + +
+ + + @error('description') +

{{ $message }}

+ @enderror +

+ Help users understand what this tag means +

+
+ + +
+ +
+
+ Tag Name +
+
+
+
+
+ + +
+ + Cancel + + +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/tags/edit.blade.php b/resources/views/tags/edit.blade.php new file mode 100644 index 0000000..360b2d2 --- /dev/null +++ b/resources/views/tags/edit.blade.php @@ -0,0 +1,186 @@ +@extends('layouts.app') + +@section('content') +
+
+ +
+
+
+

Edit Tag

+

Update tag information

+
+ + Back to Tags + +
+
+ + +
+ @csrf + @method('PUT') + +
+
+ +
+ + + @error('name') +

{{ $message }}

+ @enderror +

+ A unique, descriptive name for the tag +

+
+ + +
+
+ + + +
+

+ Current slug: {{ $tag->slug }} +

+

+ The slug will be automatically regenerated if you change the tag name. +

+
+
+
+ + +
+ +
+ +
+ +
+
+ @error('color') +

{{ $message }}

+ @enderror +

+ Choose a color to help visually identify this tag +

+
+ + +
+ + + @error('description') +

{{ $message }}

+ @enderror +

+ Help users understand what this tag means +

+
+ + + @if($tag->products()->count() > 0) +
+

Tag Usage

+

+ This tag is currently used by {{ $tag->products()->count() }} + {{ Str::plural('product', $tag->products()->count()) }}. +

+
+ @endif + + +
+ +
+
+ {{ $tag->name }} +
+
+
+
+
+ + +
+ + Cancel + + +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php new file mode 100644 index 0000000..f9ff667 --- /dev/null +++ b/resources/views/tags/index.blade.php @@ -0,0 +1,149 @@ +@extends('layouts.app') + +@section('content') +
+
+ +
+
+
+

Tag Management

+

Manage product tags

+
+ + + + + New Tag + +
+
+ + + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + +
+
+
+ +
+
+ + @if(request('search')) + + Clear + + @endif +
+
+
+ + +
+ @forelse($tags as $tag) +
+
+
+
+
+

+ {{ $tag->name }} +

+ + {{ $tag->slug }} + +
+
+
+ + @if($tag->description) +

+ {{ $tag->description }} +

+ @endif + +
+
+ {{ $tag->products_count }} {{ Str::plural('product', $tag->products_count) }} +
+
+ + Edit + +
+ @csrf + @method('DELETE') + +
+
+
+
+ @empty +
+ + + +

No tags found

+ + Create your first tag + +
+ @endforelse +
+ + + @if($tags->hasPages()) +
+ {{ $tags->links() }} +
+ @endif +
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 5b72132..8961077 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\CommentController; use App\Http\Controllers\CommentModerationController; use App\Http\Controllers\ProductController; use App\Http\Controllers\ProfileController; +use App\Http\Controllers\TagController; use App\Http\Controllers\UpvoteController; use App\Http\Controllers\WaitlistController; use Illuminate\Support\Facades\Route; @@ -65,6 +66,9 @@ Route::middleware(['auth', 'moderator'])->group(function () { // Category Management Routes (Moderators & Admins) Route::resource('categories', CategoryManagementController::class); + // Tag Management Routes (Moderators & Admins) + Route::resource('tags', TagController::class); + // Comment Moderation Routes (Moderators & Admins) Route::prefix('moderation')->name('moderation.')->group(function () { Route::get('/comments', [CommentModerationController::class, 'index'])->name('comments.index');