tags management

This commit is contained in:
Doar Mihai
2026-02-21 16:12:57 +02:00
parent 898bddf614
commit 76d888760c
13 changed files with 850 additions and 2 deletions
+17 -2
View File
@@ -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!');
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TagController extends Controller
{
/**
* Display a listing of tags.
*/
public function index(Request $request)
{
$query = Tag::withCount('products');
// Search
if ($request->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.');
}
}
+9
View File
@@ -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.
*/
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class Tag extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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';
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_tag', function (Blueprint $table) {
$table->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');
}
};
@@ -164,6 +164,16 @@
Manage Categories
</div>
</a>
<a href="{{ route('tags.index') }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div class="flex items-center">
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
</svg>
Manage Tags
</div>
</a>
<a href="{{ route('moderation.comments.index') }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
@@ -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
</a>
<a href="{{ route('tags.index') }}"
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('tags.*') ? '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 Tags
</a>
<a href="{{ route('moderation.comments.index') }}"
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('moderation.comments.*') ? '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' }}">
Moderate Comments
+37
View File
@@ -96,6 +96,43 @@
</div>
</div>
<!-- Tags -->
@if($tags->count() > 0)
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<div class="mt-1 space-y-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach($tags as $tag)
<label class="relative flex items-start p-3 border border-gray-300 rounded-md cursor-pointer hover:bg-gray-50 transition-colors">
<div class="flex items-center h-5">
<input type="checkbox"
name="tags[]"
value="{{ $tag->id }}"
{{ is_array(old('tags')) && in_array($tag->id, old('tags')) ? 'checked' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded">
</div>
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span>
<span class="text-sm font-medium text-gray-900">{{ $tag->name }}</span>
</div>
@if($tag->description)
<p class="text-xs text-gray-500 mt-1">{{ $tag->description }}</p>
@endif
</div>
</label>
@endforeach
</div>
@error('tags')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="mt-2 text-sm text-gray-500">Select all tags that apply to your product.</p>
</div>
</div>
@endif
<!-- Thumbnail -->
<div>
<label for="thumbnail" class="block text-sm font-medium text-gray-700">
+42
View File
@@ -97,6 +97,48 @@
</div>
</div>
<!-- Tags -->
@if($tags->count() > 0)
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<div class="mt-1 space-y-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach($tags as $tag)
@php
$isChecked = old('tags')
? in_array($tag->id, old('tags'))
: $product->tags->contains($tag->id);
@endphp
<label class="relative flex items-start p-3 border border-gray-300 rounded-md cursor-pointer hover:bg-gray-50 transition-colors">
<div class="flex items-center h-5">
<input type="checkbox"
name="tags[]"
value="{{ $tag->id }}"
{{ $isChecked ? 'checked' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded">
</div>
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span>
<span class="text-sm font-medium text-gray-900">{{ $tag->name }}</span>
</div>
@if($tag->description)
<p class="text-xs text-gray-500 mt-1">{{ $tag->description }}</p>
@endif
</div>
</label>
@endforeach
</div>
@error('tags')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="mt-2 text-sm text-gray-500">Select all tags that apply to your product.</p>
</div>
</div>
@endif
<!-- Current Thumbnail -->
@if($product->thumbnail_url)
<div>
+157
View File
@@ -0,0 +1,157 @@
@extends('layouts.app')
@section('content')
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Create Tag</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Add a new product tag</p>
</div>
<a
href="{{ route('tags.index') }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md font-medium transition-colors"
>
Back to Tags
</a>
</div>
</div>
<!-- Form -->
<form action="{{ route('tags.store') }}" method="POST" class="space-y-6">
@csrf
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tag Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name') }}"
required
autofocus
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 @error('name') border-red-500 @enderror"
placeholder="e.g., Made in EU, Open Source, Privacy Friendly"
>
@error('name')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A unique, descriptive name for the tag
</p>
</div>
<!-- Color -->
<div>
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Color <span class="text-red-500">*</span>
</label>
<div class="flex gap-4 items-start">
<input
type="color"
name="color"
id="color"
value="{{ old('color', '#3B82F6') }}"
required
class="h-10 w-20 rounded border-gray-300 dark:border-gray-600 cursor-pointer"
>
<div class="flex-1">
<input
type="text"
id="color-hex"
value="{{ old('color', '#3B82F6') }}"
readonly
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm font-mono text-sm"
placeholder="#3B82F6"
>
</div>
</div>
@error('color')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Choose a color to help visually identify this tag
</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 @error('description') border-red-500 @enderror"
placeholder="Describe what this tag represents..."
>{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Help users understand what this tag means
</p>
</div>
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preview
</label>
<div class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 border border-gray-200 dark:border-gray-700">
<div class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium text-white" id="tag-preview" style="background-color: #3B82F6">
<span id="preview-text">Tag Name</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<a
href="{{ route('tags.index') }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md font-medium transition-colors"
>
Cancel
</a>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md font-medium transition-colors"
>
Create Tag
</button>
</div>
</form>
</div>
</div>
@push('scripts')
<script>
// Live preview updates
const nameInput = document.getElementById('name');
const colorInput = document.getElementById('color');
const colorHexInput = document.getElementById('color-hex');
const tagPreview = document.getElementById('tag-preview');
const previewText = document.getElementById('preview-text');
nameInput.addEventListener('input', (e) => {
previewText.textContent = e.target.value || 'Tag Name';
});
colorInput.addEventListener('input', (e) => {
const color = e.target.value;
tagPreview.style.backgroundColor = color;
colorHexInput.value = color;
});
</script>
@endpush
@endsection
+186
View File
@@ -0,0 +1,186 @@
@extends('layouts.app')
@section('content')
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Edit Tag</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Update tag information</p>
</div>
<a
href="{{ route('tags.index') }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md font-medium transition-colors"
>
Back to Tags
</a>
</div>
</div>
<!-- Form -->
<form action="{{ route('tags.update', $tag) }}" method="POST" class="space-y-6">
@csrf
@method('PUT')
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tag Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name', $tag->name) }}"
required
autofocus
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 @error('name') border-red-500 @enderror"
placeholder="e.g., Made in EU, Open Source, Privacy Friendly"
>
@error('name')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A unique, descriptive name for the tag
</p>
</div>
<!-- Current Slug Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<div class="flex-1">
<p class="text-sm text-blue-800 dark:text-blue-200">
Current slug: <code class="bg-blue-100 dark:bg-blue-900 px-2 py-0.5 rounded">{{ $tag->slug }}</code>
</p>
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
The slug will be automatically regenerated if you change the tag name.
</p>
</div>
</div>
</div>
<!-- Color -->
<div>
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Color <span class="text-red-500">*</span>
</label>
<div class="flex gap-4 items-start">
<input
type="color"
name="color"
id="color"
value="{{ old('color', $tag->color) }}"
required
class="h-10 w-20 rounded border-gray-300 dark:border-gray-600 cursor-pointer"
>
<div class="flex-1">
<input
type="text"
id="color-hex"
value="{{ old('color', $tag->color) }}"
readonly
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm font-mono text-sm"
placeholder="#3B82F6"
>
</div>
</div>
@error('color')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Choose a color to help visually identify this tag
</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 @error('description') border-red-500 @enderror"
placeholder="Describe what this tag represents..."
>{{ old('description', $tag->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Help users understand what this tag means
</p>
</div>
<!-- Tag Stats -->
@if($tag->products()->count() > 0)
<div class="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-md p-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Tag Usage</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
This tag is currently used by <strong class="text-gray-900 dark:text-white">{{ $tag->products()->count() }}</strong>
{{ Str::plural('product', $tag->products()->count()) }}.
</p>
</div>
@endif
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preview
</label>
<div class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 border border-gray-200 dark:border-gray-700">
<div class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium text-white" id="tag-preview" style="background-color: {{ $tag->color }}">
<span id="preview-text">{{ $tag->name }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<a
href="{{ route('tags.index') }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md font-medium transition-colors"
>
Cancel
</a>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md font-medium transition-colors"
>
Update Tag
</button>
</div>
</form>
</div>
</div>
@push('scripts')
<script>
// Live preview updates
const nameInput = document.getElementById('name');
const colorInput = document.getElementById('color');
const colorHexInput = document.getElementById('color-hex');
const tagPreview = document.getElementById('tag-preview');
const previewText = document.getElementById('preview-text');
nameInput.addEventListener('input', (e) => {
previewText.textContent = e.target.value || '{{ $tag->name }}';
});
colorInput.addEventListener('input', (e) => {
const color = e.target.value;
tagPreview.style.backgroundColor = color;
colorHexInput.value = color;
});
</script>
@endpush
@endsection
+149
View File
@@ -0,0 +1,149 @@
@extends('layouts.app')
@section('content')
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Tag Management</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage product tags</p>
</div>
<a
href="{{ route('tags.create') }}"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md font-medium transition-colors"
>
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
New Tag
</a>
</div>
</div>
<!-- Success/Error Messages -->
@if(session('success'))
<div class="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200 px-4 py-3 rounded-lg">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 px-4 py-3 rounded-lg">
{{ session('error') }}
</div>
@endif
<!-- Search -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
<form method="GET" action="{{ route('tags.index') }}" class="flex gap-4">
<div class="flex-1">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search tags..."
class="w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md font-medium transition-colors"
>
Search
</button>
@if(request('search'))
<a
href="{{ route('tags.index') }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md font-medium transition-colors"
>
Clear
</a>
@endif
</div>
</form>
</div>
<!-- Tags Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@forelse($tags as $tag)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3 flex-1">
<div
class="w-4 h-4 rounded-full flex-shrink-0"
style="background-color: {{ $tag->color }}"
></div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{{ $tag->name }}
</h3>
<code class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded">
{{ $tag->slug }}
</code>
</div>
</div>
</div>
@if($tag->description)
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{{ $tag->description }}
</p>
@endif
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span class="font-medium">{{ $tag->products_count }}</span> {{ Str::plural('product', $tag->products_count) }}
</div>
<div class="flex gap-2">
<a
href="{{ route('tags.edit', $tag) }}"
class="text-primary-600 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-300 text-sm font-medium"
>
Edit
</a>
<form
action="{{ route('tags.destroy', $tag) }}"
method="POST"
onsubmit="return confirm('Are you sure you want to delete this tag?{{ $tag->products_count > 0 ? ' It is used by ' . $tag->products_count . ' products.' : '' }}');"
class="inline"
>
@csrf
@method('DELETE')
<button
type="submit"
class="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 text-sm font-medium"
>
Delete
</button>
</form>
</div>
</div>
</div>
@empty
<div class="col-span-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">No tags found</p>
<a
href="{{ route('tags.create') }}"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md font-medium transition-colors"
>
Create your first tag
</a>
</div>
@endforelse
</div>
<!-- Pagination -->
@if($tags->hasPages())
<div class="mt-6">
{{ $tags->links() }}
</div>
@endif
</div>
</div>
@endsection
+4
View File
@@ -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');