Add Catalog Editor functionality from commit 847879c4

This commit is contained in:
Remco
2026-01-24 21:13:30 +01:00
parent 858143a69e
commit d9be189436
14 changed files with 1781 additions and 1 deletions
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors;
use App\Models\Game\Furniture\CatalogPage;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class CatalogEditorResource extends Resource
{
protected static ?string $model = CatalogPage::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static string|\UnitEnum|null $navigationGroup = 'Hotel';
protected static ?string $navigationLabel = 'Catalog Editor';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')->label('ID')->sortable(),
TextColumn::make('caption')->label('Page Name')->searchable(),
TextColumn::make('parent_id')->label('Parent ID'),
TextColumn::make('order_num')->label('Order'),
IconColumn::make('visible')->boolean()->label('Visible'),
IconColumn::make('enabled')->boolean()->label('Enabled'),
])
->recordActions([])
->toolbarActions([]);
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function getPages(): array
{
return [
'index' => Pages\ManageCatalogEditor::route('/'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors\Pages;
use App\Filament\Resources\Hotel\CatalogEditors\CatalogEditorResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCatalogEditor extends CreateRecord
{
protected static string $resource = CatalogEditorResource::class;
}
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors\Pages;
use App\Filament\Resources\Hotel\CatalogEditors\CatalogEditorResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCatalogEditor extends EditRecord
{
protected static string $resource = CatalogEditorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors\Pages;
use App\Filament\Resources\Hotel\CatalogEditors\CatalogEditorResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCatalogEditors extends ListRecords
{
protected static string $resource = CatalogEditorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,957 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors\Pages;
use App\Filament\Resources\Hotel\CatalogEditors\CatalogEditorResource;
use App\Models\Game\Furniture\CatalogItem;
use App\Models\Game\Furniture\CatalogPage;
use App\Models\Miscellaneous\WebsiteSetting;
use Filament\Actions\Action as FilamentAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Facades\DB;
class ManageCatalogEditor extends Page implements HasTable
{
use InteractsWithTable;
protected static string $resource = CatalogEditorResource::class;
protected string $view = 'filament.resources.hotel.catalog-editors.pages.manage-catalog-editor';
public string $search = '';
public string $pageSearch = '';
public ?CatalogPage $selectedPage = null;
public array $expandedPages = [];
public array $selectedItemIds = [];
/**
* Escape LIKE wildcards for literal searches.
* MariaDB/MySQL: use with "... LIKE ? ESCAPE '\'"
*/
protected function escapeLike(string $value, string $escapeChar = '\\'): string
{
return str_replace(
[$escapeChar, '%', '_'],
[$escapeChar . $escapeChar, $escapeChar . '%', $escapeChar . '_'],
$value,
);
}
public function selectPage(int $pageId): void
{
$this->selectedPage = CatalogPage::find($pageId);
$this->selectedItemIds = [];
if ($this->pageSearch !== '') {
$this->pageSearch = '';
}
$this->expandedPages = $this->collectParentIds($pageId);
$this->resetTable();
}
protected function collectParentIds(int $pageId): array
{
$pages = CatalogPage::pluck('parent_id', 'id');
$ids = [$pageId];
$parentId = $pages[$pageId] ?? null;
while ($parentId && $parentId > 0) {
$ids[] = $parentId;
$parentId = $pages[$parentId] ?? null;
}
return array_unique($ids);
}
public function getMaxContentWidth(): ?string
{
return 'full';
}
public function resetView(): void
{
$this->pageSearch = '';
$this->selectedPage = null;
$this->expandedPages = [];
$this->selectedItemIds = [];
$this->resetTable();
Notification::make()
->title('View reset')
->body('Catalog view restored to default.')
->success()
->send();
}
public function toggleExpand(int $pageId): void
{
if (in_array($pageId, $this->expandedPages, true)) {
$this->expandedPages = array_values(array_diff($this->expandedPages, [$pageId]));
} else {
$this->expandedPages[] = $pageId;
}
}
public function isExpanded(int $pageId): bool
{
return in_array($pageId, $this->expandedPages, true);
}
public function toggleSelectItem(int $itemId, bool $ctrl = false): void
{
if ($ctrl) {
if (in_array($itemId, $this->selectedItemIds, true)) {
$this->selectedItemIds = array_values(array_diff($this->selectedItemIds, [$itemId]));
} else {
$this->selectedItemIds[] = $itemId;
}
} else {
$this->selectedItemIds = [$itemId];
}
$this->resetTable();
}
public function updatedPageSearch(): void
{
$this->resetTable();
$needle = trim($this->pageSearch);
if ($needle === '') {
return;
}
$like = '%' . $this->escapeLike($needle) . '%';
$matchingPage = CatalogPage::query()
->whereRaw("caption LIKE ? ESCAPE '\\\\'", [$like])
->first();
if ($matchingPage) {
$this->selectedPage = $matchingPage;
$this->expandedPages[] = $matchingPage->id;
$this->resetTable();
$this->dispatch('scroll-to-page', id: $matchingPage->id);
return;
}
$matchingItem = CatalogItem::query()
->whereRaw("catalog_name LIKE ? ESCAPE '\\\\'", [$like])
->orWhere('id', ctype_digit($needle) ? (int) $needle : -1)
->first();
if ($matchingItem) {
$page = CatalogPage::find($matchingItem->page_id);
if ($page) {
$this->selectedPage = $page;
$this->expandedPages[] = $page->id;
$this->selectedItemIds = [$matchingItem->id];
$this->resetTable();
$this->dispatch('scroll-to-page', id: $page->id);
Notification::make()
->title('Item found')
->body("Opened page: {$page->caption}")
->success()
->send();
}
}
}
public function getTableQuery()
{
if (! $this->selectedPage) {
return CatalogItem::query()->whereRaw('1=0');
}
$query = CatalogItem::query()
->where('page_id', $this->selectedPage->id);
if (filled($this->pageSearch)) {
$needle = trim($this->pageSearch);
$like = '%' . $this->escapeLike($needle) . '%';
$isNumeric = ctype_digit($needle);
$query->where(function ($q) use ($like, $needle, $isNumeric) {
// Text search (escaped)
$q->whereRaw("catalog_name LIKE ? ESCAPE '\\\\'", [$like]);
// Numeric search: exact matches only (faster and avoids weird casts)
if ($isNumeric) {
$n = (int) $needle;
$q->orWhere('id', $n)
->orWhere('cost_credits', $n)
->orWhere('cost_points', $n)
->orWhere('points_type', $n);
}
});
}
if (! $this->getTableSortColumn()) {
$query->orderBy('order_number')->orderBy('catalog_name')->orderBy('id');
}
return $query;
}
protected function findPrevNeighbor(CatalogItem $record): ?CatalogItem
{
return CatalogItem::query()
->where('page_id', $record->page_id)
->where('order_number', '!=', -1)
->where(function ($q) use ($record) {
$q->where('order_number', '<', $record->order_number)
->orWhere(function ($q2) use ($record) {
$q2->where('order_number', $record->order_number)
->where('id', '<', $record->id);
});
})
->orderBy('order_number', 'desc')
->orderBy('id', 'desc')
->first();
}
protected function findNextNeighbor(CatalogItem $record): ?CatalogItem
{
return CatalogItem::query()
->where('page_id', $record->page_id)
->where('order_number', '!=', -1)
->where(function ($q) use ($record) {
$q->where('order_number', '>', $record->order_number)
->orWhere(function ($q2) use ($record) {
$q2->where('order_number', $record->order_number)
->where('id', '>', $record->id);
});
})
->orderBy('order_number', 'asc')
->orderBy('id', 'asc')
->first();
}
protected function canMoveUp(CatalogItem $record): bool
{
if ($record->order_number === -1) {
return false;
}
return (bool) $this->findPrevNeighbor($record);
}
protected function canMoveDown(CatalogItem $record): bool
{
if ($record->order_number === -1) {
return false;
}
return (bool) $this->findNextNeighbor($record);
}
protected function nudgeRecord(CatalogItem $record, string $direction): void
{
if ($record->order_number === -1) {
Notification::make()->title('Locked')->body('This item is locked (order = -1).')->danger()->send();
return;
}
$neighbor = $direction === 'up'
? $this->findPrevNeighbor($record)
: $this->findNextNeighbor($record);
if (! $neighbor) {
return;
}
DB::transaction(function () use ($record, $neighbor) {
$a = $record->order_number;
$b = $neighbor->order_number;
$record->update(['order_number' => $b]);
$neighbor->update(['order_number' => $a]);
});
$this->normalizeOrderForSelectedPage();
Notification::make()->title('Order updated')->success()->send();
}
protected function normalizeOrderForSelectedPage(): void
{
if (! $this->selectedPage?->id) {
return;
}
$items = CatalogItem::query()
->where('page_id', $this->selectedPage->id)
->where('order_number', '!=', -1)
->orderBy('order_number')
->orderBy('id')
->get(['id']);
DB::transaction(function () use ($items) {
foreach ($items->values() as $index => $item) {
CatalogItem::whereKey($item->id)
->update(['order_number' => ($index + 1) * 10]);
}
});
$this->resetTable();
}
public function pageHasLockedItems(): bool
{
if (! $this->selectedPage?->id) {
return false;
}
return CatalogItem::query()
->where('page_id', $this->selectedPage->id)
->where('order_number', -1)
->exists();
}
public function autoOrderItems(): void
{
if (! $this->selectedPage?->id) {
Notification::make()->title('Select a page first')->warning()->send();
return;
}
if ($this->pageHasLockedItems()) {
Notification::make()
->title('Action not allowed')
->body('This page contains item(s) with order_number = -1. Remove or change them before auto-ordering.')
->danger()
->send();
return;
}
$affected = CatalogItem::query()
->where('page_id', $this->selectedPage->id)
->where('order_number', '!=', -1)
->update(['order_number' => 99]);
$this->resetTable();
if ($affected > 0) {
Notification::make()->title('Items auto-ordered')->body("Updated {$affected} item(s).")->success()->send();
} else {
Notification::make()->title('Nothing to update')->body('No items were changed (none on this page or all are set to -1).')->warning()->send();
}
}
public function manualOrderItems(): void
{
if (! $this->selectedPage?->id) {
Notification::make()->title('Select a page first')->warning()->send();
return;
}
if ($this->pageHasLockedItems()) {
Notification::make()->title('Action not allowed')->body('This page contains item(s) with order_number = -1. Change/remove them before manual ordering.')->danger()->send();
return;
}
$items = CatalogItem::query()
->where('page_id', $this->selectedPage->id)
->where('order_number', '!=', -1)
->orderBy('catalog_name', 'asc')
->orderBy('id', 'asc')
->get(['id']);
if ($items->isEmpty()) {
Notification::make()->title('Nothing to update')->body('No items on this page (or all are locked to -1).')->warning()->send();
return;
}
DB::transaction(function () use ($items) {
foreach ($items->values() as $index => $item) {
CatalogItem::whereKey($item->id)->update(['order_number' => ($index + 1) * 10]);
}
});
$this->resetTable();
Notification::make()->title('Items manually ordered')->body('Items sorted AZ and numbered 10, 20, 30, ')->success()->send();
}
public function table(Table $table): Table
{
return $table->paginated(false);
}
protected function getTableColumns(): array
{
return [
Tables\Columns\ViewColumn::make('select_item')
->label('')
->view('filament.tables.columns.catalog-item-select')
->viewData([
'itemId' => fn ($record) => $record->id,
'isSelected' => fn ($record) => in_array($record->id, $this->selectedItemIds, true),
])
->width('36px')
->sortable(false)
->searchable(false),
Tables\Columns\ViewColumn::make('item_display')
->label('Item')
->view('filament.tables.columns.catalog-item-draggable')
->viewData([
'icon' => fn ($record) => $this->buildFurniIconUrl($record->catalog_name),
'name' => fn ($record) => $record->catalog_name,
'itemId' => fn ($record) => $record->id,
'isSelected' => fn ($record) => in_array($record->id, $this->selectedItemIds, true),
])
->sortable(false)
->searchable(false),
Tables\Columns\TextColumn::make('cost_credits')
->label('Credits')
->sortable(),
Tables\Columns\TextColumn::make('cost_points')
->label('Points')
->sortable(),
Tables\Columns\TextColumn::make('points_type')
->label('Type')
->sortable(),
Tables\Columns\TextColumn::make('amount')
->label('Amount')
->sortable(),
Tables\Columns\TextColumn::make('order_number')
->label('Order')
->sortable()
->toggleable(),
Tables\Columns\IconColumn::make('club_only')
->boolean()
->label('Club Only')
->sortable(),
];
}
protected function getTableActions(): array
{
return [
FilamentAction::make('move_up')
->label('')
->icon('heroicon-m-chevron-up')
->color('gray')
->tooltip('Move up')
->action(fn (CatalogItem $record) => $this->nudgeRecord($record, 'up'))
->visible(fn (CatalogItem $record) => $this->pageSearch === '' && $this->canMoveUp($record))
->size('sm'),
FilamentAction::make('move_down')
->label('')
->icon('heroicon-m-chevron-down')
->color('gray')
->tooltip('Move down')
->action(fn (CatalogItem $record) => $this->nudgeRecord($record, 'down'))
->visible(fn (CatalogItem $record) => $this->pageSearch === '' && $this->canMoveDown($record))
->size('sm'),
EditAction::make('edit')
->label('Edit')
->icon('heroicon-m-pencil-square')
->modalHeading('Edit catalog item')
->modalSubmitActionLabel('Save')
->modalWidth('md')
->form([
Forms\Components\TextInput::make('cost_credits')->label('Credits')->numeric()->minValue(0)->required(),
Forms\Components\TextInput::make('cost_points')->label('Points')->numeric()->minValue(0)->required(),
Forms\Components\TextInput::make('points_type')->label('Type')->numeric()->minValue(0)->maxValue(999)->maxLength(50),
Forms\Components\TextInput::make('amount')->label('Amount')->numeric()->minValue(1)->default(1)->required(),
Forms\Components\TextInput::make('order_number')
->label('Order')
->numeric()
->minValue(-1)
->step(1)
->helperText('Use -1 to lock, or a non-negative number to sort (lower = earlier).')
->required(),
Forms\Components\Toggle::make('club_only')->label('Club only'),
])
->fillForm(fn (CatalogItem $record) => [
'cost_credits' => $record->cost_credits,
'cost_points' => $record->cost_points,
'points_type' => $record->points_type,
'amount' => $record->amount,
'order_number' => $record->order_number,
'club_only' => $record->club_only === '1',
])
->action(function (CatalogItem $record, array $data): void {
$record->update([
'cost_credits' => (int) $data['cost_credits'],
'cost_points' => (int) $data['cost_points'],
'points_type' => $data['points_type'] ?? null,
'amount' => (int) $data['amount'],
'order_number' => (int) $data['order_number'],
'club_only' => ! empty($data['club_only']) ? '1' : '0',
]);
$this->resetTable();
Notification::make()->title('Item updated')->success()->send();
}),
];
}
protected function getActions(): array
{
return [
FilamentAction::make('editPage')
->label('Edit page')
->modalHeading(function (array $arguments): string {
$page = CatalogPage::find($arguments['pageId'] ?? null);
return $page ? 'Edit: ' . $page->caption : 'Edit page';
})
->modalSubmitActionLabel('Save')
->modalWidth('3xl')
->form([
Forms\Components\TextInput::make('caption')->label('Name')->maxLength(128)->required(),
Forms\Components\TextInput::make('caption_save')
->label('Name TAG')
->maxLength(25)
->nullable()
->extraInputAttributes([
'pattern' => '[a-z]*',
'title' => 'Lowercase letters only (aÔÇôz); leave empty if you want.',
'spellcheck' => 'false',
'autocomplete' => 'off',
])
->live(onBlur: true)
->afterStateUpdated(function ($state, callable $set) {
$set('caption_save', $this->sanitizeCaptionSave($state));
})
->rules(['nullable', 'regex:/^[a-z]*$/'])
->validationMessages([
'regex' => 'Use lowercase letters only (aÔÇôz), no spaces or special characters.',
]),
Forms\Components\TextInput::make('order_num')
->label('Order')
->numeric()
->minValue(0)
->step(1)
->required()
->helperText('Lower number appears earlier in the menu.'),
Forms\Components\TextInput::make('icon_image')
->label('Icon number')
->numeric()
->minValue(1)
->required()
->default(1)
->live()
->helperText(function ($get) {
$id = (int) ($get('icon_image') ?: 1);
$url = $this->buildCatalogIconUrl($id);
$fallback = $this->buildCatalogIconUrl(1);
$html = '<div class="mt-2 flex items-center gap-3">
<img src="' . e($url) . '" alt="icon ' . e($id) . '" class="h-8 w-8 object-contain"
loading="lazy"
onerror="this.onerror=null;this.src=\'' . e($fallback) . '\'"
style="image-rendering: pixelated; image-rendering: crisp-edges;">
<span class="text-sm text-gray-600 dark:text-gray-300">Icon #' . e($id) . '</span>
</div>';
return new \Illuminate\Support\HtmlString($html);
}),
])
->fillForm(function (array $arguments): array {
$page = CatalogPage::find($arguments['pageId'] ?? null);
return [
'caption' => $page?->caption ?? '',
'caption_save' => $page?->caption_save ?? '',
'order_num' => $page?->order_num ?? 1,
'icon_image' => $page?->icon_image ?? 1,
];
})
->action(function (array $data, array $arguments): void {
$page = CatalogPage::find($arguments['pageId'] ?? null);
if (! $page) {
Notification::make()->title('Page not found')->danger()->send();
return;
}
$tag = $this->sanitizeCaptionSave($data['caption_save'] ?? '');
$icon = max(1, (int) ($data['icon_image'] ?: 1));
$page->update([
'caption' => $data['caption'],
'caption_save' => $tag,
'order_num' => (int) ($data['order_num'] ?? 1),
'icon_image' => $icon,
]);
$this->selectPage($page->id);
Notification::make()->title('Page updated')->success()->send();
}),
];
}
protected function sanitizeCaptionSave(?string $value): string
{
$value = (string) ($value ?? '');
if ($value === '') {
return '';
}
return strtolower(preg_replace('/[^a-z]/', '', $value));
}
public function reorderPage(int $pageId, int $targetPageId, string $position = 'after'): void
{
$page = CatalogPage::find($pageId);
$target = CatalogPage::find($targetPageId);
if (! $page || ! $target) {
return;
}
if ((int) $page->parent_id !== (int) $target->parent_id) {
return;
}
if ($page->id === $target->id) {
return;
}
$siblings = CatalogPage::query()
->where('parent_id', $page->parent_id)
->orderBy('order_num')
->orderBy('id')
->pluck('id')
->toArray();
$siblings = array_values(array_filter($siblings, fn ($id) => (int) $id !== (int) $page->id));
$targetIndex = array_search($target->id, $siblings, true);
if ($targetIndex === false) {
return;
}
if ($position === 'before') {
array_splice($siblings, $targetIndex, 0, [$page->id]);
} else {
array_splice($siblings, $targetIndex + 1, 0, [$page->id]);
}
DB::transaction(function () use ($siblings) {
foreach ($siblings as $i => $id) {
CatalogPage::whereKey($id)->update(['order_num' => ($i + 1) * 10]);
}
});
if ($this->selectedPage?->id) {
$this->selectedPage = CatalogPage::find($this->selectedPage->id);
}
Notification::make()->title('Menu order updated')->success()->send();
}
public function normalizePageOrder(int $parentId): void
{
$ids = CatalogPage::query()
->where('parent_id', $parentId)
->orderBy('order_num')
->orderBy('id')
->pluck('id')
->toArray();
DB::transaction(function () use ($ids) {
foreach ($ids as $i => $id) {
CatalogPage::whereKey($id)->update(['order_num' => ($i + 1) * 10]);
}
});
}
public function openEditPage(int $pageId): void
{
$this->mountAction('editPage', ['pageId' => $pageId]);
}
public function moveItemToPage(int $itemId, int $targetPageId): void
{
$this->moveItemsToPage((string) $itemId, $targetPageId);
}
public function moveItemsToPage(string $itemIdsCsv, int $targetPageId): void
{
$raw = $itemIdsCsv;
$target = CatalogPage::find($targetPageId);
$ids = collect(explode(',', $itemIdsCsv))
->map(fn ($v) => (int) trim($v))
->filter(fn ($v) => $v > 0)
->unique()
->values()
->all();
if (empty($ids) || ! $target) {
Notification::make()->title('Move failed')->body('No items selected or target page not found.')->danger()->send();
return;
}
DB::transaction(function () use ($ids, $targetPageId) {
$maxOrder = (int) (CatalogItem::where('page_id', $targetPageId)->max('order_number') ?? 0);
foreach ($ids as $i => $id) {
CatalogItem::whereKey($id)->update([
'page_id' => $targetPageId,
'order_number' => $maxOrder + 1 + $i,
]);
}
});
$this->resetTable();
$this->selectedItemIds = [];
$this->dispatch('$refresh');
Notification::make()
->title('Items moved')
->body('Moved ' . count($ids) . ' item(s) to: ' . ($target->caption ?? ('#' . $targetPageId)))
->success()
->send();
}
protected function buildFurniIconUrl(string $catalogName): string
{
$base = $this->getFurniIconBasePath();
$safeName = str_replace('*', '_', $catalogName);
$path = rtrim($base, '/') . '/' . $safeName . '_icon.png';
if (preg_match('#^(https?:)?//#', $path)) {
return $path;
}
return asset($path);
}
protected function getFurniIconBasePath(): string
{
$setting = WebsiteSetting::where('key', 'furniture_icons_path')->first();
return $setting && $setting->value ? rtrim($setting->value, '/') : '/images/furniture';
}
protected function getCatalogIconBasePath(): string
{
$setting = WebsiteSetting::where('key', 'catalog_icons_path')->first();
return $setting && $setting->value ? rtrim($setting->value, '/') : '/gamedata/c_images/catalogue';
}
protected function buildCatalogIconUrl(int $iconImage): string
{
$iconImage = $iconImage > 0 ? $iconImage : 1;
$base = $this->getCatalogIconBasePath();
$path = $base . '/icon_' . $iconImage . '.png';
if (preg_match('#^(https?:)?//#', $path)) {
return $path;
}
return asset($path);
}
public function reorderItems(array $orderedIds): void
{
if (filled($this->pageSearch)) {
Notification::make()
->title('Ordering disabled in search mode')
->body('You cannot reorder items while viewing search results.')
->warning()
->send();
return;
}
if (! $this->selectedPage?->id) {
return;
}
DB::transaction(function () use ($orderedIds) {
foreach ($orderedIds as $index => $id) {
CatalogItem::whereKey($id)->update([
'order_number' => ($index + 1) * 10,
]);
}
});
$this->normalizeOrderForSelectedPage();
Notification::make()
->title('Items reordered')
->success()
->send();
$this->resetTable();
}
protected function getTableHeaderActions(): array
{
return [
FilamentAction::make('massEdit')
->label('Mass edit selected')
->icon('heroicon-m-pencil-square')
->color('primary')
->disabled(fn () => empty($this->selectedItemIds))
->modalHeading('Edit selected catalog items')
->modalSubmitActionLabel('Apply changes')
->modalWidth('lg')
->form([
Forms\Components\TextInput::make('cost_credits')->label('Credits')->numeric()->minValue(0)->nullable()->helperText('Leave empty to keep unchanged'),
Forms\Components\TextInput::make('cost_points')->label('Points')->numeric()->minValue(0)->nullable()->helperText('Leave empty to keep unchanged'),
Forms\Components\TextInput::make('points_type')->label('Type (points_type)')->numeric()->minValue(0)->maxValue(999)->nullable()->helperText('Leave empty to keep unchanged'),
Forms\Components\TextInput::make('amount')->label('Amount')->numeric()->minValue(1)->nullable()->helperText('Leave empty to keep unchanged'),
Forms\Components\TextInput::make('order_number')->label('Order')->numeric()->minValue(-1)->nullable()->helperText('Leave empty to keep unchanged'),
Forms\Components\Select::make('club_only')
->label('Club only')
->options(['' => 'ÔÇö No change ÔÇö', '1' => 'Yes', '0' => 'No'])
->native(false)
->nullable()
->default('')
->helperText('Choose Yes/No, or leave as "No change"'),
])
->action(function (array $data): void {
$ids = collect($this->selectedItemIds)
->filter(fn ($v) => (int) $v > 0)
->map(fn ($v) => (int) $v)
->values()
->all();
if (empty($ids)) {
Notification::make()->title('No items selected')->warning()->send();
return;
}
$updates = [];
if ($data['cost_credits'] !== null && $data['cost_credits'] !== '') {
$updates['cost_credits'] = (int) $data['cost_credits'];
}
if ($data['cost_points'] !== null && $data['cost_points'] !== '') {
$updates['cost_points'] = (int) $data['cost_points'];
}
if ($data['points_type'] !== null && $data['points_type'] !== '') {
$updates['points_type'] = (int) $data['points_type'];
}
if ($data['amount'] !== null && $data['amount'] !== '') {
$updates['amount'] = (int) $data['amount'];
}
if ($data['order_number'] !== null && $data['order_number'] !== '') {
$updates['order_number'] = (int) $data['order_number'];
}
if ($data['club_only'] !== null && $data['club_only'] !== '') {
$updates['club_only'] = $data['club_only'] === '1' ? '1' : '0';
}
if (empty($updates)) {
Notification::make()->title('Nothing to update')->body('Fill at least one field to apply to the selected items.')->warning()->send();
return;
}
CatalogItem::whereIn('id', $ids)->update($updates);
$count = count($ids);
$this->resetTable();
$this->selectedItemIds = [];
Notification::make()->title('Updated items')->body("Applied changes to {$count} item(s).")->success()->send();
}),
FilamentAction::make('updateOrder')
->label('Update Order')
->icon('heroicon-o-arrow-path')
->color('secondary')
->visible(fn () => $this->selectedPage && $this->pageSearch === '')
->requiresConfirmation()
->modalHeading('Confirm Update Order')
->modalDescription('This will save the current item order (as currently sorted) into the database. Continue?')
->modalSubmitActionLabel('Update Order')
->action(function (): void {
if (! $this->selectedPage?->id) {
Notification::make()->title('No page selected')->warning()->send();
return;
}
if ($this->pageSearch !== '') {
Notification::make()
->title('Disabled in search mode')
->body('Cannot update order while search results are active.')
->warning()
->send();
return;
}
$sortColumn = $this->getTableSortColumn();
$sortDirection = $this->getTableSortDirection() ?? 'asc';
$query = $this->getTableQuery();
if ($sortColumn) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy('order_number')->orderBy('id');
}
$items = $query->get(['id']);
if ($items->isEmpty()) {
Notification::make()->title('No items')->warning()->send();
return;
}
DB::transaction(function () use ($items) {
foreach ($items->values() as $index => $item) {
CatalogItem::whereKey($item->id)->update([
'order_number' => ($index + 1) * 10,
]);
}
});
$this->resetTable();
Notification::make()->title('Order updated')->success()->send();
}),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Hotel\CatalogEditors\Pages;
use App\Filament\Resources\Hotel\CatalogEditors\CatalogEditorResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewCatalogEditor extends ViewRecord
{
protected static string $resource = CatalogEditorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('website_settings')->updateOrInsert(
['key' => 'catalog_icons_path'],
[
'value' => '/gamedata/c_images/catalogue',
'comment' => 'Path to catalog icons',
],
);
}
public function down(): void
{
DB::table('website_settings')->whereIn('key', [
'catalog_icons_path',
])->delete();
}
};
+8 -1
View File
@@ -1,4 +1,4 @@
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -271,3 +271,10 @@ html.dark .swiper-pagination-bullet:not(.swiper-pagination-bullet-active) {
#article-content a {
color: #53b2f8;
}
.cursor-grab {
cursor: grab !important;
}
.cursor-grab:active {
cursor: grabbing !important;
}
@@ -0,0 +1,39 @@
@props(['icons' => []])
<div x-data="{ open: false }" class="mt-6">
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-medium">Icon picker</h3>
<button
type="button"
class="fi-btn fi-btn-size-md fi-btn-color-gray fi-btn-variant-outline"
@click="open = !open"
>
<span x-text="open ? 'Hide icons' : 'Select icon'"></span>
</button>
</div>
<template x-if="open">
<div
class="grid gap-2 mt-2"
style="grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));"
>
@foreach($icons as $icon)
<button
type="button"
class="rounded border p-1 bg-white dark:bg-gray-900 flex items-center justify-center border-gray-200 dark:border-gray-700 hover:border-primary-400"
@click="$wire.setIconFromPicker({{ $icon['id'] }})"
title="Icon {{ $icon['id'] }}"
>
<img
src="{{ $icon['url'] }}"
alt="icon {{ $icon['id'] }}"
class="h-8 w-8 object-contain"
loading="lazy"
onerror="this.onerror=null;this.src='{{ $icon['fallback'] }}';"
style="image-rendering: pixelated; image-rendering: crisp-edges;"
/>
</button>
@endforeach
</div>
</template>
</div>
@@ -0,0 +1,13 @@
@props(['getUrl' => null, 'fallbackUrl' => null])
<div class="flex items-center gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">Current icon:</div>
<img
src="{{ is_callable($getUrl) ? $getUrl() : $getUrl }}"
alt=""
class="h-8 w-8 object-contain"
loading="lazy"
onerror="this.onerror=null;this.src='{{ $fallbackUrl }}';"
style="image-rendering: pixelated; image-rendering: crisp-edges;"
/>
</div>
@@ -0,0 +1,283 @@
<x-filament-panels::page class="!max-w-full !px-0">
<script>
window.catalogSelIds = [];
window.addEventListener('catalog-sel-update', (e) => {
window.catalogSelIds = Array.isArray(e.detail?.ids) ? e.detail.ids : [];
});
</script>
<div
x-data="{
h: 0,
leftWidth: 320,
resizing: false,
startX: 0,
startWidth: 0,
set() {
this.h = Math.max(320, window.innerHeight - 160);
},
init() {
this.set();
window.addEventListener('resize', () => this.set());
window.addEventListener('mousemove', e => this.doResize(e));
window.addEventListener('mouseup', () => this.stopResize());
const saved = localStorage.getItem('catalogEditorLeftWidth');
if (saved) this.leftWidth = parseInt(saved, 10);
window.addEventListener('scroll-to-page', e => {
const id = e.detail?.id;
if (!id) return;
const el = document.querySelector(`[data-page-id='${id}']`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-2', 'ring-primary-500', 'rounded-md');
setTimeout(() => el.classList.remove('ring-2', 'ring-primary-500', 'rounded-md'), 1500);
}
});
},
startResize(e) {
this.resizing = true;
this.startX = e.clientX;
this.startWidth = this.leftWidth;
document.body.style.cursor = 'col-resize';
$refs.divider.classList.add('bg-primary-400');
},
stopResize() {
if (!this.resizing) return;
this.resizing = false;
document.body.style.cursor = '';
$refs.divider.classList.remove('bg-primary-400');
localStorage.setItem('catalogEditorLeftWidth', this.leftWidth);
},
doResize(e) {
if (!this.resizing) return;
const diff = e.clientX - this.startX;
this.leftWidth = Math.max(200, Math.min(700, this.startWidth + diff));
},
}"
x-init="init()"
:style="`
display:grid;
grid-template-columns:${leftWidth}px 8px 1fr;
height:${h}px;
gap:0;
width:100%;
overflow:hidden;
`"
class="relative select-none"
>
<div
class="dark:bg-gray-900 dark:border-gray-700"
style="
height:100%;
overflow:auto;
border:1px solid var(--gray-200);
border-radius:1rem;
padding:0.75rem;
background:var(--filament-color-white,#fff);
"
>
<div class="mb-3">
<x-filament::input.wrapper
class="w-full border border-gray-300 dark:border-gray-600 rounded-lg focus-within:ring-2 focus-within:ring-primary-500 transition"
>
<x-filament::input
wire:model.live.debounce.400ms="pageSearch"
placeholder="Search catalog pages or items..."
class="!border-0 !shadow-none !ring-0 !outline-none bg-transparent text-sm"
/>
<x-slot name="suffix">
<button
type="button"
wire:click="resetView"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs font-bold leading-none transition"
title="Reset to default view"
>
X
</button>
</x-slot>
</x-filament::input.wrapper>
</div>
@php
if ($pageSearch !== '') {
$search = trim($pageSearch);
$matchedPages = \App\Models\Game\Furniture\CatalogPage::query()
->where('caption', 'like', "%{$search}%")
->get();
$matchedItems = \App\Models\Game\Furniture\CatalogItem::query()
->where('catalog_name', 'like', "%{$search}%")
->orWhere('id', (int) $search)
->get(['page_id']);
$visiblePageIds = collect()
->merge($matchedPages->pluck('id'))
->merge($matchedItems->pluck('page_id'))
->filter()
->unique();
$allPages = \App\Models\Game\Furniture\CatalogPage::all(['id', 'parent_id']);
$idToParent = $allPages->pluck('parent_id', 'id');
foreach ($visiblePageIds as $pid) {
$parentId = $idToParent[$pid] ?? null;
while ($parentId && $parentId > 0) {
$visiblePageIds->push($parentId);
$parentId = $idToParent[$parentId] ?? null;
}
}
$visiblePageIds = $visiblePageIds->unique();
$rootPages = \App\Models\Game\Furniture\CatalogPage::query()
->where('parent_id', -1)
->where(function ($q) use ($visiblePageIds) {
$q->whereIn('id', $visiblePageIds)
->orWhereIn('id', function ($sub) use ($visiblePageIds) {
$sub->select('parent_id')
->from('catalog_pages')
->whereIn('id', $visiblePageIds);
});
})
->orderBy('order_num')
->get();
$expanded = $visiblePageIds->values()->all();
$this->expandedPages = array_unique(array_merge($this->expandedPages, $expanded));
if (! $this->selectedPage && $visiblePageIds->isNotEmpty()) {
$this->selectedPage = \App\Models\Game\Furniture\CatalogPage::find($visiblePageIds->first());
$this->resetTable();
}
$visibleIdsForTree = $visiblePageIds->all();
} else {
$rootPages = \App\Models\Game\Furniture\CatalogPage::query()
->where('parent_id', -1)
->orderBy('order_num')
->get();
$visibleIdsForTree = null;
}
@endphp
@include('filament.resources.hotel.catalog-editors.pages.partials.catalog-tree', [
'pages' => $rootPages,
'depth' => 0,
'selectedPage' => $selectedPage,
'visibleIds' => $visibleIdsForTree,
])
</div>
<div
x-ref="divider"
x-on:mousedown="startResize"
class="bg-gray-300 dark:bg-gray-700 hover:bg-primary-400 cursor-col-resize transition-colors duration-150 relative"
style="
width:8px;
height:100%;
border-left:1px solid rgba(0,0,0,0.05);
border-right:1px solid rgba(0,0,0,0.05);
"
>
<div class="absolute inset-y-0 left-1/2 -translate-x-1/2 w-px bg-gray-500/40"></div>
</div>
<div
class="dark:bg-gray-900 dark:border-gray-700"
style="
min-width:0;
height:100%;
overflow:hidden;
border:1px solid var(--gray-200);
border-radius:1rem;
background:var(--filament-color-white,#fff);
display:flex;
flex-direction:column;
"
>
<div style="padding:0.75rem; border-bottom:1px solid var(--gray-200);" class="dark:border-gray-700">
<div class="flex items-center justify-between gap-2">
<h2 class="font-semibold text-lg m-0">
@if($selectedPage)
Items for: <span class="text-primary-600">{{ $selectedPage->caption }}</span>
@else
Select a catalog page to view its items
@endif
</h2>
@if($selectedPage && $pageSearch === '' && $selectedPage->parent_id !== -1 && ! $this->pageHasLockedItems())
<div class="flex items-center gap-2">
<x-filament::button
wire:click="autoOrderItems"
icon="heroicon-m-arrow-path"
>
Auto Order Items
</x-filament::button>
<x-filament::button
wire:click="manualOrderItems"
icon="heroicon-m-arrow-up-on-square-stack"
color="secondary"
>
Manual Order
</x-filament::button>
</div>
@endif
</div>
@if($selectedPage && $selectedPage->parent_id === -1)
<p class="mt-2 text-xs text-gray-500">
This is a root menu entry. Select a subpage to order its items.
</p>
@elseif($selectedPage && $this->pageHasLockedItems())
<p class="mt-2 text-xs text-gray-500">
This page contains item(s) with
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-800">order_number = -1</code>.
Change or remove them to enable ordering.
</p>
@endif
</div>
{{-- Table --}}
<div style="flex:1 1 auto; min-height:0; overflow:auto; padding:0.75rem;">
<div style="min-width:0;">
@if($pageSearch !== '')
<div
class="mb-2 flex items-center justify-center"
x-data
x-transition.opacity.duration.300ms
>
<span class="text-[11px] px-3 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-700 shadow-sm">
­ƒöì Search mode active ÔÇö ordering disabled
</span>
</div>
@endif
<div
data-catalog-list
data-livewire-id="{{ $this->getId() }}"
class="space-y-0"
@class([
'opacity-70 cursor-not-allowed pointer-events-none' => $pageSearch !== '' && ! $selectedPage
])
>
{{ $this->table }}
</div>
<script>
window.catalogSelIds = @json($selectedItemIds ?? []);
window.dispatchEvent(new CustomEvent('catalog-sel-refresh'));
</script>
</div>
</div>
</div>
</div>
</x-filament-panels::page>
@@ -0,0 +1,175 @@
<ul class="pl-{{ $depth * 4 }} text-sm">
@foreach ($pages as $index => $page)
@if ($depth === 0 && $index > 0)
<li class="list-none my-2">
<div
style="
width: 100%;
height: 1px;
background-image: radial-gradient(currentColor 1px, transparent 1.5px);
background-size: 6px 1px;
color: rgba(156,163,175,0.6);
display: block;
"
class="dark:text-[rgba(107,114,128,0.7)]"
></div>
</li>
@endif
@php
$filterIds = $visibleIds ?? null;
$children = \App\Models\Game\Furniture\CatalogPage::query()
->where('parent_id', $page->id)
->when($filterIds !== null, fn ($q) => $q->whereIn('id', $filterIds))
->orderBy('order_num')
->orderBy('id')
->get();
$shouldShow = $filterIds === null
? true
: in_array($page->id, $filterIds, true) || $children->isNotEmpty();
if (! $shouldShow) {
continue;
}
$hasChildren = $children->isNotEmpty();
$iconUrl = $this->buildCatalogIconUrl((int) $page->icon_image);
$fallbackUrl = $this->buildCatalogIconUrl(1);
@endphp
<li
data-page-id="{{ $page->id }}"
class="group flex items-center gap-1 min-w-0 rounded transition-all duration-150"
{{-- Only highlight + compute drop position when dragging PAGES.
IMPORTANT: no .stop here, otherwise item drags can get blocked. --}}
@dragover.prevent="
if (!event.dataTransfer.types.includes('text/x-page-id')) return;
const rect = $el.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
$el.dataset.dropPos = (event.clientY < mid) ? 'before' : 'after';
$el.classList.add('ring-2','ring-primary-400/60');
"
@dragleave.stop="
$el.classList.remove('ring-2','ring-primary-400/60');
delete $el.dataset.dropPos;
"
{{-- Page reorder drop target (keep .stop) --}}
@drop.prevent.stop="
const src = event.dataTransfer.getData('text/x-page-id');
if (src && src !== '{{ $page->id }}') {
const pos = $el.dataset.dropPos || 'after';
$wire.reorderPage(parseInt(src, 10), {{ $page->id }}, pos);
}
$el.classList.remove('ring-2','ring-primary-400/60');
delete $el.dataset.dropPos;
"
>
@if ($hasChildren)
<x-filament::icon-button
:icon="$this->isExpanded($page->id) ? 'heroicon-s-chevron-down' : 'heroicon-s-chevron-right'"
wire:click="toggleExpand({{ $page->id }})"
label="{{ $this->isExpanded($page->id) ? 'Collapse' : 'Expand' }}"
tooltip="{{ $this->isExpanded($page->id) ? 'Collapse' : 'Expand' }}"
size="xs"
color="gray"
variant="ghost"
class="shrink-0 inline-flex"
style="display:inline-flex;vertical-align:middle;"
/>
@else
<span class="inline-flex h-5 w-5 shrink-0"></span>
@endif
{{-- Page drag handle --}}
<span
x-data
draggable="true"
@dragstart="
event.dataTransfer.setData('text/x-page-id', '{{ $page->id }}');
event.dataTransfer.effectAllowed = 'move';
"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center cursor-move
text-gray-400 dark:text-gray-500
opacity-0 group-hover:opacity-100 transition-opacity"
title="Drag to reorder within this level"
style="display:inline-flex;vertical-align:middle;"
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<circle cx="3" cy="3" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="3" r="1.2" fill="currentColor"></circle>
<circle cx="3" cy="6" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="6" r="1.2" fill="currentColor"></circle>
<circle cx="3" cy="9" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="9" r="1.2" fill="currentColor"></circle>
</svg>
</span>
<button
x-data="{
over: false,
clickTimer: null,
clickDelay: 350,
singleClick() {
clearTimeout(this.clickTimer);
this.clickTimer = setTimeout(() => { $wire.selectPage({{ $page->id }}); }, this.clickDelay);
},
doubleClick() {
clearTimeout(this.clickTimer);
$wire.openEditPage({{ $page->id }});
},
}"
@dragover.prevent="
if (event.dataTransfer.getData('text/x-page-id')) return;
const payload = event.dataTransfer.getData('text/x-catalog-item-ids');
if (!payload) return;
over = true;"
@dragleave.prevent="over = false"
@drop.prevent.stop="
if (event.dataTransfer.getData('text/x-page-id')) return;
over = false;
const payload = event.dataTransfer.getData('text/x-catalog-item-ids');
if (!payload) return;
$wire.moveItemsToPage(payload, {{ $page->id }});"
@click.stop.prevent="singleClick()"
@dblclick.stop.prevent="doubleClick()"
class="flex-1 min-w-0 inline-flex items-center gap-0.5 px-2 py-1 rounded
hover:bg-gray-100 dark:hover:bg-gray-800 whitespace-nowrap
transition-all duration-150
{{ $selectedPage && $selectedPage->id === $page->id ? 'bg-gray-200 dark:bg-gray-700 font-semibold' : '' }}"
:class="over ? 'ring-2 ring-primary-500/50 bg-primary-50 dark:bg-primary-900/10' : ''"
title="Click to select. Double-click to edit. Drop items here to move."
style="display:inline-flex;vertical-align:middle;"
>
<span class="inline-block h-5 w-5 shrink-0"></span>
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center">
<img
src="{{ $iconUrl }}"
alt=""
class="max-w-full max-h-full object-contain align-middle"
loading="lazy"
referrerpolicy="no-referrer"
onerror="this.onerror=null;this.src='{{ $fallbackUrl }}';"
style="image-rendering: pixelated; image-rendering: crisp-edges;"
/>
</span>
<span class="truncate inline-block" style="display:inline-block;vertical-align:middle;">
{{ $page->caption }}
</span>
</button>
@if ($hasChildren && $this->isExpanded($page->id))
@include('filament.resources.hotel.catalog-editors.pages.partials.catalog-tree', [
'pages' => $children,
'depth' => $depth + 1,
'selectedPage' => $selectedPage,
'visibleIds' => $filterIds,
])
@endif
</li>
@endforeach
</ul>
@@ -0,0 +1,120 @@
@props([
'icon' => '',
'name' => '',
'itemId' => null,
'isSelected' => false,
'reordering' => false,
])
@php
$record = isset($getRecord) ? $getRecord() : null;
$resolvedIcon = is_callable($icon) ? $icon($record) : $icon;
$resolvedName = is_callable($name) ? $name($record) : $name;
$resolvedItemId = (int) (is_callable($itemId) ? $itemId($record) : $itemId);
@endphp
<div
x-data="{
id: {{ $resolvedItemId }},
highlight: false,
dragging: false,
compute() {
const arr = Array.isArray(window.catalogSelIds) ? window.catalogSelIds : [];
this.highlight = arr.includes(this.id);
},
dragStart(e) {
if ({{ $reordering ? 'true' : 'false' }}) return;
this.dragging = true;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/x-item-id', String(this.id));
const sel = Array.isArray(window.catalogSelIds) ? window.catalogSelIds : [];
const ids = (sel.length > 0) ? sel : [this.id];
const csv = ids
.map(v => parseInt(v, 10))
.filter(v => Number.isFinite(v) && v > 0)
.join(',');
e.dataTransfer.setData('text/x-catalog-item-ids', csv);
e.dataTransfer.setData('text/plain', csv);
e.dataTransfer.setDragImage($el, 10, 10);
},
dragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
$el.classList.add('ring-2', 'ring-primary-400/60');
},
dragLeave(e) {
$el.classList.remove('ring-2', 'ring-primary-400/60');
},
drop(e) {
e.preventDefault();
$el.classList.remove('ring-2', 'ring-primary-400/60');
const srcId = parseInt(e.dataTransfer.getData('text/x-item-id'), 10);
if (!srcId || srcId === this.id) return;
const parent = $el.closest('[data-catalog-list]');
if (!parent) return;
const children = Array.from(parent.querySelectorAll('[data-item-id]'));
const ids = children.map(c => parseInt(c.dataset.itemId, 10));
const srcIndex = ids.indexOf(srcId);
const destIndex = ids.indexOf(this.id);
if (srcIndex === -1 || destIndex === -1) return;
ids.splice(destIndex, 0, ids.splice(srcIndex, 1)[0]);
window.Livewire.find(parent.dataset.livewireId).call('reorderItems', ids);
},
clickRow(e) {
const multi = !!(e.ctrlKey || e.metaKey);
$wire.toggleSelectItem(this.id, multi);
},
openEditor() {
$wire.mountTableAction('quickEdit', this.id);
},
}"
x-init="compute(); window.addEventListener('catalog-sel-refresh', compute)"
@dragover="dragOver"
@dragleave="dragLeave"
@drop="drop"
@click.stop="clickRow"
@dblclick.stop="openEditor"
class="!flex !flex-row !items-center !gap-2 px-2 py-1 rounded select-none group cursor-default w-full"
:class="highlight ? 'bg-blue-50 dark:bg-primary-900/20 ring-1 ring-blue-400/40' : ''"
:data-item-id="id"
style="display:flex; align-items:center; gap:0.5rem;"
>
<span
x-data
draggable="true"
@dragstart="dragStart"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center cursor-grab text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Drag to reorder"
style="flex:0 0 auto;"
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<circle cx="3" cy="3" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="3" r="1.2" fill="currentColor"></circle>
<circle cx="3" cy="6" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="6" r="1.2" fill="currentColor"></circle>
<circle cx="3" cy="9" r="1.2" fill="currentColor"></circle>
<circle cx="9" cy="9" r="1.2" fill="currentColor"></circle>
</svg>
</span>
<img
src="{{ $resolvedIcon }}"
alt=""
class="h-6 w-6 shrink-0"
loading="lazy"
draggable="false"
@dragstart.prevent
style="image-rendering: pixelated; image-rendering: crisp-edges;"
/>
<span class="truncate" draggable="false" @dragstart.prevent>{{ $resolvedName }}</span>
</div>
@@ -0,0 +1,44 @@
@props([
'itemId' => null,
'isSelected' => false,
])
@php
$record = isset($getRecord) ? $getRecord() : null;
$resolvedItemId = (int) (is_callable($itemId) ? $itemId($record) : $itemId);
$checked = (bool) (is_callable($isSelected) ? $isSelected($record) : $isSelected);
@endphp
<div
x-data="{
id: {{ $resolvedItemId }},
init() {
if (!Array.isArray(window.catalogSelIds)) window.catalogSelIds = [];
if ({{ $checked ? 'true' : 'false' }}) {
if (!window.catalogSelIds.includes(this.id)) window.catalogSelIds.push(this.id);
}
window.dispatchEvent(new CustomEvent('catalog-sel-refresh'));
},
toggle(e) {
if (!Array.isArray(window.catalogSelIds)) window.catalogSelIds = [];
if (e.target.checked) {
if (!window.catalogSelIds.includes(this.id)) window.catalogSelIds.push(this.id);
$wire.toggleSelectItem(this.id, true);
} else {
window.catalogSelIds = window.catalogSelIds.filter(x => x !== this.id);
$wire.toggleSelectItem(this.id, false);
}
window.dispatchEvent(new CustomEvent('catalog-sel-refresh'));
}
}"
x-init="init()"
class="flex items-center justify-center"
>
<input
type="checkbox"
@change="toggle($event)"
{{ $checked ? 'checked' : '' }}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
aria-label="Select item {{ $resolvedItemId }}"
/>
</div>