Skip to content
Merged
2 changes: 1 addition & 1 deletion app/Console/Commands/GenerateSitemap.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function handle(): int
'/blogs' => ['priority' => 0.8, 'freq' => 'daily'],
'/forum' => ['priority' => 0.8, 'freq' => 'daily'],
'/about-us' => ['priority' => 0.7, 'freq' => 'monthly'],
'/projects' => ['priority' => 0.7, 'freq' => 'monthly'],
'/products' => ['priority' => 0.7, 'freq' => 'monthly'],
'/guide' => ['priority' => 0.7, 'freq' => 'monthly'],
'/ai' => ['priority' => 0.7, 'freq' => 'monthly'],
'/donate' => ['priority' => 0.6, 'freq' => 'monthly'],
Expand Down
109 changes: 109 additions & 0 deletions app/Http/Controllers/Admin/ProductController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\Product\StoreProductRequest;
use App\Http\Requests\Product\UpdateProductRequest;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response;

class ProductController extends Controller
{
/**
* Display a listing of products.
*/
public function index(): Response
{
$products = Product::orderBy('sort_order')
->orderByDesc('id')
->get();

return Inertia::render('admin/Product', [
'products' => $products,
]);
}

/**
* Show the form for creating a new product.
*/
public function create(): Response
{
return Inertia::render('admin/ProductCreateOrEdit');
}

/**
* Store a newly created product in storage.
*/
public function store(StoreProductRequest $request): RedirectResponse
{
$data = $request->validated();

if ($request->hasFile('image')) {
$path = $request->file('image')->store('products');
$data['image_path'] = $path;
}

unset($data['image']);

Product::create($data);

return redirect()
->route('admin.products.index')
->with('success', 'Product created successfully.');
}

/**
* Show the form for editing the specified product.
*/
public function edit(Product $product): Response
{
return Inertia::render('admin/ProductCreateOrEdit', [
'product' => $product,
]);
}

/**
* Update the specified product in storage.
*/
public function update(UpdateProductRequest $request, Product $product): RedirectResponse
{
$data = $request->validated();

if ($request->hasFile('image')) {
if ($product->image_path && ! str($product->image_path)->startsWith(['http://', 'https://'])) {
Storage::delete($product->image_path);
}

$path = $request->file('image')->store('products');
$data['image_path'] = $path;
}

unset($data['image']);

$product->update($data);

return redirect()
->route('admin.products.index')
->with('success', 'Product updated successfully.');
}

/**
* Remove the specified product from storage.
*/
public function destroy(Product $product): RedirectResponse
{
if ($product->image_path && ! str($product->image_path)->startsWith(['http://', 'https://'])) {
Storage::delete($product->image_path);
}

$product->delete();

return redirect()
->back()
->with('success', 'Product deleted successfully.');
}
}
28 changes: 28 additions & 0 deletions app/Http/Controllers/ProductController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;

class ProductController extends Controller
{
/**
* Display the public list of products.
*/
public function index(): Response
{
$products = Cache::rememberForever('products_page_products', function () {
return Product::where('is_active', true)
->orderBy('sort_order')
->orderBy('id')
->get();
});

return Inertia::render('Products', [
'products' => $products,
]);
}
}
33 changes: 33 additions & 0 deletions app/Http/Requests/Product/StoreProductRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Http\Requests\Product;

use Illuminate\Foundation\Http\FormRequest;

class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
'image' => [
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:5120',
],
'users' => ['nullable', 'string', 'max:100'],
'link' => ['required', 'string', 'max:255'],
'open_type' => ['required', 'string', 'in:_blank,_self'],
'button_text' => ['nullable', 'string', 'max:100'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['required', 'boolean'],
];
}
}
33 changes: 33 additions & 0 deletions app/Http/Requests/Product/UpdateProductRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Http\Requests\Product;

use Illuminate\Foundation\Http\FormRequest;

class UpdateProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
'image' => [
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:5120',
],
'users' => ['nullable', 'string', 'max:100'],
'link' => ['required', 'string', 'max:255'],
'open_type' => ['required', 'string', 'in:_blank,_self'],
'button_text' => ['nullable', 'string', 'max:100'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['required', 'boolean'],
];
}
}
47 changes: 47 additions & 0 deletions app/Models/Product.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Product extends Model
{
use HasFactory;

protected $fillable = [
'name',
'description',
'image_path',
'users',
'link',
'open_type',
'button_text',
'sort_order',
'is_active',
];

protected $appends = [
'image_url',
];

protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}

public function getImageUrlAttribute(): ?string
{
if (! $this->image_path) {
return null;
}

return str($this->image_path)->startsWith(['http://', 'https://'])
? $this->image_path
: Storage::url($this->image_path);
}
}
19 changes: 19 additions & 0 deletions app/Observers/ProductObserver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Observers;

use App\Models\Product;
use Illuminate\Support\Facades\Cache;

class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget('products_page_products');
}

public function deleted(Product $product): void
{
Cache::forget('products_page_products');
}
}
3 changes: 3 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
use App\Models\Node;
use App\Models\NodeVote;
use App\Models\Notice;
use App\Models\Product;
use App\Models\Resource;
use App\Models\Subject;
use App\Models\User;
use App\Observers\BlogObserver;
use App\Observers\NodeObserver;
use App\Observers\NodeVoteObserver;
use App\Observers\NoticeObserver;
use App\Observers\ProductObserver;
use App\Observers\ResourceObserver;
use App\Observers\SubjectObserver;
use App\Observers\UserObserver;
Expand Down Expand Up @@ -43,6 +45,7 @@ public function boot(): void
Node::observe(NodeObserver::class);
NodeVote::observe(NodeVoteObserver::class);
Notice::observe(NoticeObserver::class);
Product::observe(ProductObserver::class);
Resource::observe(ResourceObserver::class);
Subject::observe(SubjectObserver::class);
User::observe(UserObserver::class);
Expand Down
36 changes: 36 additions & 0 deletions database/migrations/2026_09_21_210000_create_products_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description');
$table->string('image_path')->nullable();
$table->string('users')->nullable();
$table->string('link');
$table->string('open_type')->default('_blank');
$table->string('button_text')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
app()[PermissionRegistrar::class]->forgetCachedPermissions();

$permission = Permission::findOrCreate('manage products', 'web');

$admin = Role::where('name', 'admin')->where('guard_name', 'web')->first();
if ($admin) {
$admin->givePermissionTo($permission);
}

app()[PermissionRegistrar::class]->forgetCachedPermissions();
}

/**
* Reverse the migrations.
*/
public function down(): void
{
app()[PermissionRegistrar::class]->forgetCachedPermissions();

$permission = Permission::where('name', 'manage products')->where('guard_name', 'web')->first();
$permission?->delete();

app()[PermissionRegistrar::class]->forgetCachedPermissions();
}
};
5 changes: 5 additions & 0 deletions database/seeders/RolePermissionSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ public function run(): void
*/
Permission::findOrCreate('manage peers');

/*
* Product management
*/
Permission::findOrCreate('manage products');

$admin->syncPermissions(Permission::all());
// Administrators have unrestricted access to all features.

Expand Down
Loading
Loading