Initial Rapih

main
marszayn 2025-02-12 15:55:37 +07:00
parent a9edde2fbd
commit f00612339c
56 changed files with 4744 additions and 159 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Http\Requests\RoleRequest;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
public function index()
{
$roles = Role::with('permissions')
->latest()
->paginate(10);
// Kembalikan data ke komponen Inertia 'Admin/Roles/Index'
return inertia('Admin/Roles/Index', compact('roles'));
}
}

View File

@ -33,6 +33,9 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
// Redirect berdasarkan role user
$user = Auth::user();
return redirect()->intended(route('dashboard', absolute: false));
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\KategoriRequest;
use App\Models\Kategori;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
class KategoriController extends Controller
{
public function index()
{
try {
$kategori = Kategori::latest()->get();
return Inertia::render('admin/kategori/index_kategori', ['kategori' => $kategori]);
} catch (\Exception $e) {
Log::error('Error fetching Kategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function store(KategoriRequest $request)
{
try {
$kategori = Kategori::withTrashed()
->where('NamaKategori', $request->NamaKategori)
->first();
if ($kategori) {
$kategori->restore();
return redirect()->route('admin.kategori.index')->with('success', 'Kategori berhasil dikembalikan.');
}
Kategori::create($request->validated());
return redirect()->route('admin.kategori.index')->with('success', 'Kategori berhasil dibuat.');
} catch (\Exception $e) {
Log::error('Error creating Kategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function update(KategoriRequest $request, Kategori $kategori)
{
try {
$kategori->update($request->validated());
return redirect()->route('admin.kategori.index')->with('success', 'Kategori berhasil diperbarui.');
} catch (\Exception $e) {
Log::error('Error updating Kategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function destroy(Kategori $kategori)
{
try {
$kategori->delete();
return redirect()->route('admin.kategori.index')->with('success', 'Kategori berhasil dihapus.');
} catch (\Exception $e) {
Log::error('Error deleting Kategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PostRequest;
use App\Models\Kategori;
use App\Models\Post;
use App\Models\SubKategori;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['kategori', 'subkategori'])->latest()->get();
$kategori = Kategori::all();
$subkategori = SubKategori::all();
return Inertia::render('admin/post/index_post', [
'posts' => $posts,
'kategori' => $kategori,
'subkategori' => $subkategori
]);
}
public function create()
{
$kategori = Kategori::all();
$subkategori = SubKategori::all();
return Inertia::render('admin/post/add_post', [
'kategori' => $kategori,
'subkategori' => $subkategori
]);
}
public function store(PostRequest $request)
{
try {
$data = $request->validated();
$data['IsPublish'] = $request->has('IsPublish');
if ($request->hasFile('ImagePost')) {
$data['ImagePost'] = $request->file('ImagePost')->store('images/posts');
}
Post::create($data);
return redirect()->route('admin.post.index')->with('success', 'Post berhasil ditambahkan');
} catch (\Exception $e) {
return redirect()->route('admin.post.index')->with('error', 'Post gagal ditambahkan');
}
}
public function edit(Post $post)
{
return Inertia::render('admin/post/edit_post', [
'post' => $post,
'kategori' => Kategori::all(),
'subkategori' => SubKategori::where('KategoriId', $post->KategoriId)->get(),
]);
}
public function update(PostRequest $request, Post $post)
{
try {
$data = $request->validated();
if ($request->hasFile('ImagePost')) {
Storage::disk('public')->delete($post->ImagePost);
$data['ImagePost'] = $request->file('ImagePost')->store('images/posts', 'public');
}
$post->update($data);
return redirect()->route('admin.post.index')->with('success', 'Post berhasil diperbarui.');
} catch (\Exception $e) {
Log::error('Error updating Post: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function destroy(Post $post)
{
try {
Storage::disk('public')->delete($post->ImagePost);
$post->delete();
return redirect()->route('admin.post.index')->with('success', 'Post berhasil dihapus.');
} catch (\Exception $e) {
Log::error('Error deleting Post: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SubKategoriRequest;
use App\Models\Kategori;
use App\Models\SubKategori;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
class SubKategoriController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$subkategori = SubKategori::with('kategori')->latest()->get();
$kategori = Kategori::all();
return Inertia::render('admin/subkategori/index_subkategori', [
'subkategori' => $subkategori,
'kategori' => $kategori
]);
}
public function store(SubKategoriRequest $request)
{
try {
SubKategori::create($request->validated());
return redirect()->route('admin.subkategori.index')->with('success', 'SubKategori berhasil dibuat.');
} catch (\Exception $e) {
Log::error('Error creating SubKategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function update(SubKategoriRequest $request, SubKategori $subkategori)
{
try {
$subkategori->update($request->validated());
return redirect()->route('admin.subkategori.index')->with('success', 'SubKategori berhasil diperbarui.');
} catch (\Exception $e) {
Log::error('Error updating SubKategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
public function destroy(SubKategori $subkategori)
{
try {
$subkategori->delete();
return redirect()->route('admin.subkategori.index')->with('success', 'SubKategori berhasil dihapus.');
} catch (\Exception $e) {
Log::error('Error deleting SubKategori: ' . $e->getMessage());
return back()->with('error', 'Something went wrong.');
}
}
}

View File

@ -27,7 +27,7 @@ class LoginRequest extends FormRequest
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'login' => ['required', 'string'],
'password' => ['required', 'string'],
];
}
@ -41,11 +41,13 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
$loginField = filter_var($this->input('login'), FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
if (! Auth::attempt([$loginField => $this->input('login'), 'password' => $this->input('password')], $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
'login' => trans('auth.failed'),
]);
}
@ -68,7 +70,7 @@ class LoginRequest extends FormRequest
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'login' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
@ -80,6 +82,6 @@ class LoginRequest extends FormRequest
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
return Str::transliterate(Str::lower($this->string('login')).'|'.$this->ip());
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class KategoriRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'NamaKategori' => [
'required',
'string',
'max:255',
Rule::unique('Kategori', 'NamaKategori')->whereNull('deleted_at') // Abaikan data yang dihapus (soft delete)
],
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'KategoriId' => 'required|exists:Kategori,KategoriId',
'SubKategoriId' => [
'required',
Rule::exists('SubKategori', 'SubKategoriId')->where('KategoriId', $this->KategoriId),
],
'JudulPost' => ['required', 'string', 'max:255'],
'DescPost' => ['required', 'string'],
'ImagePost' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'permissions' => 'required|array',
'permissions.*' => 'exists:permissions,id'
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubKategoriRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'KategoriId' => ['required', 'integer', 'exists:Kategori,KategoriId'],
'NamaSubKategori' => ['required', 'string', 'max:255'],
Rule::unique('SubKategori', 'NamaSubKategori')->where('KategoriId', $this->KategoriId)->whereNull('deleted_at')
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Kategori extends Model
{
use HasFactory; use SoftDeletes;
protected $table = 'Kategori';
protected $primaryKey = 'KategoriId';
protected $fillable = ['NamaKategori'];
protected $dates = ['deleted_at'];
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Post extends Model
{
use HasFactory;
protected $table = 'Post';
protected $primaryKey = 'PostId';
protected $fillable = [
'KategoriId',
'SubKategoriId',
'JudulPost',
'SlugPost',
'DescPost',
'ImagePost',
'IsPublish',
];
public function kategori()
{
return $this->belongsTo(Kategori::class, 'KategoriId', 'KategoriId');
}
public function subkategori()
{
return $this->belongsTo(SubKategori::class, 'SubKategoriId', 'SubKategoriId');
}
public function getFormattedPublishDateAttribute()
{
return \Carbon\Carbon::parse($this->created_at)->translatedFormat('d F Y');
}
public static function boot()
{
parent::boot();
static::creating(function ($post) {
$post->Slug = Str::slug($post->JudulPost);
});
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class SubKategori extends Model
{
use HasFactory; use SoftDeletes;
protected $table = 'SubKategori';
protected $primaryKey = 'SubKategoriId';
protected $fillable = ['KategoriId', 'NamaSubKategori'];
public function kategori()
{
return $this->belongsTo(Kategori::class, 'KategoriId', 'KategoriId');
}
}

View File

@ -6,10 +6,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable /*implements MustVerifyEmail*/
{
use HasFactory, Notifiable;
use HasFactory, Notifiable, HasRoles;
/**
* The attributes that are mass assignable.
@ -18,6 +19,7 @@ class User extends Authenticatable /*implements MustVerifyEmail*/
*/
protected $fillable = [
'name',
'username',
'email',
'password',
];

View File

@ -4,6 +4,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
//
}
/**

View File

@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -14,9 +15,14 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
HandleInertiaRequests::class,
]);
//
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//

View File

@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\RepositoryServiceProvider::class,
];

View File

@ -10,6 +10,7 @@
"laravel/framework": "^11.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9",
"spatie/laravel-permission": "6.4.0",
"tightenco/ziggy": "^2.0"
},
"require-dev": {

86
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "86d4a008177912b9459f27068d5f4e1d",
"content-hash": "953cea45b540b8469800ed81d3f1fba6",
"packages": [
{
"name": "brick/math",
@ -3254,6 +3254,88 @@
],
"time": "2024-04-27T21:32:50+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "6.4.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
"reference": "05cce017fe3ac78f60a3fce78c07fe6e8e6e6e52"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/05cce017fe3ac78f60a3fce78c07fe6e8e6e6e52",
"reference": "05cce017fe3ac78f60a3fce78c07fe6e8e6e6e52",
"shasum": ""
},
"require": {
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0",
"illuminate/container": "^8.12|^9.0|^10.0|^11.0",
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0",
"illuminate/database": "^8.12|^9.0|^10.0|^11.0",
"php": "^8.0"
},
"require-dev": {
"laravel/passport": "^11.0|^12.0",
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0",
"phpunit/phpunit": "^9.4|^10.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Permission\\PermissionServiceProvider"
]
},
"branch-alias": {
"dev-main": "6.x-dev",
"dev-master": "6.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Permission\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Permission handling for Laravel 8.0 and up",
"homepage": "https://github.com/spatie/laravel-permission",
"keywords": [
"acl",
"laravel",
"permission",
"permissions",
"rbac",
"roles",
"security",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
"source": "https://github.com/spatie/laravel-permission/tree/6.4.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2024-02-28T08:11:20+00:00"
},
{
"name": "symfony/clock",
"version": "v7.1.1",
@ -8410,5 +8492,5 @@
"php": "^8.2"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.3.0"
}

View File

@ -0,0 +1,186 @@
<?php
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, //default 'role_id',
'permission_pivot_key' => null, //default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'permission.wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@ -0,0 +1,29 @@
<?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('Kategori', function (Blueprint $table) {
$table->id('KategoriId');
$table->string('NamaKategori')->unique();
$table->softDeletes();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('Kategori');
}
};

View File

@ -0,0 +1,138 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
if (empty($tableNames)) {
throw new \Exception('Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
}
if ($teams && empty($columnNames['team_foreign_key'] ?? null)) {
throw new \Exception('Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
}
Schema::create($tableNames['permissions'], function (Blueprint $table) {
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MySQL 8.0 use string('name', 125);
$table->string('guard_name'); // For MySQL 8.0 use string('guard_name', 125);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames) {
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MySQL 8.0 use string('name', 125);
$table->string('guard_name'); // For MySQL 8.0 use string('guard_name', 125);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
if (empty($tableNames)) {
throw new \Exception('Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
}
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->string('username')->unique()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
};

View File

@ -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('SubKategori', function (Blueprint $table) {
$table->id('SubKategoriId');
$table->unsignedBigInteger('KategoriId');
$table->string('NamaSubKategori');
$table->softDeletes();
$table->timestamps();
// Foreign key ke tabel Kategori
$table->foreign('KategoriId')->references('KategoriId')->on('Kategori')->onDelete('cascade');
// UNIQUE constraint untuk memastikan kategori & subkategori tidak duplikat
$table->unique(['KategoriId', 'NamaSubKategori']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('SubKategori');
}
};

View File

@ -0,0 +1,37 @@
<?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('Post', function (Blueprint $table) {
$table->id('PostId');
$table->unsignedBigInteger('KategoriId');
$table->unsignedBigInteger('SubKategoriId');
$table->string('JudulPost');
$table->string('SlugPost');
$table->text('DescPost');
$table->string('ImagePost')->nullable();
$table->timestamps();
$table->foreign('KategoriId')->references('KategoriId')->on('Kategori')->onDelete('cascade');
$table->foreign('SubKategoriId')->references('SubKategoriId')->on('SubKategori')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('Post');
}
};

View File

@ -0,0 +1,29 @@
<?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::table('Post', function (Blueprint $table) {
$table->boolean('IsPublish')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('Post', function (Blueprint $table) {
$table->dropColumn('IsPublish');
});
}
};

View File

@ -13,11 +13,9 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
RolesTableSeeder::class,
UserSeeder::class,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class PermissionsTableSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$resources = [
'users' => ['index', 'create', 'edit', 'delete'],
'roles' => ['index', 'create', 'edit', 'delete'],
'kategori' => ['index', 'create', 'edit', 'delete'],
'subkategori' => ['index', 'create', 'edit', 'delete'],
];
foreach ($resources as $resource => $actions) {
foreach ($actions as $action) {
$permissionName = "{$resource}.{$action}";
Permission::firstOrCreate(['name' => $permissionName, 'guard_name' => 'web']);
}
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Carbon\Carbon;
class PostSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Ambil kategori dan subkategori dari database
$kategori = DB::table('Kategori')->pluck('KategoriId')->toArray();
$subkategori = DB::table('SubKategori')->pluck('SubKategoriId')->toArray();
if (empty($kategori) || empty($subkategori)) {
$this->command->info('Tidak ada data kategori atau subkategori. Harap jalankan seeder kategori dan subkategori terlebih dahulu.');
return;
}
// Buat 10 post dummy
for ($i = 1; $i <= 10; $i++) {
$judul = "Judul Post " . $i;
$slug = Str::slug($judul);
DB::table('Post')->insert([
'KategoriId' => $kategori[array_rand($kategori)],
'SubKategoriId' => $subkategori[array_rand($subkategori)],
'JudulPost' => $judul,
'SlugPost' => $slug,
'DescPost' => "<p>Deskripsi konten untuk $judul</p>",
'ImagePost' => 'images/posts/default.png', // Pastikan ada file default.png di folder public/images/posts/
'IsPublish' => rand(0, 1),
'created_at' => Carbon::now()->subDays(rand(1, 30))->format('Y-m-d H:i:s'),
'updated_at' => Carbon::now()->format('Y-m-d H:i:s'),
]);
}
$this->command->info('Seeder post berhasil dijalankan.');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RolesTableSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$admin = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$user = Role::firstOrCreate(['name' => 'user', 'guard_name' => 'web']);
// Assign permissions ke role
$admin->syncPermissions(Permission::all()); // Admin mendapatkan semua akses
// Assign role `admin` ke user
$user->assignRole($admin);
}
}

View File

@ -14,10 +14,22 @@ class UserSeeder extends Seeder
*/
public function run(): void
{
User::create([
'name' => 'Admin User',
'email' => 'admin@gmail.com',
'password' => Hash::make('password')
$admin = User::create([
'name' => 'Admin',
'username' => 'admindlh25',
'email' => 'dlh@gmail.com',
'password' => Hash::make('admindlh25'),
]);
$admin->assignRole('admin');
$user = User::create([
'name' => 'Uji Coba',
'username' => 'ujicoba25',
'email' => 'ujicoba@gmail.com',
'password' => Hash::make('ujicoba25'),
]);
$user->assignRole('user');
}
}

974
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,19 +29,22 @@
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.2.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.3",
"@reduxjs/toolkit": "^2.5.1",
"@types/react-redux": "^7.1.34",
"apexcharts": "^4.4.0",
"ckeditor4-react": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
@ -56,6 +59,7 @@
"react-i18next": "^15.4.0",
"react-perfect-scrollbar": "^1.5.8",
"react-popper": "^2.3.0",
"react-quill": "^2.0.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.0.19",
"react-router-dom": "^7.1.4",

View File

@ -1,4 +1,5 @@
import "./bootstrap";
import "../css/app.css";
import { createRoot } from "react-dom/client";
import { createInertiaApp } from "@inertiajs/react";
@ -6,7 +7,6 @@ import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { ThemeProvider } from "@/components/theme-provider";
const appName = import.meta.env.VITE_APP_NAME || "Laravel";
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) =>
@ -14,6 +14,7 @@ createInertiaApp({
`./pages/${name}.tsx`,
import.meta.glob("./pages/**/*.tsx")
),
setup({ el, App, props }) {
const root = createRoot(el);

View File

@ -3,102 +3,139 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowRight } from "lucide-react";
import { Link } from "@inertiajs/react";
const pengumumans = [
{
id: 1,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "16 Januari 2025",
title: "Pelatihan & Sertifikasi Online Bidang Pengendalian Pencemaran Air Dan Udara",
description:
"Kegiatan Pelatihan Dan Uji Sertifikasi Tersebut Akan Diselenggarakan Sebagaimana Jadwal Terlampir, Kegiatan Tersebut Bekerjasama Dengan Lembaga P...",
image: "/assets/img1.jpg",
},
{
id: 2,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "12 Desember 2024",
title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
description:
"Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
image: "/assets/img1.jpg",
},
{
id: 3,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "12 Desember 2024",
title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
description:
"Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
image: "/assets/img1.jpg",
},
{
id: 4,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "12 Desember 2024",
title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
description:
"Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
image: "/assets/img1.jpg",
},
{
id: 5,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "12 Desember 2024",
title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
description:
"Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
image: "/assets/img1.jpg",
},
{
id: 6,
title_page: "Pengumuman",
alt_image: "Pengumuman",
date: "12 Desember 2024",
title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
description:
"Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
image: "/assets/img1.jpg",
},
];
// const pengumumans = [
// {
// id: 1,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "16 Januari 2025",
// title: "Pelatihan & Sertifikasi Online Bidang Pengendalian Pencemaran Air Dan Udara",
// description:
// "Kegiatan Pelatihan Dan Uji Sertifikasi Tersebut Akan Diselenggarakan Sebagaimana Jadwal Terlampir, Kegiatan Tersebut Bekerjasama Dengan Lembaga P...",
// image: "/assets/img1.jpg",
// },
// {
// id: 2,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "12 Desember 2024",
// title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
// description:
// "Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
// image: "/assets/img1.jpg",
// },
// {
// id: 3,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "12 Desember 2024",
// title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
// description:
// "Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
// image: "/assets/img1.jpg",
// },
// {
// id: 4,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "12 Desember 2024",
// title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
// description:
// "Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
// image: "/assets/img1.jpg",
// },
// {
// id: 5,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "12 Desember 2024",
// title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
// description:
// "Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
// image: "/assets/img1.jpg",
// },
// {
// id: 6,
// title_page: "Pengumuman",
// alt_image: "Pengumuman",
// date: "12 Desember 2024",
// title: "Pembinaan Pelaporan secara Online Pengelolaan Lingkungan melalui situs Status Ketaatan Lingkungan (SKL)",
// description:
// "Sukolompok Pengawasan Lingkungan Bidang Pengawasan dan Penataan Hukum Dinas Lingkungan Hidup Prov. DKI Jakarta...",
// image: "/assets/img1.jpg",
// },
// ];
const CardPengumuman = () => {
interface SubKategori {
SubKategoriId: number;
NamaSubKategori: string;
}
interface Kategori {
KategoriId: number;
NamaKategori: string;
}
interface Post {
PostId: number;
JudulPost: string;
DescPost: string;
SlugPost: string;
ImagePost: string;
IsPublish: boolean;
created_at: string;
kategori?: Kategori;
subkategori?: SubKategori;
}
interface CardPengumumanProps {
posts: Post[];
}
const CardPengumuman = ({ posts }: CardPengumumanProps) => {
return (
<section className="container max-w-7xl py-8 px-6">
{/* List of Announcements */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{pengumumans.map((item) => (
<Card key={item.id} className="md:p-4">
{posts.map((post) => (
<Card key={post.PostId} className="md:p-4">
<img
src={item.image}
alt={item.alt_image}
src={`/storage/${post.ImagePost}`}
alt={post.JudulPost}
className="rounded-md"
/>
<CardContent className="p-4">
<Badge className="bg-red-600 text-white">
{item.title_page}
{post.kategori?.NamaKategori} |{" "}
{post.subkategori?.NamaSubKategori}
</Badge>
<p className="text-gray-500 text-sm mt-2">
{item.date}
{new Date(post.created_at).toLocaleDateString(
"id-ID",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
<h3 className="text-md font-semibold mt-2 text-gray-900">
{item.title}
{post.JudulPost}
</h3>
<p className="text-sm text-gray-600 mt-2">
{item.description}
{post.DescPost.replace(/<[^>]*>/g, "")}
</p>
<Button
variant="link"
className="text-red-600 mt-2 pl-0"
>
Baca Selengkapnya{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Link href={`/post/${post.SlugPost}`}>
<Button
variant="link"
className="text-red-600 mt-2 pl-0"
>
Baca Selengkapnya{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</CardContent>
</Card>
))}

View File

@ -17,6 +17,7 @@ import {
} from "@/components/ui/drawer";
import RunningText from "./RunningText";
import { useTheme } from "next-themes";
import SearchDialog from "./SearchDialog";
interface NavItemsProps {
mobile?: boolean;
@ -26,7 +27,7 @@ interface NavItemsProps {
const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
// const [searchQuery, setSearchQuery] = useState("");
const { theme, setTheme } = useTheme();
// Handle scroll effect
@ -44,14 +45,6 @@ const Navbar = () => {
setTheme(theme === "light" ? "dark" : "light");
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery) {
router.push(`/search/${searchQuery}`);
setSearchQuery(""); // Clear input setelah search
}
};
const NavItems: React.FC<NavItemsProps> = ({ mobile = false, onClose }) => (
<>
<NavigationMenuItem>
@ -82,7 +75,7 @@ const Navbar = () => {
</Link>
</NavigationMenuItem>
))}
<NavigationMenuItem>
{/* <NavigationMenuItem>
<form onSubmit={handleSearch} className="flex items-center">
<input
type="text"
@ -101,6 +94,9 @@ const Navbar = () => {
<Search className="h-4 w-4 text-white" />
</Button>
</form>
</NavigationMenuItem> */}
<NavigationMenuItem>
<SearchDialog />
</NavigationMenuItem>
</>
);

View File

@ -1,4 +1,3 @@
// PopupModal.js
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { X, ArrowLeft, ArrowRight } from "lucide-react";
@ -15,7 +14,7 @@ const PopupModal = ({ onClose }: { onClose: () => void }) => {
title: "Workshop Peningkatan Kapasitas Pengendalian Pencemaran Udara",
image: "/assets/popup-2.jpeg",
description:
"Workshop ini bertujuan untuk meningkatkan pengetahuan dan keterampilan di bidang lingkungan hidup.",
"Workshop ini bertujuan untuk meningkatkan pengetahuan dan keterampilan di bidang lingkungan hidup. Workshop ini bertujuan untuk meningkatkan pengetahuan dan keterampilan di bidang lingkungan hidup.",
},
{
title: "Sosialisasi Program Ramah Lingkungan",
@ -36,30 +35,39 @@ const PopupModal = ({ onClose }: { onClose: () => void }) => {
};
return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-6 rounded-lg max-w-lg w-full relative">
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50 container md:max-w-none">
<div className="bg-white p-6 rounded-lg relative md:max-w-2xl mx-auto w-full h-50 md:h-5/6 flex flex-col justify-between md:justify-around">
<button className="absolute top-2 right-2" onClick={onClose}>
<X className="w-6 h-6 text-gray-700" />
</button>
<h2 className="text-xl font-bold text-center mb-4">
<h2 className="md:text-xl text-md font-bold text-center mb-4">
{slides[currentSlide].title}
</h2>
<img
src={slides[currentSlide].image}
alt="Popup Slide Image"
className="w-full h-auto mb-4"
/>
<p className="text-sm text-gray-600 text-center mb-4">
{slides[currentSlide].description}
</p>
<div className="flex justify-between items-center mb-4">
<button onClick={prevSlide} className="p-2">
<div className="relative">
<img
src={slides[currentSlide].image}
alt="Popup Slide Image"
className="w-full h-full md:h-96 object-contain mb-4"
/>
<button
onClick={prevSlide}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 bg-white bg-opacity-50 rounded-full"
>
<ArrowLeft className="w-6 h-6 text-gray-700" />
</button>
<button onClick={nextSlide} className="p-2">
<button
onClick={nextSlide}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 bg-white bg-opacity-50 rounded-full"
>
<ArrowRight className="w-6 h-6 text-gray-700" />
</button>
</div>
{/* <h2 className="text-xl font-bold text-center mb-4">
{slides[currentSlide].title}
</h2> */}
<p className="text-sm text-gray-600 text-center mb-4 overflow-hidden text-ellipsis whitespace-nowrap">
{slides[currentSlide].description}
</p>
<div className="flex justify-center">
<Button className="bg-green-800 text-white px-6 py-2 rounded-lg">
Selengkapnya

View File

@ -0,0 +1,72 @@
import React, { useState } from "react";
import { Search } from "lucide-react";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { router } from "@inertiajs/react";
const SearchDialog: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (searchQuery.trim()) {
router.visit(`/search/${encodeURIComponent(searchQuery)}`);
setSearchQuery(""); // Clear input setelah search
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
className="bg-green-800/90 hover:bg-green-700 text-white p-2 rounded-full"
>
<Search className="h-6 w-6" />
</Button>
</DialogTrigger>
<DialogContent className="bg-white text-black p-6 rounded-lg w-[90%] max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-center">
Search
</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSearch}
className="flex items-center gap-2 w-full"
>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-gray-100 px-3 py-2 rounded-lg focus:outline-none w-full"
/>
<Button
type="submit"
className="bg-green-700 hover:bg-green-600 text-white p-2 rounded-lg"
>
<Search className="h-5 w-5" />
</Button>
</form>
<DialogClose asChild>
<Button
variant="ghost"
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
>
</Button>
</DialogClose>
</DialogContent>
</Dialog>
);
};
export default SearchDialog;

View File

@ -3,6 +3,7 @@
import * as React from "react";
import {
Command,
FolderArchive,
Frame,
Home,
LifeBuoy,
@ -38,20 +39,28 @@ const data = {
icon: Home,
},
{
title: "Projects",
title: "Data Master",
url: "#",
icon: SquareTerminal,
icon: FolderArchive,
isActive: true,
items: [
{
title: "History",
url: "#",
title: "Kategori Post",
url: "/admin/kategori",
},
{
title: "Sub Kategori Post",
url: "/admin/subkategori",
},
// {
// title: "Menu",
// url: "/menus",
// },
],
},
{
title: "Design Engineering",
url: "#",
title: "Post",
url: "/admin/post",
icon: Frame,
},
],

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,127 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,41 @@
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider duration={2000} swipeDirection="right">
{toasts.map(function ({
id,
title,
description,
action,
...props
}) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>
{description}
</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -11,7 +11,7 @@ export default function Pengumuman() {
<Head title="Pengumuman" />
<Navbar />
<HeroSecond title="Pengumuman" />
<CardPengumuman />
<CardPengumuman posts={[]} />
<Footer />
</>
);

View File

@ -0,0 +1,461 @@
import React, { useEffect, useState } from "react";
import { useForm } from "@inertiajs/react";
import { PageProps } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
// import { toast } from "react-toastify";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Search,
Plus,
Pencil,
Trash2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import AuthenticatedLayout from "@/layouts/authenticated-layout";
import { Head } from "@inertiajs/react";
import { Toaster } from "@/components/ui/toaster";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface Kategori {
KategoriId: number | null;
NamaKategori: string;
}
const ITEMS_PER_PAGE = 5;
export default function KategoriIndex({
kategori,
}: PageProps<{ kategori: Kategori[] }>) {
const {
data,
setData,
post,
put,
delete: destroy,
reset,
} = useForm<Kategori>({
KategoriId: null,
NamaKategori: "",
});
const { toast } = useToast();
const [editing, setEditing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<Kategori | null>(null);
const [search, setSearch] = useState("");
const [filteredKategori, setFilteredKategori] = useState(kategori);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
let filtered = kategori;
if (search) {
filtered = filtered.filter((kategori) =>
kategori.NamaKategori.toLowerCase().includes(
search.toLowerCase()
)
);
}
setFilteredKategori(filtered);
setCurrentPage(1);
}, [kategori, search]);
const totalPages = Math.ceil(filteredKategori.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentItems = filteredKategori.slice(startIndex, endIndex);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editing) {
put(`/admin/kategori/${data.KategoriId}`, {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Kategori berhasil diperbarui",
variant: "default",
});
setIsModalOpen(false);
reset();
setEditing(false);
},
onError: () => {
toast({
title: "Gagal",
description:
"Terjadi kesalahan saat memperbarui kategori",
variant: "destructive",
});
},
});
} else {
post("/admin/kategori", {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Kategori berhasil dibuat",
variant: "default",
});
setIsModalOpen(false);
reset();
},
onError: () => {
toast({
title: "Gagal",
description: "Terjadi kesalahan saat membuat kategori",
variant: "destructive",
});
},
});
}
};
const handleEdit = (kategori: Kategori) => {
setData({ ...kategori });
setEditing(true);
setIsModalOpen(true);
};
const handleDelete = () => {
if (deleteConfirm) {
destroy(`/admin/kategori/${deleteConfirm.KategoriId}`, {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Kategori berhasil dihapus",
variant: "default",
});
setDeleteConfirm(null);
},
onError: () => {
toast({
title: "Gagal",
description:
"Terjadi kesalahan saat menghapus kategori",
variant: "destructive",
});
setDeleteConfirm(null);
},
});
}
};
return (
<AuthenticatedLayout header={"Kategori Post"}>
<Head title="Kategori Post" />
<div className="container mx-auto p-4">
<Card className="shadow-lg">
<CardHeader>
<div className="flex flex-col space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-4 w-4" />
<Input
type="text"
placeholder="Cari Kategori..."
value={search}
onChange={handleSearch}
className="pl-10 pr-4 w-full border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 rounded-lg"
/>
</div>
<Dialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
>
<DialogTrigger asChild>
<Button
onClick={() => {
setEditing(false);
setIsModalOpen(true);
}}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors duration-200"
>
<Plus className="h-4 w-4" />
Buat Kategori
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{editing
? "Ubah Kategori"
: "Buat Kategori"}
</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 mt-4"
>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Name
</label>
<Input
type="text"
placeholder="Masukkan nama kategori"
value={data.NamaKategori}
onChange={(e) =>
setData(
"NamaKategori",
e.target.value
)
}
className="w-full"
/>
{/* <div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Publish
</label>
<Select
value={
data.is_publish
? "true"
: "false"
}
onValueChange={(
value
) =>
setData(
"is_publish",
value === "true"
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
Published
</SelectItem>
<SelectItem value="false">
Unpublished
</SelectItem>
</SelectContent>
</Select>
</div> */}
</div>
<DialogFooter className="mt-6">
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{editing ? "Ubah" : "Buat"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-black/50">
{/* <TableHead className="font-semibold">
ID
</TableHead> */}
<TableHead className="font-semibold">
No
</TableHead>
<TableHead className="font-semibold">
Name
</TableHead>
<TableHead className="font-semibold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentItems.map((kategori, index) => (
<TableRow
key={kategori.KategoriId || "new"}
className="hover:bg-gray-50 transition-colors duration-150"
>
{/* <TableCell>{category.id}</TableCell> */}
<TableCell>
{startIndex + index + 1}
</TableCell>
<TableCell>
{kategori.NamaKategori}
</TableCell>
<TableCell className="flex gap-2">
<Button
onClick={() =>
handleEdit(kategori)
}
variant="outline"
className="flex items-center gap-2"
>
<Pencil className="h-4 w-4" />
Edit
</Button>
<Button
onClick={() =>
setDeleteConfirm(
kategori
)
}
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</TableCell>
</TableRow>
))}
{currentItems.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-4 text-gray-500"
>
Tidak ada data
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* <div className="flex flex-row mt-3">
<div className="text-sm w-1/2">
1 : Pengumuman <br /> 2 : Undangan
</div>
<div className="text-sm w-1/2">
3 : Peraturan <br /> 4 : Popup Home
</div>
</div> */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {startIndex + 1} to{" "}
{Math.min(endIndex, filteredKategori.length)} of{" "}
{filteredKategori.length} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage - 1)
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from(
{ length: totalPages },
(_, i) => i + 1
).map((page) => (
<Button
key={page}
variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage + 1)
}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{deleteConfirm && (
<Dialog open={true} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Konfirmasi Penghapusan
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-gray-600">
Apakah Anda yakin ingin menghapus "
<span className="font-medium">
{deleteConfirm.NamaKategori}
</span>
"?
<br />
<span className="text-red-500">
Tindakan ini tidak dapat dibatalkan.
</span>
</p>
</div>
<DialogFooter className="gap-2">
<Button
onClick={() => setDeleteConfirm(null)}
variant="outline"
>
Batal
</Button>
<Button
onClick={handleDelete}
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Ya, hapus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<Toaster />
</AuthenticatedLayout>
);
}

View File

@ -0,0 +1,273 @@
import React, { useEffect } from "react";
import { useForm } from "@inertiajs/react";
import AuthenticatedLayout from "@/layouts/authenticated-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { Head } from "@inertiajs/react";
import { CKEditor } from "ckeditor4-react"; // ✅ Import CKEditor langsung
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const slugify = (text: string) => text.toLowerCase().replace(/\s+/g, "-");
interface Kategori {
KategoriId: number;
NamaKategori: string;
}
interface SubKategori {
SubKategoriId: number;
KategoriId: number;
NamaSubKategori: string;
}
interface AddPostProps {
kategori: Kategori[];
subkategori: SubKategori[];
}
export default function AddPost({ kategori, subkategori }: AddPostProps) {
const { toast } = useToast();
const { data, setData, post, reset } = useForm({
KategoriId: "",
SubKategoriId: "",
JudulPost: "",
SlugPost: "",
DescPost: "",
ImagePost: null as File | null,
IsPublish: false,
});
useEffect(() => {
setData("SlugPost", slugify(data.JudulPost));
}, [data.JudulPost]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData();
formData.append("KategoriId", data.KategoriId);
formData.append("SubKategoriId", data.SubKategoriId);
formData.append("JudulPost", data.JudulPost);
formData.append("SlugPost", data.SlugPost);
formData.append("DescPost", data.DescPost);
formData.append("IsPublish", data.IsPublish.toString());
if (data.ImagePost) {
formData.append("ImagePost", data.ImagePost);
}
post("/admin/post", {
data: formData,
headers: { "Content-Type": "multipart/form-data" },
onSuccess: () => {
toast({
title: "Berhasil",
description: "Post berhasil dibuat",
variant: "default",
});
reset();
},
onError: () => {
toast({
title: "Gagal",
description: "Terjadi kesalahan saat membuat Post",
variant: "destructive",
});
},
});
};
return (
<AuthenticatedLayout header="Tambah Post">
<Head title="Tambah Post" />
<div className="container mx-auto p-4">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="text"
placeholder="Judul Post"
value={data.JudulPost}
onChange={(e) => setData("JudulPost", e.target.value)}
/>
<Input
type="text"
placeholder="Slug Post"
value={data.SlugPost}
readOnly
/>
<Select
onValueChange={(value) =>
setData("KategoriId", Number(value).toString())
}
>
<SelectTrigger>
<SelectValue placeholder="Pilih Kategori" />
</SelectTrigger>
<SelectContent>
{kategori.map((kat) => (
<SelectItem
key={kat.KategoriId}
value={kat.KategoriId.toString()}
>
{kat.NamaKategori}
</SelectItem>
))}
</SelectContent>
</Select>
{data.KategoriId && (
<Select
onValueChange={(value) =>
setData(
"SubKategoriId",
Number(value).toString()
)
}
>
<SelectTrigger>
<SelectValue placeholder="Pilih SubKategori" />
</SelectTrigger>
<SelectContent>
{subkategori
.filter(
(sub) =>
sub.KategoriId ===
Number(data.KategoriId)
)
.map((sub) => (
<SelectItem
key={sub.SubKategoriId}
value={sub.SubKategoriId.toString()}
>
{sub.NamaSubKategori}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="relative min-h-[300px]">
<CKEditor
initData={data.DescPost}
onChange={(evt) => {
const newData = evt.editor.getData();
setData("DescPost", newData);
}}
config={{
height: 300,
removePlugins:
"easyimage,cloudservices,exportpdf",
toolbar: [
{ name: "document", items: ["Source"] },
{
name: "clipboard",
items: [
"Cut",
"Copy",
"Paste",
"PasteText",
"PasteFromWord",
"-",
"Undo",
"Redo",
],
},
{
name: "editing",
items: [
"Find",
"Replace",
"-",
"SelectAll",
],
},
{
name: "basicstyles",
items: [
"Bold",
"Italic",
"Underline",
"Strike",
"Subscript",
"Superscript",
"-",
"RemoveFormat",
],
},
{
name: "paragraph",
items: [
"NumberedList",
"BulletedList",
"-",
"Outdent",
"Indent",
"-",
"Blockquote",
"-",
"JustifyLeft",
"JustifyCenter",
"JustifyRight",
"JustifyBlock",
],
},
{
name: "links",
items: ["Link", "Unlink"],
},
{
name: "insert",
items: [
"Image",
"Table",
"HorizontalRule",
"SpecialChar",
],
},
{
name: "styles",
items: [
"Styles",
"Format",
"Font",
"FontSize",
],
},
{
name: "colors",
items: ["TextColor", "BGColor"],
},
{ name: "tools", items: ["Maximize"] },
],
filebrowserUploadUrl: "/images/posts",
filebrowserUploadMethod: "form",
removeButtons: "",
}}
/>
</div>
<Input
type="file"
accept="image/png, image/jpg, image/webp"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
setData("ImagePost", e.target.files[0]);
}
}}
/>
<Button type="submit" className="bg-blue-600 text-white">
Simpan
</Button>
</form>
</div>
</AuthenticatedLayout>
);
}

View File

@ -0,0 +1,5 @@
import React from "react";
export default function PostEdit() {
return <div>PostEdit</div>;
}

View File

@ -0,0 +1,247 @@
import React, { useEffect, useState } from "react";
import { Link, useForm } from "@inertiajs/react";
import { PageProps } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Search,
Plus,
Pencil,
Trash2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import AuthenticatedLayout from "@/layouts/authenticated-layout";
import { Head } from "@inertiajs/react";
import { Toaster } from "@/components/ui/toaster";
interface SubKategori {
SubKategoriId: number;
NamaSubKategori: string;
}
interface Kategori {
KategoriId: number;
NamaKategori: string;
}
interface Posting {
PostId: number | null;
JudulPost: string;
SubKategoriId: number | null;
IsPublish: boolean;
kategori?: Kategori;
subkategori?: SubKategori;
}
const ITEMS_PER_PAGE = 5;
export default function PostIndex({
posts = [],
}: PageProps<{ posts: Posting[] }>) {
const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1);
const { delete: destroy } = useForm({});
const [search, setSearch] = useState("");
const [filteredPosting, setFilteredPosting] = useState(posts);
useEffect(() => {
let filtered = posts;
if (search) {
filtered = filtered.filter((posting) =>
posting.JudulPost.toLowerCase().includes(search.toLowerCase())
);
}
setFilteredPosting(filtered);
setCurrentPage(1);
}, [posts, search]);
const totalPages = Math.ceil(filteredPosting.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentItems = filteredPosting.slice(startIndex, endIndex);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleDelete = (PostId: number) => {
if (confirm("Apakah Anda yakin ingin menghapus postingan ini?")) {
destroy(`/admin/post/${PostId}`, {
onSuccess: () =>
toast({
title: "Berhasil",
description: "Post berhasil dihapus",
variant: "default",
}),
onError: () =>
toast({
title: "Gagal",
description: "Terjadi kesalahan saat menghapus Post",
variant: "destructive",
}),
});
}
};
return (
<AuthenticatedLayout header="Daftar Postingan">
<Head title="Daftar Postingan" />
<div className="container mx-auto p-4">
<Card className="shadow-lg">
<CardHeader className="flex flex-row justify-between items-center">
<Input
type="text"
placeholder="Cari Post..."
value={search}
onChange={handleSearch}
className="w-96 border-gray-200 rounded-lg"
/>
<Link href="/admin/post/add">
<Button className="bg-blue-600 text-white flex items-center gap-2">
<Plus className="h-4 w-4" />
Tambah Post
</Button>
</Link>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead>No</TableHead>
<TableHead>Judul</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentItems.length > 0 ? (
currentItems.map((posting, index) => (
<TableRow key={posting.PostId}>
<TableCell>
{startIndex + index + 1}
</TableCell>
<TableCell>
{posting.JudulPost}
</TableCell>
<TableCell>
{posting.kategori?.NamaKategori}{" "}
|{" "}
{
posting.subkategori
?.NamaSubKategori
}
</TableCell>
<TableCell>
{posting.IsPublish
? "Published"
: "Draft"}
</TableCell>
<TableCell className="flex gap-2">
<Link
href={`/admin/post/edit/${posting.PostId}`}
>
<Button
variant="outline"
className="flex items-center gap-2"
>
<Pencil className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
onClick={() =>
handleDelete(
posting.PostId!
)
}
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-4 text-gray-500"
>
Tidak ada data
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {startIndex + 1} to{" "}
{Math.min(endIndex, filteredPosting.length)} of{" "}
{filteredPosting.length} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage - 1)
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from(
{ length: totalPages },
(_, i) => i + 1
).map((page) => (
<Button
key={page}
variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage + 1)
}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<Toaster />
</AuthenticatedLayout>
);
}

View File

@ -0,0 +1,552 @@
import React, { useEffect, useState } from "react";
import { useForm } from "@inertiajs/react";
import { PageProps } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
// import { toast } from "react-toastify";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Search,
Plus,
Pencil,
Trash2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import AuthenticatedLayout from "@/layouts/authenticated-layout";
import { Head } from "@inertiajs/react";
import { Toaster } from "@/components/ui/toaster";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface Kategori {
KategoriId: number;
NamaKategori: string;
}
interface SubKategori {
SubKategoriId: number | null;
KategoriId: number;
NamaSubKategori: string;
}
const ITEMS_PER_PAGE = 5;
export default function SubKategoriIndex({
subkategori = [],
kategori = [],
}: PageProps<{ subkategori: SubKategori[]; kategori: Kategori[] }>) {
const {
data,
setData,
post,
put,
delete: destroy,
reset,
} = useForm<SubKategori>({
SubKategoriId: null,
KategoriId: kategori.length > 0 ? kategori[0].KategoriId : 0,
NamaSubKategori: "",
});
const { toast } = useToast();
const [editing, setEditing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<SubKategori | null>(
null
);
const [search, setSearch] = useState("");
const [filteredKategori, setFilteredKategori] = useState(subkategori);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
let filtered = subkategori;
if (search) {
filtered = filtered.filter((subkategori) =>
subkategori.NamaSubKategori.toLowerCase().includes(
search.toLowerCase()
)
);
}
setFilteredKategori(filtered);
setCurrentPage(1);
}, [kategori, search]);
const totalPages = Math.ceil(
(filteredKategori?.length || 0) / ITEMS_PER_PAGE
);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentItems = filteredKategori.slice(startIndex, endIndex);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editing) {
put(`/admin/subkategori/${data.SubKategoriId}`, {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Sub Kategori berhasil diperbarui",
variant: "default",
});
setIsModalOpen(false);
reset();
setEditing(false);
setData({
SubKategoriId: null,
NamaSubKategori: "",
KategoriId: kategori[0]?.KategoriId || 0,
});
setEditing(false);
},
onError: () => {
toast({
title: "Gagal",
description:
"Terjadi kesalahan saat memperbarui Sub Kategori",
variant: "destructive",
});
},
});
} else {
post("/admin/subkategori", {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Sub Kategori berhasil dibuat",
variant: "default",
});
setIsModalOpen(false);
setData({
SubKategoriId: null,
NamaSubKategori: "",
KategoriId: kategori[0]?.KategoriId || 0,
});
},
onError: () => {
toast({
title: "Gagal",
description:
"Terjadi kesalahan saat membuat Sub Kategori",
variant: "destructive",
});
},
});
}
};
const handleEdit = (subkategori: SubKategori) => {
setData({ ...subkategori });
setEditing(true);
setIsModalOpen(true);
};
const handleDelete = () => {
if (deleteConfirm) {
destroy(`/admin/subkategori/${deleteConfirm.SubKategoriId}`, {
onSuccess: () => {
toast({
title: "Berhasil",
description: "Sub Kategori berhasil dihapus",
variant: "default",
});
setDeleteConfirm(null);
},
onError: () => {
toast({
title: "Gagal",
description:
"Terjadi kesalahan saat menghapus Sub Kategori",
variant: "destructive",
});
setDeleteConfirm(null);
},
});
}
};
return (
<AuthenticatedLayout header={"Sub Kategori Post"}>
<Head title="Sub Kategori Post" />
<div className="container mx-auto p-4">
<Card className="shadow-lg">
<CardHeader>
<div className="flex flex-col space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-4 w-4" />
<Input
type="text"
placeholder="Cari Sub Kategori..."
value={search}
onChange={handleSearch}
className="pl-10 pr-4 w-full border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 rounded-lg"
/>
</div>
<Dialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
>
<DialogTrigger asChild>
<Button
onClick={() => {
setEditing(false);
reset();
setIsModalOpen(true);
}}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors duration-200"
>
<Plus className="h-4 w-4" />
Buat Sub Kategori
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{editing
? "Ubah Sub Kategori"
: "Buat Sub Kategori"}
</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 mt-4"
>
<div className="space-y-2">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Kategori
</label>
<Select
value={data.KategoriId.toString()}
onValueChange={(
value
) =>
setData(
"KategoriId",
parseInt(value)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Pilih Kategori" />
</SelectTrigger>
<SelectContent>
{kategori.length >
0 ? (
kategori.map(
(
kategori
) => (
<SelectItem
key={
kategori.KategoriId
}
value={kategori.KategoriId.toString()}
>
{
kategori.NamaKategori
}
</SelectItem>
)
)
) : (
<div className="p-2 text-gray-500">
Tidak ada
kategori
</div>
)}
</SelectContent>
</Select>
</div>
<label className="text-sm font-medium text-gray-700">
Sub Kategori
</label>
<Input
type="text"
placeholder="Masukkan Nama Sub Kategori"
value={data.NamaSubKategori}
onChange={(e) =>
setData(
"NamaSubKategori",
e.target.value
)
}
className="w-full"
/>
{/* <div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Publish
</label>
<Select
value={
data.is_active
? "true"
: "false"
}
onValueChange={(
value
) =>
setData(
"is_active",
value === "true"
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
Published
</SelectItem>
<SelectItem value="false">
Unpublished
</SelectItem>
</SelectContent>
</Select>
</div> */}
</div>
<DialogFooter className="mt-6">
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{editing
? "Perbarui"
: "Buat"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-black/50">
{/* <TableHead className="font-semibold">
ID
</TableHead> */}
<TableHead className="font-semibold">
No
</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="font-semibold">
Sub Kategori
</TableHead>
{/* <TableHead className="font-semibold">
Status
</TableHead> */}
<TableHead className="font-semibold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentItems.map((subkategori, index) => (
<TableRow
key={
subkategori.SubKategoriId ||
"new"
}
className="hover:bg-gray-50 transition-colors duration-150"
>
{/* <TableCell>{subkategori.id}</TableCell> */}
<TableCell>
{startIndex + index + 1}
</TableCell>
<TableCell>
{kategori.find(
(c) =>
c.KategoriId ===
subkategori.KategoriId
)?.NamaKategori || "-"}
</TableCell>
<TableCell>
{subkategori.NamaSubKategori}
</TableCell>
{/* <TableCell>
{subkategori.is_active
? "Published"
: "Unpublished"}
</TableCell> */}
<TableCell className="flex gap-2">
<Button
onClick={() =>
handleEdit(subkategori)
}
variant="outline"
className="flex items-center gap-2"
>
<Pencil className="h-4 w-4" />
Edit
</Button>
<Button
onClick={() =>
setDeleteConfirm(
subkategori
)
}
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</TableCell>
</TableRow>
))}
{currentItems.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-4 text-gray-500"
>
Tidak ada data
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* <div className="flex flex-row mt-3">
<div className="text-sm w-1/2">
1 : Pengumuman <br /> 2 : Undangan
</div>
<div className="text-sm w-1/2">
3 : Peraturan <br /> 4 : Popup Home
</div>
</div> */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {startIndex + 1} to{" "}
{Math.min(endIndex, filteredKategori.length)} of{" "}
{filteredKategori.length} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage - 1)
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from(
{ length: totalPages },
(_, i) => i + 1
).map((page) => (
<Button
key={page}
variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
handlePageChange(currentPage + 1)
}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{deleteConfirm && (
<Dialog open={true} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Konfirmasi Penghapusan
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-gray-600">
Apakah Anda yakin ingin menghapus "
<span className="font-medium">
{deleteConfirm.NamaSubKategori}
</span>
"?
<br />
<span className="text-red-500">
Tindakan ini tidak dapat dibatalkan.
</span>
</p>
</div>
<DialogFooter className="gap-2">
<Button
onClick={() => setDeleteConfirm(null)}
variant="outline"
>
Batal
</Button>
<Button
onClick={handleDelete}
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Ya, hapus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<Toaster />
</AuthenticatedLayout>
);
}

View File

@ -14,7 +14,7 @@ export default function Login({
canResetPassword: boolean;
}) {
const { data, setData, post, processing, errors, reset } = useForm({
email: "",
login: "",
password: "",
remember: false,
});
@ -73,19 +73,19 @@ export default function Login({
</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={data.email}
type="text"
placeholder="m@example.com/username"
value={data.login}
onChange={(e) =>
setData(
"email",
"login",
e.target.value
)
}
required
/>
<InputError
message={errors.email}
message={errors.login}
/>
</div>
<div className="grid gap-2">

View File

@ -0,0 +1,100 @@
import React, { useState } from "react";
import { useForm } from "@inertiajs/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Plus, Pencil, Trash2 } from "lucide-react";
import AuthenticatedLayout from "@/layouts/authenticated-layout";
import { Head } from "@inertiajs/react";
import { Toaster } from "@/components/ui/toaster";
interface Menu {
id: number | null;
name: string;
is_active: boolean;
}
export default function Index({ menus }: { menus: Menu[] }) {
const {
data,
setData,
post,
put,
delete: destroy,
reset,
} = useForm<Menu>({
id: null,
name: "",
is_active: true,
});
const [isModalOpen, setIsModalOpen] = useState(false);
const [editing, setEditing] = useState(false);
return (
<AuthenticatedLayout header="Manajemen Menu">
<Head title="Manajemen Menu" />
<div className="container mx-auto p-4">
<Button
onClick={() => {
setEditing(false);
reset();
setIsModalOpen(true);
}}
>
<Plus className="h-4 w-4" /> Tambah Menu
</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nama Menu</TableHead>
<TableHead>Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{menus.map((menu) => (
<TableRow key={menu.id}>
<TableCell>{menu.name}</TableCell>
<TableCell>
<Button
onClick={() => {
setEditing(true);
setIsModalOpen(true);
setData(menu);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
onClick={() =>
destroy(`/menus/${menu.id}`)
}
variant="destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Toaster />
</AuthenticatedLayout>
);
}

View File

@ -1,6 +1,14 @@
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\KategoriController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\RefCategoryController;
use App\Http\Controllers\RefSubCategoryController;
use App\Http\Controllers\SubCategoryController;
use App\Http\Controllers\SubKategoriController;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -39,6 +47,8 @@ Route::get('/peraturan', function () {
return Inertia::render('Peraturan');
});
// Dashboard Route
Route::get('/dashboard', function () {
return Inertia::render('dashboard');
})->middleware(['auth'])->name('dashboard');
@ -49,4 +59,30 @@ Route::middleware(['auth'])->group(function () {
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
Route::middleware(['auth'])->prefix('admin')->group(function () {
Route::get('/kategori', [KategoriController::class, 'index'])->name('admin.kategori.index');
Route::post('/kategori', [KategoriController::class, 'store'])->name('admin.kategori.store');
Route::put('/kategori/{kategori}', [KategoriController::class, 'update'])->name('admin.kategori.update');
Route::delete('/kategori/{kategori}', [KategoriController::class, 'destroy'])->name('admin.kategori.destroy');
});
Route::middleware(['auth'])->prefix('admin')->group(function () {
Route::get('/subkategori', [SubKategoriController::class, 'index'])->name('admin.subkategori.index');
Route::post('/subkategori', [SubKategoriController::class, 'store'])->name('admin.subkategori.store');
Route::put('/subkategori/{subkategori}', [SubKategoriController::class, 'update'])->name('admin.subkategori.update');
Route::delete('/subkategori/{subkategori}', [SubKategoriController::class, 'destroy'])->name('admin.subkategori.destroy');
});
Route::middleware(['auth'])->prefix('admin')->group(function () {
Route::get('/post', [PostController::class, 'index'])->name('admin.post.index');
Route::get('/post/add', [PostController::class, 'create'])->name('admin.post.create');
Route::post('/post', [PostController::class, 'store'])->name('admin.post.store');
Route::get('/post/edit/{post}', [PostController::class, 'edit'])->name('admin.post.edit');
Route::put('/post/{post}', [PostController::class, 'update'])->name('admin.post.update');
Route::delete('/post/{post}', [PostController::class, 'destroy'])->name('admin.post.destroy');
});
require __DIR__.'/auth.php';