feat: implement feedback system with CRUD functionality #32
19
app/Enums/FeedbackStatus.php
Normal file
19
app/Enums/FeedbackStatus.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum FeedbackStatus: string
|
||||
{
|
||||
case Submitted = 'submitted';
|
||||
case InReview = 'in_review';
|
||||
case Resolved = 'resolved';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Submitted => 'Terkirim',
|
||||
self::InReview => 'Ditinjau',
|
||||
self::Resolved => 'Selesai',
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/FeedbackType.php
Normal file
19
app/Enums/FeedbackType.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum FeedbackType: string
|
||||
{
|
||||
case Kritik = 'kritik';
|
||||
case Saran = 'saran';
|
||||
case Aduan = 'aduan';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Kritik => 'Kritik',
|
||||
self::Saran => 'Saran',
|
||||
self::Aduan => 'Aduan',
|
||||
};
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/FeedbackController.php
Normal file
58
app/Http/Controllers/FeedbackController.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\FeedbackType;
|
||||
use App\Http\Requests\FeedbackRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Feedback;
|
||||
use App\Services\FeedbackService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FeedbackController extends Controller
|
||||
{
|
||||
public function __construct(private readonly FeedbackService $service) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('feedback/index', [
|
||||
'feedbacks' => $this->service->paginated($request->user(), ...$request->validatedWithDefaults()),
|
||||
'types' => array_map(fn (FeedbackType $type) => [
|
||||
'value' => $type->value,
|
||||
'label' => $type->label(),
|
||||
], FeedbackType::cases()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(FeedbackRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->user(), $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dikirim.']);
|
||||
|
||||
return to_route('feedback.index');
|
||||
}
|
||||
|
||||
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
|
||||
{
|
||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||
|
||||
$this->service->update($feedback, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']);
|
||||
|
||||
return to_route('feedback.index');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Feedback $feedback): RedirectResponse
|
||||
{
|
||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||
|
||||
$this->service->delete($feedback);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/FeedbackRequest.php
Normal file
24
app/Http/Requests/FeedbackRequest.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\FeedbackType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class FeedbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', Rule::in(array_column(FeedbackType::cases(), 'value'))],
|
||||
'subject' => ['required', 'string', 'max:150'],
|
||||
'message' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Models/Feedback.php
Normal file
31
app/Models/Feedback.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FeedbackStatus;
|
||||
use App\Enums\FeedbackType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Feedback extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'feedbacks';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => FeedbackType::class,
|
||||
'status' => FeedbackStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -55,4 +55,9 @@ public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
|
||||
76
app/Services/FeedbackService.php
Normal file
76
app/Services/FeedbackService.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Feedback;
|
||||
use App\Models\User;
|
||||
use App\Support\TextAlignAttributeSanitizer;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
|
||||
class FeedbackService
|
||||
{
|
||||
private readonly HtmlSanitizer $sanitizer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = (new HtmlSanitizerConfig)
|
||||
->allowElement('p', ['style'])
|
||||
->allowElement('h2', ['style'])
|
||||
->allowElement('h3', ['style'])
|
||||
->allowElement('strong')
|
||||
->allowElement('b')
|
||||
->allowElement('em')
|
||||
->allowElement('i')
|
||||
->allowElement('u')
|
||||
->allowElement('s')
|
||||
->allowElement('strike')
|
||||
->allowElement('ul')
|
||||
->allowElement('ol')
|
||||
->allowElement('li')
|
||||
->allowElement('blockquote')
|
||||
->allowElement('br')
|
||||
->allowElement('a', ['href'])
|
||||
->allowElement('img', ['src', 'alt'])
|
||||
->allowLinkSchemes(['http', 'https', 'mailto'])
|
||||
->allowMediaSchemes(['https'])
|
||||
->withAttributeSanitizer(new TextAlignAttributeSanitizer);
|
||||
|
||||
$this->sanitizer = new HtmlSanitizer($config);
|
||||
}
|
||||
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return Feedback::query()
|
||||
->where('user_id', $user->id)
|
||||
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(User $user, array $data): Feedback
|
||||
{
|
||||
return Feedback::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $data['type'],
|
||||
'subject' => $data['subject'],
|
||||
'message' => $this->sanitizer->sanitize($data['message']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Feedback $feedback, array $data): Feedback
|
||||
{
|
||||
$feedback->type = $data['type'];
|
||||
$feedback->subject = $data['subject'];
|
||||
$feedback->message = $this->sanitizer->sanitize($data['message']);
|
||||
$feedback->update();
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public function delete(Feedback $feedback): bool
|
||||
{
|
||||
return $feedback->delete();
|
||||
}
|
||||
}
|
||||
32
app/Support/TextAlignAttributeSanitizer.php
Normal file
32
app/Support/TextAlignAttributeSanitizer.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
|
||||
|
||||
/**
|
||||
* Allows only `style="text-align: left|center|right|justify"` (as emitted by
|
||||
* Tiptap's TextAlign extension) through the sanitizer's `style` attribute.
|
||||
*/
|
||||
class TextAlignAttributeSanitizer implements AttributeSanitizerInterface
|
||||
{
|
||||
public function getSupportedElements(): ?array
|
||||
{
|
||||
return ['p', 'h2', 'h3'];
|
||||
}
|
||||
|
||||
public function getSupportedAttributes(): ?array
|
||||
{
|
||||
return ['style'];
|
||||
}
|
||||
|
||||
public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string
|
||||
{
|
||||
if (preg_match('/^text-align:\s*(left|center|right|justify);?$/i', trim($value)) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,8 @@
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"spatie/laravel-medialibrary": "^11.23",
|
||||
"spatie/laravel-permission": "^8.3"
|
||||
"spatie/laravel-permission": "^8.3",
|
||||
"symfony/html-sanitizer": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
|
||||
74
composer.lock
generated
74
composer.lock
generated
@ -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": "05388ded27fad46309907544186b1146",
|
||||
"content-hash": "3fabe2a835a47ecc56faf22b029cda13",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -5539,6 +5539,78 @@
|
||||
],
|
||||
"time": "2026-06-27T09:05:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/html-sanitizer",
|
||||
"version": "v8.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/html-sanitizer.git",
|
||||
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
|
||||
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"league/uri": "^6.5|^7.0",
|
||||
"php": ">=8.4.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HtmlSanitizer\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "galopintitouan@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"Purifier",
|
||||
"html",
|
||||
"sanitizer"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v8.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-06T11:11:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.1.1",
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FeedbackStatus;
|
||||
use App\Enums\FeedbackType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('feedbacks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('type', array_column(FeedbackType::cases(), 'value'));
|
||||
$table->string('subject', 150);
|
||||
$table->text('message');
|
||||
$table->enum('status', array_column(FeedbackStatus::cases(), 'value'))->default(FeedbackStatus::Submitted->value);
|
||||
$table->text('admin_notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('feedbacks');
|
||||
}
|
||||
};
|
||||
4940
package-lock.json
generated
4940
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -54,6 +54,14 @@
|
||||
"@tabler/icons-react": "^3.46.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-image": "^3.30.3",
|
||||
"@tiptap/extension-link": "^3.30.3",
|
||||
"@tiptap/extension-placeholder": "^3.30.3",
|
||||
"@tiptap/extension-text-align": "^3.30.3",
|
||||
"@tiptap/extension-underline": "^3.30.3",
|
||||
"@tiptap/pm": "^3.30.3",
|
||||
"@tiptap/react": "^3.30.3",
|
||||
"@tiptap/starter-kit": "^3.30.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
@import '@fontsource-variable/inter';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@ -65,137 +65,74 @@ @theme {
|
||||
}
|
||||
|
||||
:root {
|
||||
--background:
|
||||
oklch(1 0 0);
|
||||
--foreground:
|
||||
oklch(0.145 0 0);
|
||||
--card:
|
||||
oklch(1 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.145 0 0);
|
||||
--popover:
|
||||
oklch(1 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.145 0 0);
|
||||
--primary:
|
||||
oklch(0.852 0.199 91.936);
|
||||
--primary-foreground:
|
||||
oklch(0.421 0.095 57.708);
|
||||
--secondary:
|
||||
oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground:
|
||||
oklch(0.21 0.006 285.885);
|
||||
--muted:
|
||||
oklch(0.97 0 0);
|
||||
--muted-foreground:
|
||||
oklch(0.556 0 0);
|
||||
--accent:
|
||||
oklch(0.97 0 0);
|
||||
--accent-foreground:
|
||||
oklch(0.205 0 0);
|
||||
--destructive:
|
||||
oklch(0.577 0.245 27.325);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.852 0.199 91.936);
|
||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border:
|
||||
oklch(0.922 0 0);
|
||||
--input:
|
||||
oklch(0.922 0 0);
|
||||
--ring:
|
||||
oklch(0.708 0 0);
|
||||
--chart-1:
|
||||
oklch(0.905 0.182 98.111);
|
||||
--chart-2:
|
||||
oklch(0.795 0.184 86.047);
|
||||
--chart-3:
|
||||
oklch(0.681 0.162 75.834);
|
||||
--chart-4:
|
||||
oklch(0.554 0.135 66.442);
|
||||
--chart-5:
|
||||
oklch(0.476 0.114 61.907);
|
||||
--radius:
|
||||
0.625rem;
|
||||
--sidebar:
|
||||
oklch(0.985 0 0);
|
||||
--sidebar-foreground:
|
||||
oklch(0.145 0 0);
|
||||
--sidebar-primary:
|
||||
oklch(0.681 0.162 75.834);
|
||||
--sidebar-primary-foreground:
|
||||
oklch(0.987 0.026 102.212);
|
||||
--sidebar-accent:
|
||||
oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground:
|
||||
oklch(0.205 0 0);
|
||||
--sidebar-border:
|
||||
oklch(0.922 0 0);
|
||||
--sidebar-ring:
|
||||
oklch(0.708 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.905 0.182 98.111);
|
||||
--chart-2: oklch(0.795 0.184 86.047);
|
||||
--chart-3: oklch(0.681 0.162 75.834);
|
||||
--chart-4: oklch(0.554 0.135 66.442);
|
||||
--chart-5: oklch(0.476 0.114 61.907);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.681 0.162 75.834);
|
||||
--sidebar-primary-foreground: oklch(0.987 0.026 102.212);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background:
|
||||
oklch(0.145 0 0);
|
||||
--foreground:
|
||||
oklch(0.985 0 0);
|
||||
--card:
|
||||
oklch(0.205 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--popover:
|
||||
oklch(0.205 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--primary:
|
||||
oklch(0.795 0.184 86.047);
|
||||
--primary-foreground:
|
||||
oklch(0.421 0.095 57.708);
|
||||
--secondary:
|
||||
oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--muted:
|
||||
oklch(0.269 0 0);
|
||||
--muted-foreground:
|
||||
oklch(0.708 0 0);
|
||||
--accent:
|
||||
oklch(0.269 0 0);
|
||||
--accent-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--destructive:
|
||||
oklch(0.704 0.191 22.216);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.795 0.184 86.047);
|
||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border:
|
||||
oklch(1 0 0 / 10%);
|
||||
--input:
|
||||
oklch(1 0 0 / 15%);
|
||||
--ring:
|
||||
oklch(0.556 0 0);
|
||||
--chart-1:
|
||||
oklch(0.905 0.182 98.111);
|
||||
--chart-2:
|
||||
oklch(0.795 0.184 86.047);
|
||||
--chart-3:
|
||||
oklch(0.681 0.162 75.834);
|
||||
--chart-4:
|
||||
oklch(0.554 0.135 66.442);
|
||||
--chart-5:
|
||||
oklch(0.476 0.114 61.907);
|
||||
--sidebar:
|
||||
oklch(0.205 0 0);
|
||||
--sidebar-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--sidebar-primary:
|
||||
oklch(0.795 0.184 86.047);
|
||||
--sidebar-primary-foreground:
|
||||
oklch(0.987 0.026 102.212);
|
||||
--sidebar-accent:
|
||||
oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--sidebar-border:
|
||||
oklch(1 0 0 / 10%);
|
||||
--sidebar-ring:
|
||||
oklch(0.556 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.905 0.182 98.111);
|
||||
--chart-2: oklch(0.795 0.184 86.047);
|
||||
--chart-3: oklch(0.681 0.162 75.834);
|
||||
--chart-4: oklch(0.554 0.135 66.442);
|
||||
--chart-5: oklch(0.476 0.114 61.907);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||
--sidebar-primary-foreground: oklch(0.987 0.026 102.212);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@ -248,84 +185,52 @@ @layer base {
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading:
|
||||
var(--font-sans);
|
||||
--font-sans:
|
||||
'Inter Variable', sans-serif;
|
||||
--color-sidebar-ring:
|
||||
var(--sidebar-ring);
|
||||
--color-sidebar-border:
|
||||
var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground:
|
||||
var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent:
|
||||
var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground:
|
||||
var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary:
|
||||
var(--sidebar-primary);
|
||||
--color-sidebar-foreground:
|
||||
var(--sidebar-foreground);
|
||||
--color-sidebar:
|
||||
var(--sidebar);
|
||||
--color-chart-5:
|
||||
var(--chart-5);
|
||||
--color-chart-4:
|
||||
var(--chart-4);
|
||||
--color-chart-3:
|
||||
var(--chart-3);
|
||||
--color-chart-2:
|
||||
var(--chart-2);
|
||||
--color-chart-1:
|
||||
var(--chart-1);
|
||||
--color-ring:
|
||||
var(--ring);
|
||||
--color-input:
|
||||
var(--input);
|
||||
--color-border:
|
||||
var(--border);
|
||||
--color-destructive:
|
||||
var(--destructive);
|
||||
--color-accent-foreground:
|
||||
var(--accent-foreground);
|
||||
--color-accent:
|
||||
var(--accent);
|
||||
--color-muted-foreground:
|
||||
var(--muted-foreground);
|
||||
--color-muted:
|
||||
var(--muted);
|
||||
--color-secondary-foreground:
|
||||
var(--secondary-foreground);
|
||||
--color-secondary:
|
||||
var(--secondary);
|
||||
--color-primary-foreground:
|
||||
var(--primary-foreground);
|
||||
--color-primary:
|
||||
var(--primary);
|
||||
--color-popover-foreground:
|
||||
var(--popover-foreground);
|
||||
--color-popover:
|
||||
var(--popover);
|
||||
--color-card-foreground:
|
||||
var(--card-foreground);
|
||||
--color-card:
|
||||
var(--card);
|
||||
--color-foreground:
|
||||
var(--foreground);
|
||||
--color-background:
|
||||
var(--background);
|
||||
--radius-sm:
|
||||
calc(var(--radius) * 0.6);
|
||||
--radius-md:
|
||||
calc(var(--radius) * 0.8);
|
||||
--radius-lg:
|
||||
var(--radius);
|
||||
--radius-xl:
|
||||
calc(var(--radius) * 1.4);
|
||||
--radius-2xl:
|
||||
calc(var(--radius) * 1.8);
|
||||
--radius-3xl:
|
||||
calc(var(--radius) * 2.2);
|
||||
--radius-4xl:
|
||||
calc(var(--radius) * 2.6);
|
||||
}
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Inter Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@ import { index as departmentsRoute } from '@/routes/admin/master/departments';
|
||||
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
|
||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||
import { index as studentsRoute } from '@/routes/admin/users/students';
|
||||
import { index as feedbackRoute } from '@/routes/feedback';
|
||||
|
||||
const data: {
|
||||
navMain: (NavGroup | NavItem)[];
|
||||
@ -184,7 +185,7 @@ const data: {
|
||||
navSecondary: [
|
||||
{
|
||||
title: 'Kritik dan Saran',
|
||||
url: '#',
|
||||
url: feedbackRoute.url(),
|
||||
icon: IconMessageDots,
|
||||
},
|
||||
{
|
||||
|
||||
515
resources/js/components/rich-text-editor.tsx
Normal file
515
resources/js/components/rich-text-editor.tsx
Normal file
@ -0,0 +1,515 @@
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImagePlus,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Redo2,
|
||||
Underline as UnderlineIcon,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MediaItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TiptapEditorProps = {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
modelType?: string;
|
||||
modelId?: number | string;
|
||||
collection?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||
'hover:bg-muted hover:text-muted-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <div className="h-6 w-px bg-border" />;
|
||||
}
|
||||
|
||||
export default function TiptapEditor({
|
||||
value = '',
|
||||
onChange,
|
||||
name = 'content',
|
||||
placeholder = 'Tulis konten di sini...',
|
||||
modelType = 'news',
|
||||
modelId = 0,
|
||||
collection = 'content',
|
||||
error,
|
||||
}: TiptapEditorProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [showLinkInput, setShowLinkInput] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [draftId, setDraftId] = useState<number | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [2, 3],
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-primary underline cursor-pointer',
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: value,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange?.(e.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
'prose prose-sm sm:prose-base max-w-none',
|
||||
'min-h-[300px] w-full rounded-b-md px-3 py-2',
|
||||
'focus:outline-none',
|
||||
'[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold',
|
||||
'[&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold',
|
||||
'[&_p]:mb-2',
|
||||
'[&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-6',
|
||||
'[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6',
|
||||
'[&_li]:mb-1',
|
||||
'[&_img]:my-4 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic',
|
||||
'placeholder:text-muted-foreground',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
const getCsrfToken = () =>
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') ?? '';
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const token = getCsrfToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': token,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
let currentModelId =
|
||||
draftId ?? (modelId ? Number(modelId) : null);
|
||||
|
||||
if (!currentModelId) {
|
||||
const initRes = await fetch(`/media/${modelType}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
throw new Error('Gagal membuat draft.');
|
||||
}
|
||||
|
||||
const { id } = await initRes.json();
|
||||
currentModelId = id;
|
||||
setDraftId(id);
|
||||
}
|
||||
|
||||
const presignedRes = await fetch(
|
||||
`/media/${modelType}/${currentModelId}/presigned-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
file_name: file.name,
|
||||
mime_type: file.type,
|
||||
size: file.size,
|
||||
collection,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!presignedRes.ok) {
|
||||
throw new Error('Gagal mendapatkan URL unggahan.');
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
presigned_url,
|
||||
headers: putHeaders,
|
||||
} = await presignedRes.json();
|
||||
|
||||
await fetch(presigned_url, {
|
||||
method: 'PUT',
|
||||
headers: putHeaders,
|
||||
body: file,
|
||||
});
|
||||
|
||||
const completeRes = await fetch(`/media/item/${id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': token,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
throw new Error('Gagal menyelesaikan unggahan.');
|
||||
}
|
||||
|
||||
const media: MediaItem = await completeRes.json();
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: media.url, alt: file.name })
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Image upload error:', err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[editor, modelType, modelId, collection, draftId],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
[handleImageUpload],
|
||||
);
|
||||
|
||||
const handleSetLink = useCallback(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (linkUrl) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: linkUrl })
|
||||
.run();
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}
|
||||
|
||||
setShowLinkInput(false);
|
||||
setLinkUrl('');
|
||||
}, [editor, linkUrl]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-md border border-input">
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-border p-1">
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleBold().run()
|
||||
}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleItalic().run()
|
||||
}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
isActive={editor.isActive('underline')}
|
||||
title="Underline"
|
||||
>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: 2 })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: 3 })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('left').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('right').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
setShowLinkInput(true);
|
||||
}
|
||||
}}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Insert Link"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
title="Insert Image"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLinkInput && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSetLink();
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setShowLinkInput(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetLink}
|
||||
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLinkInput(false);
|
||||
setLinkUrl('');
|
||||
}}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<input type="hidden" name={name} value={editor.getHTML()} />
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
resources/js/pages/feedback/columns.tsx
Normal file
122
resources/js/pages/feedback/columns.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Feedback, FeedbackStatusValue } from '@/types/feedback';
|
||||
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
|
||||
|
||||
export type { Feedback } from '@/types/feedback';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (feedback: Feedback) => void;
|
||||
handleDeleteClick: (feedback: Feedback) => void;
|
||||
};
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function statusBadgeVariant(
|
||||
status: FeedbackStatusValue,
|
||||
): 'outline' | 'secondary' | 'default' {
|
||||
if (status === 'resolved') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (status === 'in_review') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export function createFeedbackColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Feedback>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'subject',
|
||||
header: () => <span>Subjek</span>,
|
||||
cell: ({ row }) => {
|
||||
const feedback = row.original;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-medium">{feedback.subject}</p>
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{stripHtml(feedback.message)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => <span className="block text-center">Jenis</span>,
|
||||
meta: {
|
||||
className: 'w-[110px] text-center',
|
||||
headerClassName: 'w-[110px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<Badge variant="outline">
|
||||
{FeedbackTypeLabels[row.original.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span className="block text-center">Status</span>,
|
||||
meta: {
|
||||
className: 'w-[130px] text-center',
|
||||
headerClassName: 'w-[130px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<Badge variant={statusBadgeVariant(row.original.status)}>
|
||||
{FeedbackStatusLabels[row.original.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => <span>Dikirim</span>,
|
||||
cell: ({ row }) =>
|
||||
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(row.original),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(row.original),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
276
resources/js/pages/feedback/index.tsx
Normal file
276
resources/js/pages/feedback/index.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import TiptapEditor from '@/components/rich-text-editor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as feedbackIndex,
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/feedback';
|
||||
import type { Feedback } from '@/types/feedback';
|
||||
import { createFeedbackColumns } from './columns';
|
||||
|
||||
type FeedbackTypeOption = { value: string; label: string };
|
||||
|
||||
type Props = {
|
||||
feedbacks: {
|
||||
data: Feedback[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
types: FeedbackTypeOption[];
|
||||
};
|
||||
|
||||
export default function FeedbackIndex({ feedbacks, types }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Feedback | null>(null);
|
||||
const [deleting, setDeleting] = useState<Feedback | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: feedbacks.current_page,
|
||||
last_page: feedbacks.last_page,
|
||||
per_page: feedbacks.per_page,
|
||||
total: feedbacks.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => feedbackIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createFeedbackColumns({
|
||||
handleEdit: (feedback) => setEditing(feedback),
|
||||
handleDeleteClick: (feedback) => setDeleting(feedback),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Kritik dan Saran" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Kritik dan Saran"
|
||||
description={
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sampaikan kritik, saran, atau aduan Anda kepada
|
||||
kami.
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
types={types}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
key={editing?.id}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
editing={editing}
|
||||
types={types}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={feedbacks.data}
|
||||
emptyText="Belum ada kritik atau saran yang dikirim."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
searchKey="subject"
|
||||
searchPlaceholder="Cari subjek..."
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Masukan"
|
||||
description={(feedback) =>
|
||||
`Apakah Anda yakin ingin menghapus "${feedback.subject}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackFields({
|
||||
errors,
|
||||
editing,
|
||||
types,
|
||||
}: {
|
||||
errors: Record<string, string>;
|
||||
editing?: Feedback;
|
||||
types: FeedbackTypeOption[];
|
||||
}) {
|
||||
const [message, setMessage] = useState(editing?.message ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jenis <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{!editing && <input type="hidden" name="type" />}
|
||||
<Select name="type" defaultValue={editing?.type ?? 'kritik'}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih jenis" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{types.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.type} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={editing ? 'edit-subject' : 'subject'}>
|
||||
Subjek <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={editing ? 'edit-subject' : 'subject'}
|
||||
name="subject"
|
||||
placeholder="Ringkasan singkat"
|
||||
defaultValue={editing?.subject ?? ''}
|
||||
/>
|
||||
<InputError message={errors.subject} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Pesan <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<TiptapEditor
|
||||
name="message"
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
|
||||
error={errors.message}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
types,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
types: FeedbackTypeOption[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Kritik dan Saran"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<FeedbackFields errors={errors} types={types} />
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
types,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: Feedback | null;
|
||||
types: FeedbackTypeOption[];
|
||||
}) {
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Kritik dan Saran"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<FeedbackFields
|
||||
errors={errors}
|
||||
editing={editing}
|
||||
types={types}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
30
resources/js/types/feedback.ts
Normal file
30
resources/js/types/feedback.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export const FeedbackTypes = ['kritik', 'saran', 'aduan'] as const;
|
||||
|
||||
export type FeedbackTypeValue = (typeof FeedbackTypes)[number];
|
||||
|
||||
export const FeedbackTypeLabels: Record<FeedbackTypeValue, string> = {
|
||||
kritik: 'Kritik',
|
||||
saran: 'Saran',
|
||||
aduan: 'Aduan',
|
||||
};
|
||||
|
||||
export const FeedbackStatuses = ['submitted', 'in_review', 'resolved'] as const;
|
||||
|
||||
export type FeedbackStatusValue = (typeof FeedbackStatuses)[number];
|
||||
|
||||
export const FeedbackStatusLabels: Record<FeedbackStatusValue, string> = {
|
||||
submitted: 'Terkirim',
|
||||
in_review: 'Ditinjau',
|
||||
resolved: 'Selesai',
|
||||
};
|
||||
|
||||
export type Feedback = {
|
||||
id: number;
|
||||
type: FeedbackTypeValue;
|
||||
subject: string;
|
||||
message: string;
|
||||
status: FeedbackStatusValue;
|
||||
admin_notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\FeedbackController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -14,6 +15,8 @@
|
||||
Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->name('read');
|
||||
Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
Route::resource('feedback', FeedbackController::class)->except(['create', 'edit', 'show']);
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user