intial push

This commit is contained in:
RallyBlock
2026-03-20 14:20:43 -07:00
parent f66aca1da2
commit f1d4b1f3b5
24 changed files with 4105 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# Blockland Config
**Version 1.7.0** · Author: RallyBlock
A Pelican Panel plugin for managing Blockland dedicated server configuration directly from the panel UI.
**Compatibility:** Pelican Panel (Filament 4.x, Laravel 12.x)
## Features
### Add-Ons
- Toggle server add-ons on/off, grouped by category (Bricks, Vehicles, Weapons, etc.)
- Categories are automatically detected from add-on names
- Orphaned entries (add-ons removed from the server) are automatically cleaned up
- Save changes or **Save & Restart** to apply immediately
### ColorSet
- Browse installed ColorSet add-ons and activate them with one click
- Read-only preview of the currently active color palette with color swatches
- Server restarts automatically after activating a new ColorSet
- Supports up to 64 colors (Blockland's maximum)
### Preferences
- Edit server preferences (`prefs.cs`) with typed form fields (toggles, text, numeric, password)
- Preferences are grouped by category (General, Bricks, Network, Quotas, etc.)
- **Save** syncs prefs to server vars and runs `webcom_postserver()` for live effect; **Save & Restart** for immediate full reload
### Admins
- Manage AutoAdminList and AutoSuperAdminList in `prefs.cs`
- **Add by BL_ID** with role (Admin or Super Admin) and mode: **Auto** (saved to prefs) or **Manual** (session only, no rejoin persistence)
- **Demote by BL_ID** for manually granted admins (player must be online)
- Apply changes to connected players immediately when adding Auto admins—no rejoin required (vanilla TorqueScript: `findClientByBL_ID`, `commandToClient setAdminLevel`, `messageAll`)
- **Remove** button to remove BL_IDs from Super Admins or Admins lists; demotes connected players instantly if online
- **Silent mode** toggle—when enabled, no `messageAll` announcements are sent for promotions, demotions, or preference updates
### Add-On Browser
- Browse and install community add-ons directly from [bl.kenko.dev](https://bl.kenko.dev/Add-ons/Retail/)
- Browse by category with a searchable dropdown
- Search and filter add-ons by name
- Paginated results with a configurable page size (10, 25, 50, or 100 per page)
- One-click install downloads add-ons directly into the server's `Add-Ons/` folder
- **Note:** These add-ons are for Retail Blockland (v21+), are community-created, and have not been tested for safety
### Save Loader
- List .bls save files from the server's `saves/` folder with optional preview thumbnails
- Copy any save to `base/server/temp/temp.bls` — then run /ReloadBricks in-game to load it
- Upload new .bls files into the saves folder; optionally add a preview image (.jpg, .jpeg, or .png with the same filename)
## Requirements
- Pelican Panel with a Blockland server egg (must have `blockland` in egg tags or features)
The plugin is **standalone** and works with vanilla Blockland—no Blockland Glass or Support Preferences (BLPrefs) required.
## Enabling Pages Per Server
Pages are controlled by **egg features** so you can choose which pages each server gets. Add these feature keys to your Blockland egg's `features` array:
| Feature | Page | Description |
|---------|------|-------------|
| `blockland-all` | All pages | Enable all Blockland pages at once |
| `blockland-addons` | Add-Ons | Toggle add-ons on/off, save or restart |
| `blockland-colorset` | ColorSet | Activate ColorSet add-ons, preview palette |
| `blockland-prefs` | Preferences | Edit prefs.cs with typed form fields |
| `blockland-admins` | Admins | Manage admin lists, add/remove by BL_ID, demote, silent mode |
| `blockland-addon-browser` | Add-On Browser | Browse and install add-ons from bl.kenko.dev |
| `blockland-save-loader` | Save Loader | List saves with previews, copy to temp.bls, upload .bls and preview images |
Only pages whose features are present on the server's egg will appear in the server panel. For example, adding only `blockland-addons` and `blockland-prefs` will show just those two pages for servers using that egg.
## Server File Paths
The plugin reads and writes the following files on the game server:
- `config/server/ADD_ON_LIST.cs` — Add-on enable/disable list
- `config/server/colorSet.txt` — Active color palette
- `config/server/prefs.cs` — Server preferences
- `config/server/.blockland-panel` — Panel config (e.g. silent mode)
- `Add-Ons/` — Installed add-ons (folders or .zip files)
- `Add-Ons/Colorset_*/` — Installed ColorSet add-ons
- `saves/` — Save files (.bls) and optional preview images (.jpg, .jpeg, .png); Save Loader lists, uploads, and displays thumbnails
- `base/server/temp/temp.bls` — Target for Save Loader "Copy to temp.bls" (run /ReloadBricks in-game to load)
+14
View File
@@ -0,0 +1,14 @@
<?php
return [
// Page enable/disable is controlled via egg features. Add the following
// to your Blockland egg's "features" array to enable each page per server:
//
// blockland-all — Enable all Blockland pages
// blockland-addons — Add-Ons page
// blockland-colorset — ColorSet page
// blockland-prefs — Preferences page
// blockland-admins — Admins page
// blockland-addon-browser — Add-On Browser page
// blockland-save-loader — Save Loader page
];
+260
View File
@@ -0,0 +1,260 @@
<?php
return [
// Loading states
'loading' => 'Loading...',
'loading_addons' => 'Loading add-ons...',
'loading_admins' => 'Loading admins...',
'loading_colorset' => 'Loading ColorSet...',
'loading_prefs' => 'Loading preferences...',
'loading_categories' => 'Loading categories...',
'loading_saves' => 'Loading saves...',
// Page titles
'pages' => [
'addons' => 'Add-Ons',
'colorset' => 'ColorSet',
'preferences' => 'Preferences',
'admins' => 'Admins',
'addon_browser' => 'Add-On Browser',
'save_loader' => 'Save Loader',
],
// Actions
'actions' => [
'save' => 'Save',
'save_and_restart' => 'Save & Restart',
'activate_colorset' => 'Activate ColorSet Add-On',
],
// Modals
'modals' => [
'restart_heading' => 'Save & Restart Server',
'restart_description' => 'This will save your add-on changes and restart the server. Players will be disconnected.',
'prefs_restart_description' => 'This will save your preference changes and restart the server. Players will be disconnected.',
],
// Add-On categories (known Blockland categories; unknown ones fall back to raw name)
'categories' => [
'ai' => 'AI',
'bot' => 'Bots',
'brick' => 'Bricks',
'chatemotes' => 'Chat Emotes',
'client' => 'Client',
'colorset' => 'ColorSets',
'content' => 'Content',
'daycycle' => 'Day Cycles',
'decal' => 'Decals',
'emote' => 'Emotes',
'event' => 'Events',
'face' => 'Faces',
'gamemode' => 'Game Modes',
'ground' => 'Grounds',
'hat' => 'Hats',
'hatmod' => 'Hat Mods',
'instrument' => 'Instruments',
'item' => 'Items',
'jvs' => 'JVS',
'langscript' => 'Language Scripts',
'light' => 'Lights',
'map' => 'Maps',
'particle' => 'Particles',
'player' => 'Player',
'print' => 'Prints',
'projectile' => 'Projectiles',
'prop' => 'Props',
'rp' => 'Roleplay',
'script' => 'Scripts',
'server' => 'Server',
'sky' => 'Skies',
'sound' => 'Sounds',
'staticmap' => 'Static Maps',
'sunflare' => 'Sun Flares',
'support' => 'Support',
'system' => 'System',
'theme' => 'Themes',
'tool' => 'Tools',
'vehicle' => 'Vehicles',
'water' => 'Water',
'weapon' => 'Weapons',
],
// ColorSet
'colorset' => [
'select_addon' => 'Choose a ColorSet add-on to replace the active palette',
'active_palette' => 'Active Palette',
'color_count' => ':count / :max colors',
'no_colorset' => 'No active colorset found.',
],
// Prefs
'prefs' => [
'restart_hint' => 'Restart required for this preference to take effect.',
],
// Validation
'validation' => [
'max_length' => 'Must be at most :max characters.',
'must_be_numeric' => 'Must be a number.',
'min_value' => 'Must be at least :min.',
'max_value' => 'Must be at most :max.',
],
// Pref groups
'pref_groups' => [
'general' => 'General',
'bricks' => 'Bricks',
'newduplicator' => 'New Duplicator',
'newbricktool' => 'New Brick Tool',
'newpaintcan' => 'New Paint Can',
'quotas' => 'Quotas',
'lan_quotas' => 'LAN Quotas',
'network' => 'Network',
'other' => 'Other',
],
// Notifications
'notifications' => [
'addons_saved' => 'Add-on list saved successfully.',
'addons_saved_restarting' => 'Add-on list saved. Server is restarting...',
'addons_cleaned' => 'Add-on list cleaned up.',
'addons_cleaned_detail' => 'Removed :count add-on(s) that no longer exist in the Add-Ons folder.',
'colorset_activated' => 'ColorSet activated successfully.',
'colorset_activated_restarting' => 'ColorSet activated. Server is restarting...',
'colorset_activate_failed' => 'Failed to activate colorset.',
'colorset_too_many_colors' => 'The selected colorset exceeds the maximum of :max colors.',
'colorset_not_found' => 'Could not find a colorSet.txt in the selected add-on. It may be corrupted or missing.',
'prefs_saved' => 'Preferences saved successfully.',
'prefs_saved_restarting' => 'Preferences saved. Server is restarting...',
'prefs_applied_live' => 'Changes applied to the running server.',
'prefs_restart_to_apply' => 'Restart the server for changes to take effect.',
'save_failed' => 'Failed to save changes.',
'addon_install_started' => 'Download started.',
'addon_install_started_detail' => ':name is being downloaded to your Add-Ons folder.',
'addon_install_failed' => 'Failed to install add-on.',
],
// Egg feature keys (for documentation; add these to egg features to enable pages per server)
'features' => [
'all' => 'blockland-all',
'addons' => 'blockland-addons',
'colorset' => 'blockland-colorset',
'prefs' => 'blockland-prefs',
'admins' => 'blockland-admins',
'addon_browser' => 'blockland-addon-browser',
'save_loader' => 'blockland-save-loader',
],
// Save Loader
'save_loader' => [
'upload' => 'Upload to Saves',
'upload_hint' => 'Choose a .bls file from your computer, then click Upload to add it to your server\'s saves folder.',
'upload_drop_or_click' => 'Drop .bls here or click to choose',
'upload_file_types' => '.bls files only',
'upload_change' => 'Change',
'upload_button' => 'Upload to saves folder',
'upload_preview_optional' => 'Optional: Add a preview image (same filename as the save, but .jpg, .jpeg, or .png)',
'upload_preview_add' => 'Add preview image',
'upload_preview_change' => 'Change',
'upload_success' => 'File uploaded to saves folder.',
'upload_failed' => 'Upload failed.',
'upload_no_file' => 'Please select a file first.',
'upload_invalid_type' => 'File must have a .bls extension.',
'saves_folder' => 'Saves Folder',
'copy_hint' => 'Copy a save to base/server/temp/temp.bls. Then run /ReloadBricks in-game.',
'copy_to_temp' => 'Copy to temp.bls',
'copy_success' => 'Save copied to temp.bls. Run /ReloadBricks in-game to load it.',
'copy_failed' => 'Failed to copy save.',
'no_saves' => 'No .bls save files found in the saves folder.',
],
// Add-On Browser
'browser' => [
'warning' => 'These add-ons are for Retail Blockland (v21+) and may not work on older versions. They are sourced from bl.kenko.dev, community-created, and have not been tested for safety. Use at your own risk.',
'category' => 'Category',
'select_category' => 'Choose a category...',
'select_category_prompt' => 'Select a category above to browse available add-ons.',
'select_category_or_search_prompt' => 'Select a category or search above to find add-ons.',
'search' => 'Search',
'search_placeholder' => 'Filter add-ons...',
'addon_name' => 'Name',
'addon_size' => 'Size',
'install' => 'Install',
'install_heading' => 'Install :name',
'install_description' => 'This will download the add-on to your server\'s Add-Ons folder. You may need to restart the server for it to take effect.',
'showing_count' => ':count add-on(s) available',
'showing_range' => 'Showing :from to :to of :total',
'per_page' => 'Per page',
'no_addons' => 'No add-ons found.',
'searching_addons' => 'Searching add-ons...',
],
// Admins
'admins' => [
'online_players' => 'Online Players',
'refresh' => 'Refresh',
'refresh_players' => 'Refresh player list',
'online_loading' => 'Loading players...',
'online_empty' => 'No players online.',
'players_online' => 'Players online',
'make_admin' => 'Make Admin',
'make_admin_manual' => 'Make Admin (Manual)',
'make_super_admin' => 'Make Super Admin',
'make_super_admin_manual' => 'Make Super Admin (Manual)',
'promote_to_super_admin' => 'Promote to Super Admin',
'promote_to_super_admin_manual' => 'Promote to Super Admin (Manual)',
'role_super_admin' => 'Super Admin',
'role_admin' => 'Admin',
'role_player' => 'Player',
'super_admins' => 'Super Admins',
'admins' => 'Admins',
'announcements' => 'Announcements',
'announcement_message' => 'Message',
'announcement_placeholder' => 'Message to send to all players...',
'announcement_empty' => 'Please enter a message.',
'announcement_sent' => 'Announcement sent.',
'send_announcement' => 'Send',
'add_by_blid' => 'Add by BL_ID',
'bl_id' => 'BL_ID',
'mode' => 'Mode',
'mode_auto' => 'Auto (saved)',
'mode_auto_checkbox' => 'Auto (saved)',
'mode_manual' => 'Manual (session only)',
'session_only' => '(session only)',
'role' => 'Role',
'add' => 'Add',
'remove' => 'Remove',
'demote' => 'Demote',
'revoke' => 'Revoke',
'cancel' => 'Cancel',
'confirm_demote' => 'Demote BL_ID :bl_id? They will lose admin for this session.',
'confirm_revoke' => 'Revoke admin for BL_ID :bl_id? They will lose admin for this session.',
'confirm_remove' => 'Remove BL_ID :bl_id from :role?',
'demote_by_blid' => 'Demote by BL_ID',
'revoke_by_blid' => 'Revoke by BL_ID',
'demote_by_blid_hint' => 'For manually granted admins. Applies only if player is online.',
'revoke_by_blid_hint' => 'For manually granted admins. Applies only if player is online.',
'demote_from' => 'Demote from',
'revoke_from' => 'Revoke from',
'no_super_admins' => 'No super admins.',
'no_admins' => 'No admins.',
'added' => ':name added as :role.',
'removed' => 'BL_ID :id removed from :role.',
'server_offline' => 'Server must be running to apply changes to connected players.',
'manual_requires_online' => 'Player must be online for Manual promotion.',
'demoted' => 'Player demoted.',
'revoked' => 'Admin privileges have been revoked.',
'banned' => 'Player banned.',
'unbanned' => 'Player unbanned.',
'ban_list' => 'Ban List',
'ban' => 'Ban',
'unban' => 'Unban',
'no_bans' => 'No players banned.',
'ban_time' => 'Time (minutes)',
'ban_reason' => 'Reason',
'silent_mode' => 'Silent mode',
'silent_mode_tooltip' => 'When on, no messageAll announcements are sent for promotes, revokes, or preference updates',
'silent_enabled' => 'Silent mode enabled. No in-game announcements will be sent.',
'silent_disabled' => 'Silent mode disabled. In-game announcements will be sent.',
],
];
+22
View File
@@ -0,0 +1,22 @@
{
"id": "blockland-config",
"name": "Blockland Config",
"author": "RallyBlock",
"version": "1.7.0",
"description": "Manage Blockland server add-ons, color sets, and preferences",
"category": "plugin",
"url": null,
"update_url": null,
"namespace": "Pelican\\BlocklandConfig",
"class": "BlocklandConfigPlugin",
"panels": [
"admin",
"server"
],
"panel_version": null,
"composer_packages": null,
"meta": {
"status": "enabled",
"status_message": null
}
}
@@ -0,0 +1,141 @@
<x-filament-panels::page wire:init="loadCategories">
@if(!$categoriesLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_categories') }}</span>
</div>
</x-filament::section>
@else
<div class="space-y-8">
<div class="flex flex-col gap-8">
{{-- Warning banner --}}
<div class="rounded-xl border border-amber-400/30 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-950/30 px-6 py-4 flex items-start gap-4 shrink-0">
<x-filament::icon icon="tabler-alert-triangle" class="h-5 w-5 text-amber-500 dark:text-amber-400 shrink-0 mt-0.5" />
<p class="text-sm text-amber-800 dark:text-amber-200">
{{ trans('blockland-config::blockland-config.browser.warning') }}
</p>
</div>
{{-- Category & search filters --}}
<div>
{{ $this->form }}
</div>
<div class="relative min-h-[12rem]">
<div wire:loading.delay.shortest class="block w-full">
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.browser.searching_addons') }}</span>
</div>
</x-filament::section>
</div>
<div wire:loading.remove.delay.shortest>
@if($this->selectedCategory || $this->search !== '')
@php $paginated = $this->getPaginatedData(); @endphp
<x-filament::section>
<x-slot name="afterHeader">
<div class="flex items-center justify-between gap-4 flex-wrap w-full">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.browser.showing_count', ['count' => $paginated['total']]) }}</p>
<div class="flex items-center gap-3">
<label for="perPage" class="text-sm font-medium text-gray-950 dark:text-white">{{ trans('blockland-config::blockland-config.browser.per_page') }}</label>
<x-filament::input.wrapper class="!w-auto min-w-[5rem]">
<x-filament::input.select wire:model.live="perPage" id="perPage">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</x-filament::input.select>
</x-filament::input.wrapper>
</div>
</div>
</x-slot>
@if(count($paginated['items']) > 0)
<div class="divide-y divide-transparent dark:divide-white/10">
@foreach($paginated['items'] as $addOn)
<div class="flex items-center justify-between py-4 gap-4" wire:key="addon-{{ md5($addOn['filename']) }}">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-950 dark:text-white truncate">{{ $addOn['name'] }}</p>
@if($addOn['size'])
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $addOn['size'] }}</p>
@endif
</div>
<x-filament::button
size="sm"
color="gray"
outlined
icon="tabler-download"
wire:click="installAddOn('{{ $addOn['url'] }}', '{{ $addOn['filename'] }}', '{{ addslashes($addOn['name']) }}')"
wire:loading.attr="disabled"
wire:target="installAddOn"
>
{{ trans('blockland-config::blockland-config.browser.install') }}
</x-filament::button>
</div>
@endforeach
</div>
@if($paginated['lastPage'] > 1)
<div class="pt-4 mt-4 border-t border-transparent dark:border-white/10 flex items-center justify-between gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ trans('blockland-config::blockland-config.browser.showing_range', [
'from' => $paginated['from'],
'to' => $paginated['to'],
'total' => $paginated['total'],
]) }}
</p>
<div class="flex items-center gap-1">
<x-filament::icon-button
icon="tabler-chevron-left"
color="gray"
wire:click="previousPage"
:disabled="$paginated['page'] <= 1"
/>
@for($p = 1; $p <= $paginated['lastPage']; $p++)
@if($paginated['lastPage'] <= 7 || $p <= 2 || $p >= $paginated['lastPage'] - 1 || abs($p - $paginated['page']) <= 1)
<x-filament::button
:color="$p === $paginated['page'] ? 'primary' : 'gray'"
:outlined="$p !== $paginated['page']"
size="sm"
wire:click="goToPage({{ $p }})"
class="!px-3 !py-1 min-w-[2rem]"
>
{{ $p }}
</x-filament::button>
@elseif($p === 3 || $p === $paginated['lastPage'] - 2)
<span class="px-2 text-sm text-gray-400 dark:text-gray-500">&hellip;</span>
@endif
@endfor
<x-filament::icon-button
icon="tabler-chevron-right"
color="gray"
wire:click="nextPage"
:disabled="$paginated['page'] >= $paginated['lastPage']"
/>
</div>
</div>
@endif
@else
<x-filament::empty-state
icon="tabler-package-off"
:heading="trans('blockland-config::blockland-config.browser.no_addons')"
/>
@endif
</x-filament::section>
@else
<x-filament::empty-state
icon="tabler-package"
:heading="trans('blockland-config::blockland-config.browser.select_category_or_search_prompt')"
/>
@endif
</div>
</div>
</div>
</div>
@endif
</x-filament-panels::page>
@@ -0,0 +1,16 @@
<x-filament-panels::page wire:init="loadAddOns">
@if(!$addOnsLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_addons') }}</span>
</div>
</x-filament::section>
@else
<div class="space-y-8">
<div class="flex flex-col gap-10">
{{ $this->form }}
</div>
</div>
@endif
</x-filament-panels::page>
+121
View File
@@ -0,0 +1,121 @@
<x-filament-panels::page wire:init="loadAdmins">
@if(!$adminsLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_admins') }}</span>
</div>
</x-filament::section>
@else
<div class="flex flex-col gap-6">
{{-- Confirmation modal popup --}}
@if($this->confirmingDemote || $this->confirmingRemove)
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
@keydown.escape.window="$wire.cancelConfirm()"
>
<div
class="absolute inset-0 bg-black/50"
wire:click="cancelConfirm"
></div>
<div class="relative z-10 w-full max-w-md rounded-xl bg-white dark:bg-gray-900 shadow-xl dark:ring-1 dark:ring-white/10 p-6 text-center">
@if($this->confirmingDemote)
<p class="text-sm text-gray-700 dark:text-gray-300 mb-8">
{{ trans('blockland-config::blockland-config.admins.confirm_demote', ['bl_id' => $demoteBlId]) }}
</p>
@else
<p class="text-sm text-gray-700 dark:text-gray-300 mb-8">
{{ trans('blockland-config::blockland-config.admins.confirm_remove', ['bl_id' => $this->confirmingRemove['blId'], 'role' => $this->confirmingRemove['list'] === 'super' ? trans('blockland-config::blockland-config.admins.super_admins') : trans('blockland-config::blockland-config.admins.admins')]) }}
</p>
@endif
<div class="flex justify-center gap-3">
<x-filament::button color="gray" outlined icon="tabler-x" wire:click="cancelConfirm">
{{ trans('blockland-config::blockland-config.admins.cancel') }}
</x-filament::button>
@if($this->confirmingDemote)
<x-filament::button color="danger" icon="tabler-user-minus" wire:click="demoteByBlId" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.demote') }}
</x-filament::button>
@else
<x-filament::button color="danger" icon="tabler-user-minus" wire:click="confirmRemoveFromList" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.remove') }}
</x-filament::button>
@endif
</div>
</div>
</div>
@endif
{{-- Add by BL_ID --}}
<x-filament::section :heading="trans('blockland-config::blockland-config.admins.add_by_blid')">
<div class="space-y-6">
<div>
{{ $this->addForm }}
</div>
<div class="mt-8 pt-6">
<x-filament::button icon="tabler-plus" wire:click="addByBlId" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.add') }}
</x-filament::button>
</div>
</div>
</x-filament::section>
{{-- Demote by BL_ID --}}
<x-filament::section
:heading="trans('blockland-config::blockland-config.admins.demote_by_blid')"
:description="trans('blockland-config::blockland-config.admins.demote_by_blid_hint')"
>
<div class="space-y-6">
<div>
{{ $this->demoteForm }}
</div>
<div class="mt-8 pt-6">
<x-filament::button color="danger" icon="tabler-user-minus" wire:click="askConfirmDemote" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.demote') }}
</x-filament::button>
</div>
</div>
</x-filament::section>
{{-- Super Admins --}}
<x-filament::section :heading="trans('blockland-config::blockland-config.admins.super_admins')" collapsible collapsed>
@if(count($this->superAdmins) > 0)
<div class="divide-y divide-transparent dark:divide-white/10">
@foreach($this->superAdmins as $blId)
<div class="flex items-center justify-between py-4 gap-4">
<span class="text-sm text-gray-950 dark:text-white">BL_ID {{ $blId }}</span>
<x-filament::button color="danger" size="sm" icon="tabler-user-minus" wire:click="askRemoveFromList('{{ $blId }}', 'super')" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.remove') }}
</x-filament::button>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.admins.no_super_admins') }}</p>
@endif
</x-filament::section>
{{-- Admins --}}
<x-filament::section :heading="trans('blockland-config::blockland-config.admins.admins')" collapsible collapsed>
@if(count($this->admins) > 0)
<div class="divide-y divide-transparent dark:divide-white/10">
@foreach($this->admins as $blId)
<div class="flex items-center justify-between py-4 gap-4">
<span class="text-sm text-gray-950 dark:text-white">BL_ID: {{ $blId }}</span>
<x-filament::button color="danger" size="sm" icon="tabler-user-minus" wire:click="askRemoveFromList('{{ $blId }}', 'admin')" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.admins.remove') }}
</x-filament::button>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.admins.no_admins') }}</p>
@endif
</x-filament::section>
</div>
@endif
</x-filament-panels::page>
@@ -0,0 +1,46 @@
<x-filament-panels::page wire:init="loadColorSet">
@if(!$colorSetLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_colorset') }}</span>
</div>
</x-filament::section>
@else
<div class="flex flex-col gap-6">
{{-- Color count summary --}}
<x-filament::section>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<x-filament::icon icon="tabler-palette" class="h-5 w-5 text-primary-500 dark:text-primary-400 shrink-0" />
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ trans('blockland-config::blockland-config.colorset.active_palette') }}</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.colorset.color_count', ['count' => $this->totalColors, 'max' => 64]) }}</span>
</div>
</x-filament::section>
{{-- Swatch preview per section --}}
@forelse($this->colorSetPreview as $section)
<x-filament::section
:heading="$section['label']"
:description="count($section['colors']) . ' ' . (count($section['colors']) === 1 ? 'color' : 'colors')"
>
<div class="flex flex-wrap gap-2">
@foreach($section['colors'] as $color)
<div
class="shrink-0 w-10 h-10 rounded-lg border-0 dark:border dark:border-gray-600 shadow-sm"
style="background-color: rgba({{ $color['r'] }}, {{ $color['g'] }}, {{ $color['b'] }}, {{ round($color['a'] / 255, 2) }});"
title="R:{{ $color['r'] }} G:{{ $color['g'] }} B:{{ $color['b'] }} A:{{ $color['a'] }}"
></div>
@endforeach
</div>
</x-filament::section>
@empty
<x-filament::empty-state
icon="tabler-palette-off"
:heading="trans('blockland-config::blockland-config.colorset.no_colorset')"
/>
@endforelse
</div>
@endif
</x-filament-panels::page>
@@ -0,0 +1,16 @@
<x-filament-panels::page wire:init="loadPrefs">
@if(!$prefsLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_prefs') }}</span>
</div>
</x-filament::section>
@else
<div class="space-y-8">
<div class="flex flex-col gap-10">
{{ $this->form }}
</div>
</div>
@endif
</x-filament-panels::page>
@@ -0,0 +1,100 @@
<x-filament-panels::page wire:init="loadSaves">
@if(!$savesLoaded)
<x-filament::section>
<div class="flex items-center justify-center gap-2 py-12">
<x-filament::loading-indicator class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.loading_saves') }}</span>
</div>
</x-filament::section>
@else
<div class="flex flex-col gap-6">
{{-- Upload to Saves --}}
<x-filament::section :heading="trans('blockland-config::blockland-config.save_loader.upload')" :description="trans('blockland-config::blockland-config.save_loader.upload_hint')">
<div class="rounded-xl border-2 transition {{ $uploadedFile ? 'border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-800/50' : 'border-dashed border-gray-300 dark:border-gray-600' }}">
@if($uploadedFile)
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<label class="flex min-w-0 cursor-pointer items-center gap-3">
<x-filament::icon icon="tabler-file" class="h-6 w-6 shrink-0 text-primary-500 dark:text-primary-400" />
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-950 dark:text-white">{{ $uploadedFile->getClientOriginalName() }}</p>
<span class="text-xs text-primary-500 hover:underline dark:text-primary-400">{{ trans('blockland-config::blockland-config.save_loader.upload_change') }}</span>
</div>
<input type="file" wire:model="uploadedFile" accept=".bls" class="absolute h-px w-px overflow-hidden opacity-0" aria-hidden="true" tabindex="-1" />
</label>
<x-filament::button icon="tabler-upload" wire:click="uploadSave" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.save_loader.upload_button') }}
</x-filament::button>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-white/10">
<p class="mb-2 text-xs text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.save_loader.upload_preview_optional') }}</p>
@if($uploadedPreviewFile)
<div class="flex items-center gap-3">
<label class="flex min-w-0 flex-1 cursor-pointer items-center gap-3">
<div class="flex h-6 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-gray-200 dark:bg-gray-700">
<img src="{{ $uploadedPreviewFile->temporaryUrl() }}" alt="" class="h-full w-full object-cover" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-950 dark:text-white">{{ $uploadedPreviewFile->getClientOriginalName() }}</p>
<span class="text-xs text-primary-500 hover:underline dark:text-primary-400">{{ trans('blockland-config::blockland-config.save_loader.upload_preview_change') }}</span>
</div>
<input type="file" wire:model="uploadedPreviewFile" accept=".jpg,.jpeg,.png" class="absolute h-px w-px overflow-hidden opacity-0" aria-hidden="true" tabindex="-1" />
</label>
<button type="button" wire:click="removeUploadedPreviewFile" class="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-filament::icon icon="tabler-x" class="h-5 w-5" />
</button>
</div>
@else
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-gray-300 px-3 py-2 text-sm transition dark:border-gray-600 hover:border-primary-500 dark:hover:border-primary-500">
<x-filament::icon icon="tabler-photo-plus" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="text-gray-600 dark:text-gray-300">{{ trans('blockland-config::blockland-config.save_loader.upload_preview_add') }}</span>
<input type="file" wire:model="uploadedPreviewFile" accept=".jpg,.jpeg,.png" class="absolute h-px w-px overflow-hidden opacity-0" aria-hidden="true" />
</label>
@endif
</div>
</div>
@else
<label class="relative flex cursor-pointer flex-col items-center justify-center gap-2 px-6 py-8 sm:flex-row sm:py-6">
<x-filament::icon icon="tabler-upload" class="h-10 w-10 shrink-0 text-gray-400 dark:text-gray-500" />
<div class="text-center sm:text-left">
<p class="text-sm font-medium text-gray-950 dark:text-white">{{ trans('blockland-config::blockland-config.save_loader.upload_drop_or_click') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ trans('blockland-config::blockland-config.save_loader.upload_file_types') }}</p>
</div>
<input type="file" wire:model="uploadedFile" accept=".bls" class="absolute inset-0 h-full w-full cursor-pointer opacity-0" aria-hidden="true" />
</label>
@endif
</div>
</x-filament::section>
{{-- Saves Folder --}}
<x-filament::section :heading="trans('blockland-config::blockland-config.save_loader.saves_folder')" :description="trans('blockland-config::blockland-config.save_loader.copy_hint')">
@if(count($saves) > 0)
<div class="divide-y divide-transparent dark:divide-white/10">
@foreach($saves as $save)
<div class="flex items-center justify-between gap-4 py-4" wire:key="save-{{ $save['path'] }}">
<div class="flex min-w-0 flex-1 items-center gap-4">
<div class="flex h-6 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
@if(isset($save['preview_url']))
<img src="{{ $save['preview_url'] }}" alt="" class="h-full w-full object-cover" loading="lazy" />
@else
<x-filament::icon icon="tabler-file" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@endif
</div>
<span class="truncate text-sm font-medium text-gray-950 dark:text-white">{{ $save['name'] }}</span>
</div>
<x-filament::button size="sm" icon="tabler-copy" wire:click="copyToTemp('{{ addslashes($save['path']) }}')" wire:loading.attr="disabled">
{{ trans('blockland-config::blockland-config.save_loader.copy_to_temp') }}
</x-filament::button>
</div>
@endforeach
</div>
@else
<x-filament::empty-state
icon="tabler-folder-off"
:heading="trans('blockland-config::blockland-config.save_loader.no_saves')"
/>
@endif
</x-filament::section>
</div>
@endif
</x-filament-panels::page>
+11
View File
@@ -0,0 +1,11 @@
<?php
use App\Http\Middleware\Activity\ServerSubject;
use App\Http\Middleware\Api\Client\Server\AuthenticateServerAccess;
use App\Http\Middleware\Api\Client\Server\ResourceBelongsToServer;
use Illuminate\Support\Facades\Route;
use Pelican\BlocklandConfig\Http\Controllers\Api\Client\Servers\SavePreviewController;
Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, AuthenticateServerAccess::class, ResourceBelongsToServer::class])->group(function () {
Route::get('/blockland-save-preview', SavePreviewController::class)->name('api:client:server.blockland-save-preview');
});
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Pelican\BlocklandConfig;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandAddOnBrowserPage;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandAddOnsPage;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandAdminsPage;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandColorSetPage;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandPrefsPage;
use Pelican\BlocklandConfig\Filament\Server\Pages\BlocklandSaveLoaderPage;
class BlocklandConfigPlugin implements Plugin
{
public function getId(): string
{
return 'blockland-config';
}
public function register(Panel $panel): void
{
if ($panel->getId() === 'server') {
$panel->pages([
BlocklandAddOnsPage::class,
BlocklandColorSetPage::class,
BlocklandAdminsPage::class,
BlocklandPrefsPage::class,
BlocklandAddOnBrowserPage::class,
BlocklandSaveLoaderPage::class,
]);
}
}
public function boot(Panel $panel): void {}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Pelican\BlocklandConfig\Facades;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Illuminate\Support\Facades\Facade;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
/**
* @method static bool isBlocklandServer(Server $server)
* @method static bool hasBlocklandFeature(Server $server, string $feature)
* @method static array getAddOns(Server $server, DaemonFileRepository $fileRepository)
* @method static void saveAddOns(Server $server, DaemonFileRepository $fileRepository, array $addOns)
* @method static array parseAddOnList(string $content)
* @method static string serializeAddOnList(array $addOns)
* @method static array groupAddOnsByCategory(array $addOns)
* @method static array listInstalledAddOnNames(Server $server, DaemonFileRepository $fileRepository)
* @method static array cleanupOrphanedAddOns(array $addOns, array $installedNames)
* @method static array parseColorSet(string $content)
* @method static string serializeColorSet(array $sections)
* @method static array parsePrefs(string $content)
* @method static string serializePrefs(array $prefs)
* @method static array getPrefs(Server $server, DaemonFileRepository $fileRepository)
* @method static void savePrefs(Server $server, DaemonFileRepository $fileRepository, array $prefs)
* @method static array groupPrefs(array $prefs)
* @method static array listColorsetAddOns(Server $server, DaemonFileRepository $fileRepository)
* @method static string|null readColorsetAddOn(Server $server, DaemonFileRepository $fileRepository, string $path, bool $isZip)
* @method static string activateColorset(Server $server, DaemonFileRepository $fileRepository, string $path, bool $isZip)
* @method static array getActiveColorSet(Server $server, DaemonFileRepository $fileRepository)
* @method static bool saveActiveColorSet(Server $server, DaemonFileRepository $fileRepository, array $sections)
* @method static void sendConsoleCommands(Server $server, DaemonFileRepository $fileRepository, array $commands)
* @method static string|null validatePrefValue(string $key, string $value)
* @method static bool requiresRestart(string $key)
* @method static array getAdminLists(Server $server, DaemonFileRepository $fileRepository)
* @method static void saveAdminLists(Server $server, DaemonFileRepository $fileRepository, array $superAdmins, array $admins)
* @method static array getOnlinePlayers(Server $server, DaemonFileRepository $fileRepository)
* @method static array fetchRetailCategories()
* @method static array fetchRetailAddOns(string $category)
* @method static void installRetailAddOn(Server $server, DaemonFileRepository $fileRepository, string $url, ?string $filename = null)
* @method static bool isSilentMode(Server $server, DaemonFileRepository $fileRepository)
* @method static void setSilentMode(Server $server, DaemonFileRepository $fileRepository, bool $silent)
* @method static array buildPrefConsoleCommands(array $prefs)
* @method static array listSaveFiles(Server $server, DaemonFileRepository $fileRepository)
* @method static void copySaveToTemp(Server $server, DaemonFileRepository $fileRepository, string $path)
* @method static void uploadSaveFile(Server $server, DaemonFileRepository $fileRepository, string $content, string $filename)
*
* @see BlocklandConfigService
*/
class BlocklandConfig extends Facade
{
protected static function getFacadeAccessor(): string
{
return BlocklandConfigService::class;
}
}
View File
@@ -0,0 +1,221 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
/**
* @property Schema $form
*/
class BlocklandAddOnBrowserPage extends Page
{
use InteractsWithForms;
protected string $view = 'blockland-config::pages.addon-browser';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-download';
protected static ?string $slug = 'blockland-addon-browser';
protected static ?int $navigationSort = 23;
public ?string $selectedCategory = null;
public string $search = '';
public int $perPage = 25;
public int $page = 1;
public bool $categoriesLoaded = false;
public array $categories = [];
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_ADDON_BROWSER);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.addon_browser');
}
public function getTitle(): string|Htmlable
{
return static::getNavigationLabel();
}
public function updatedSelectedCategory(): void
{
$this->page = 1;
$this->search = '';
}
public function updatedSearch(): void
{
$this->page = 1;
}
public function updatedPerPage(): void
{
$this->page = 1;
}
public function loadCategories(): void
{
$this->categories = BlocklandConfig::fetchRetailCategories();
$this->categoriesLoaded = true;
}
public function form(Schema $form): Schema
{
$options = [];
foreach ($this->categories as $cat) {
$options[$cat['name']] = $cat['name'];
}
return $form
->schema([
Select::make('selectedCategory')
->label(trans('blockland-config::blockland-config.browser.category'))
->options($options)
->searchable()
->placeholder(trans('blockland-config::blockland-config.browser.select_category'))
->live(),
TextInput::make('search')
->label(trans('blockland-config::blockland-config.browser.search'))
->placeholder(trans('blockland-config::blockland-config.browser.search_placeholder'))
->live(debounce: 300),
])
->statePath(null);
}
public function getAllAddOns(): array
{
if ($this->selectedCategory) {
$addOns = BlocklandConfig::fetchRetailAddOns($this->selectedCategory);
} elseif ($this->search !== '') {
$addOns = $this->fetchAddOnsFromAllCategories();
} else {
return [];
}
if ($this->search !== '') {
$needle = strtolower($this->search);
$addOns = array_filter($addOns, fn ($a) => str_contains(strtolower($a['name']), $needle));
$addOns = array_values($addOns);
}
return $addOns;
}
protected function fetchAddOnsFromAllCategories(): array
{
$all = [];
$seen = [];
foreach ($this->categories as $cat) {
$addOns = BlocklandConfig::fetchRetailAddOns($cat['name']);
foreach ($addOns as $addOn) {
$key = strtolower($addOn['filename']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$all[] = $addOn;
}
}
usort($all, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return array_values($all);
}
public function getPaginatedData(): array
{
$all = $this->getAllAddOns();
$total = count($all);
$lastPage = max(1, (int) ceil($total / $this->perPage));
if ($this->page > $lastPage) {
$this->page = $lastPage;
}
$offset = ($this->page - 1) * $this->perPage;
$items = array_slice($all, $offset, $this->perPage);
return [
'items' => $items,
'total' => $total,
'page' => $this->page,
'lastPage' => $lastPage,
'perPage' => $this->perPage,
'from' => $total > 0 ? $offset + 1 : 0,
'to' => min($offset + $this->perPage, $total),
];
}
public function previousPage(): void
{
$this->page = max(1, $this->page - 1);
}
public function nextPage(): void
{
$data = $this->getPaginatedData();
$this->page = min($data['lastPage'], $this->page + 1);
}
public function goToPage(int $page): void
{
$this->page = max(1, $page);
}
public function installAddOn(string $url, string $filename, string $name): void
{
try {
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
BlocklandConfig::installRetailAddOn($server, $fileRepository, $url, $filename);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.addon_install_started'))
->body(trans('blockland-config::blockland-config.notifications.addon_install_started_detail', [
'name' => $name,
]))
->success()
->send();
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.addon_install_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,209 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
/**
* @property Schema $form
*/
class BlocklandAddOnsPage extends Page
{
use InteractsWithForms;
protected string $view = 'blockland-config::pages.addons-page';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-puzzle';
protected static ?string $slug = 'blockland-addons';
protected static ?int $navigationSort = 20;
public ?array $data = [];
public bool $addOnsLoaded = false;
public array $groupedAddOns = [];
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_ADDONS);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.addons');
}
public function getTitle(): string
{
return static::getNavigationLabel();
}
public function mount(): void
{
}
public function loadAddOns(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$addOns = BlocklandConfig::getAddOns($server, $fileRepository);
$installedNames = BlocklandConfig::listInstalledAddOnNames($server, $fileRepository);
$result = BlocklandConfig::cleanupOrphanedAddOns($addOns, $installedNames);
if (!empty($result['removed'])) {
BlocklandConfig::saveAddOns($server, $fileRepository, $result['cleaned']);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.addons_cleaned'))
->body(trans('blockland-config::blockland-config.notifications.addons_cleaned_detail', [
'count' => count($result['removed']),
]))
->info()
->send();
}
$addOns = $result['cleaned'];
$this->groupedAddOns = BlocklandConfig::groupAddOnsByCategory($addOns);
$this->addOnsLoaded = true;
$formData = [];
foreach ($addOns as $name => $value) {
$formData['addon_' . $name] = $value === 1;
}
$this->form->fill($formData);
}
public function form(Schema $form): Schema
{
if (!$this->addOnsLoaded || empty($this->groupedAddOns)) {
return $form->schema([])->statePath('data');
}
$sections = [];
foreach ($this->groupedAddOns as $category => $items) {
$toggles = [];
foreach ($items as $name => $value) {
$label = str_replace('_', ' ', preg_replace('/^' . preg_quote($category, '/') . '_/', '', $name));
$toggles[] = Toggle::make('addon_' . $name)
->label($label)
->inline(false);
}
$transKey = 'blockland-config::blockland-config.categories.' . strtolower($category);
$translated = trans($transKey);
$sectionLabel = $translated === $transKey ? $category : $translated;
$sections[] = Section::make($sectionLabel)
->schema($toggles)
->collapsible()
->collapsed()
->columns(2);
}
return $form
->schema($sections)
->statePath('data');
}
protected function getHeaderActions(): array
{
return [
Action::make('save')
->label(trans('blockland-config::blockland-config.actions.save'))
->icon('tabler-device-floppy')
->disabled(fn () => !$this->addOnsLoaded)
->action(function () {
$this->saveAddOns();
}),
Action::make('save_and_restart')
->label(trans('blockland-config::blockland-config.actions.save_and_restart'))
->icon('tabler-refresh')
->color('warning')
->disabled(fn () => !$this->addOnsLoaded)
->requiresConfirmation()
->modalHeading(trans('blockland-config::blockland-config.modals.restart_heading'))
->modalDescription(trans('blockland-config::blockland-config.modals.restart_description'))
->action(function () {
$this->saveAddOns(restart: true);
}),
];
}
public function saveAddOns(bool $restart = false): void
{
try {
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$currentAddOns = BlocklandConfig::getAddOns($server, $fileRepository);
$installedNames = BlocklandConfig::listInstalledAddOnNames($server, $fileRepository);
$result = BlocklandConfig::cleanupOrphanedAddOns($currentAddOns, $installedNames);
$currentAddOns = $result['cleaned'];
$formData = $this->form->getState();
$updatedAddOns = [];
foreach ($currentAddOns as $name => $oldValue) {
$formKey = 'addon_' . $name;
$updatedAddOns[$name] = !empty($formData[$formKey]) ? 1 : -1;
}
BlocklandConfig::saveAddOns($server, $fileRepository, $updatedAddOns);
if ($restart) {
/** @var DaemonServerRepository $serverRepository */
$serverRepository = app(DaemonServerRepository::class);
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.addons_saved_restarting'))
->success()
->send();
} else {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.addons_saved'))
->success()
->send();
}
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,411 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Grid;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
/**
* @property Schema $addForm
* @property Schema $demoteForm
*/
class BlocklandAdminsPage extends Page
{
use InteractsWithForms;
protected string $view = 'blockland-config::pages.admins-page';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-shield';
protected static ?string $slug = 'blockland-admins';
protected static ?int $navigationSort = 21;
public array $superAdmins = [];
public array $admins = [];
public string $newBlId = '';
public string $newRole = 'admin';
public bool $newModeAuto = false;
public string $demoteBlId = '';
public string $demoteFromRole = 'admin';
public bool $confirmingDemote = false;
/** @var array{blId: string, list: string}|null */
public ?array $confirmingRemove = null;
public bool $silentMode = false;
public bool $adminsLoaded = false;
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (! $server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_ADMINS);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.admins');
}
public function getTitle(): string
{
return static::getNavigationLabel();
}
protected function getForms(): array
{
return ['addForm', 'demoteForm'];
}
public function addForm(Schema $form): Schema
{
return $form
->schema([
Grid::make(2)->schema([
TextInput::make('newBlId')
->label(trans('blockland-config::blockland-config.admins.bl_id'))
->placeholder('BL_ID')
->live(),
Select::make('newRole')
->label(trans('blockland-config::blockland-config.admins.role'))
->options([
'admin' => trans('blockland-config::blockland-config.admins.admins'),
'super' => trans('blockland-config::blockland-config.admins.super_admins'),
])
->placeholder(trans('blockland-config::blockland-config.admins.role'))
->position('bottom')
->live(),
]),
Checkbox::make('newModeAuto')
->label(trans('blockland-config::blockland-config.admins.mode_auto_checkbox')),
])
->statePath(null);
}
public function demoteForm(Schema $form): Schema
{
return $form
->schema([
TextInput::make('demoteBlId')
->label(trans('blockland-config::blockland-config.admins.bl_id'))
->placeholder('BL_ID')
->live(),
Select::make('demoteFromRole')
->label(trans('blockland-config::blockland-config.admins.demote_from'))
->options([
'admin' => trans('blockland-config::blockland-config.admins.admins'),
'super' => trans('blockland-config::blockland-config.admins.super_admins'),
])
->placeholder(trans('blockland-config::blockland-config.admins.demote_from'))
->position('bottom')
->live(),
])
->statePath(null)
->columns(2);
}
public function loadAdmins(): void
{
$this->loadAdminLists();
$this->loadSilentMode();
$this->adminsLoaded = true;
}
public function loadSilentMode(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$this->silentMode = BlocklandConfig::isSilentMode($server, $fileRepository);
}
public function toggleSilentMode(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$this->silentMode = ! $this->silentMode;
BlocklandConfig::setSilentMode($server, $fileRepository, $this->silentMode);
Notification::make()
->title($this->silentMode
? trans('blockland-config::blockland-config.admins.silent_enabled')
: trans('blockland-config::blockland-config.admins.silent_disabled'))
->success()
->send();
}
public function loadAdminLists(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$lists = BlocklandConfig::getAdminLists($server, $fileRepository);
$this->superAdmins = $lists['superAdmins'];
$this->admins = $lists['admins'];
}
public function askConfirmDemote(): void
{
$blId = trim($this->demoteBlId);
if ($blId === '' || ! ctype_digit($blId)) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.')
->danger()
->send();
return;
}
$this->confirmingDemote = true;
}
public function askRemoveFromList(string $blId, string $list): void
{
$this->confirmingRemove = ['blId' => $blId, 'list' => $list];
}
public function cancelConfirm(): void
{
$this->confirmingDemote = false;
$this->confirmingRemove = null;
}
public function confirmRemoveFromList(): void
{
if ($this->confirmingRemove === null) {
return;
}
$blId = $this->confirmingRemove['blId'];
$list = $this->confirmingRemove['list'];
$this->confirmingRemove = null;
$this->removeFromList($blId, $list);
}
public function removeFromList(string $blId, string $list): void
{
$superAdmins = $this->superAdmins;
$admins = $this->admins;
$fromLevel = $list === 'super' ? 2 : 1;
if ($list === 'super') {
$superAdmins = array_values(array_filter($superAdmins, fn ($id) => $id !== $blId));
} else {
$admins = array_values(array_filter($admins, fn ($id) => $id !== $blId));
}
$this->saveAndApply($superAdmins, $admins);
$server = Filament::getTenant();
$fileRepository = app(DaemonFileRepository::class);
$commands = BlocklandConfig::buildDemoteAdminCommands(
[$blId],
[$blId => $fromLevel],
BlocklandConfig::isSilentMode($server, $fileRepository)
);
try {
BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands);
} catch (Exception) {
}
$roleLabel = $list === 'super' ? trans('blockland-config::blockland-config.admins.super_admins') : trans('blockland-config::blockland-config.admins.admins');
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved'))
->body(trans('blockland-config::blockland-config.admins.removed', ['id' => $blId, 'role' => $roleLabel]))
->success()
->send();
}
public function addByBlId(): void
{
$blId = trim($this->newBlId);
if ($blId === '' || ! ctype_digit($blId)) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.')
->danger()
->send();
return;
}
$manual = ! $this->newModeAuto;
if ($manual) {
$level = $this->newRole === 'super' ? 2 : 1;
$commands = BlocklandConfig::buildApplyAdminCommands(
[$blId],
$level,
[$blId => 0],
manual: true,
silent: BlocklandConfig::isSilentMode(Filament::getTenant(), app(DaemonFileRepository::class))
);
try {
BlocklandConfig::sendConsoleCommands(
Filament::getTenant(),
app(DaemonFileRepository::class),
$commands
);
} catch (Exception) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body(trans('blockland-config::blockland-config.admins.server_offline'))
->danger()
->send();
return;
}
} else {
$superAdmins = $this->superAdmins;
$admins = $this->admins;
if ($this->newRole === 'super') {
$admins = array_values(array_filter($admins, fn ($id) => $id !== $blId));
if (! in_array($blId, $superAdmins, true)) {
$superAdmins[] = $blId;
}
} else {
$superAdmins = array_values(array_filter($superAdmins, fn ($id) => $id !== $blId));
if (! in_array($blId, $admins, true)) {
$admins[] = $blId;
}
}
$this->saveAndApply($superAdmins, $admins);
$level = $this->newRole === 'super' ? 2 : 1;
$server = Filament::getTenant();
$fileRepository = app(DaemonFileRepository::class);
$commands = BlocklandConfig::buildApplyAdminCommands(
[$blId],
$level,
[$blId => 0],
manual: false,
silent: BlocklandConfig::isSilentMode($server, $fileRepository)
);
try {
BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands);
} catch (Exception) {
}
}
$this->newBlId = '';
$role = $this->newRole === 'super' ? 'Super Admin' : 'Admin';
$suffix = $manual ? ' ' . trans('blockland-config::blockland-config.admins.session_only') : '';
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved'))
->body(trans('blockland-config::blockland-config.admins.added', ['name' => "BL_ID {$blId}", 'role' => $role . $suffix]))
->success()
->send();
}
public function demoteByBlId(): void
{
$blId = trim($this->demoteBlId);
if ($blId === '' || ! ctype_digit($blId)) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.')
->danger()
->send();
return;
}
$fromLevel = $this->demoteFromRole === 'super' ? 2 : 1;
$server = Filament::getTenant();
$fileRepository = app(DaemonFileRepository::class);
$commands = BlocklandConfig::buildDemoteAdminCommands(
[$blId],
[$blId => $fromLevel],
BlocklandConfig::isSilentMode($server, $fileRepository)
);
try {
BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands);
} catch (Exception) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body(trans('blockland-config::blockland-config.admins.server_offline'))
->danger()
->send();
return;
}
$this->demoteBlId = '';
$this->confirmingDemote = false;
Notification::make()
->title(trans('blockland-config::blockland-config.admins.demoted'))
->success()
->send();
}
protected function saveAndApply(array $superAdmins, array $admins): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
BlocklandConfig::saveAdminLists($server, $fileRepository, $superAdmins, $admins);
$this->superAdmins = $superAdmins;
$this->admins = $admins;
try {
BlocklandConfig::sendConsoleCommands($server, $fileRepository, ['exec("config/server/prefs.cs");']);
} catch (Exception) {
}
}
protected function getHeaderActions(): array
{
return [
Action::make('silent_mode')
->label(trans('blockland-config::blockland-config.admins.silent_mode'))
->icon(fn () => $this->silentMode ? 'tabler-volume-off' : 'tabler-volume')
->color(fn () => $this->silentMode ? 'success' : 'gray')
->tooltip(trans('blockland-config::blockland-config.admins.silent_mode_tooltip'))
->action(fn () => $this->toggleSilentMode()),
];
}
}
@@ -0,0 +1,168 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
class BlocklandColorSetPage extends Page
{
use InteractsWithForms;
protected string $view = 'blockland-config::pages.colorset-page';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-palette';
protected static ?string $slug = 'blockland-colorset';
protected static ?int $navigationSort = 21;
public array $colorSetPreview = [];
public int $totalColors = 0;
public bool $colorSetLoaded = false;
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_COLORSET);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.colorset');
}
public function getTitle(): string
{
return static::getNavigationLabel();
}
public function mount(): void
{
}
public function loadColorSet(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$colorSet = BlocklandConfig::getActiveColorSet($server, $fileRepository);
$this->colorSetPreview = $colorSet['sections'];
$this->totalColors = $colorSet['totalColors'];
$this->colorSetLoaded = true;
}
protected function getHeaderActions(): array
{
return [
Action::make('activate_colorset')
->label(trans('blockland-config::blockland-config.actions.activate_colorset'))
->icon('tabler-palette')
->color('success')
->disabled(fn () => !$this->colorSetLoaded)
->form([
Select::make('colorset')
->label(trans('blockland-config::blockland-config.colorset.select_addon'))
->options(fn () => $this->getColorsetOptions())
->searchable()
->required(),
])
->modalCancelAction(fn (Action $action) => $action
->extraAttributes(['class' => '!bg-white dark:!bg-gray-800 dark:!ring-gray-600 dark:hover:!bg-gray-900 !text-gray-950 dark:!text-white'], merge: true))
->action(function (array $data) {
$this->activateColorsetAddOn($data['colorset']);
}),
];
}
protected function getColorsetOptions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$addOns = BlocklandConfig::listColorsetAddOns($server, $fileRepository);
$options = [];
foreach ($addOns as $addOn) {
$key = $addOn['path'] . '|' . ($addOn['isZip'] ? '1' : '0');
$options[$key] = $addOn['name'] . ($addOn['isZip'] ? ' (.zip)' : '');
}
return $options;
}
public function activateColorsetAddOn(string $selection): void
{
try {
[$path, $isZipStr] = explode('|', $selection, 2);
$isZip = $isZipStr === '1';
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$result = BlocklandConfig::activateColorset($server, $fileRepository, $path, $isZip);
if ($result === 'ok') {
/** @var DaemonServerRepository $serverRepository */
$serverRepository = app(DaemonServerRepository::class);
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.colorset_activated_restarting'))
->success()
->send();
$this->redirect(static::getUrl());
} elseif ($result === 'too_many_colors') {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed'))
->body(trans('blockland-config::blockland-config.notifications.colorset_too_many_colors', ['max' => BlocklandConfigService::MAX_COLORS]))
->danger()
->send();
} else {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed'))
->body(trans('blockland-config::blockland-config.notifications.colorset_not_found'))
->danger()
->send();
}
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,403 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
/**
* @property Schema $form
*/
class BlocklandPrefsPage extends Page
{
use InteractsWithForms;
protected string $view = 'blockland-config::pages.prefs-page';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-adjustments';
protected static ?string $slug = 'blockland-prefs';
protected static ?int $navigationSort = 22;
public ?array $data = [];
/**
* Stores the original pref metadata (key, rawKey, quoted, etc.) for serialization.
*
* @var array<int, array{key: string, rawKey: string, value: string, quoted: bool}>
*/
public array $prefsMeta = [];
public bool $prefsLoaded = false;
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_PREFS);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.preferences');
}
public function getTitle(): string
{
return static::getNavigationLabel();
}
public function mount(): void
{
}
public function loadPrefs(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$prefs = BlocklandConfig::getPrefs($server, $fileRepository);
$this->prefsMeta = $prefs;
$this->prefsLoaded = true;
$formData = [];
foreach ($prefs as $i => $pref) {
$fieldType = $this->detectFieldType($pref);
$formKey = 'pref_' . $i;
if ($fieldType === 'boolean') {
$formData[$formKey] = in_array($pref['value'], ['1', 'true'], true);
} else {
$formData[$formKey] = $pref['value'];
}
}
$this->form->fill($formData);
}
protected function detectFieldType(array $pref): string
{
$key = $pref['key'];
$value = $pref['value'];
$booleanKeys = [
'AllowMultiClient', 'AllowColorLoading', 'ETardFilter', 'FallingDamage',
'RandomBrickColor', 'ResetOnEmpty', 'WrenchEventsAdminOnly',
'ClearEventsOnClientExit', 'UseRTB',
'AdminOnly', 'AdminTrustBypass1', 'AdminTrustBypass2',
'FillBricksAdminOnly', 'FloatAdminOnly', 'LoadAdminOnly',
'PaintAdminOnly', 'PaintFxAdminOnly', 'PlayMenuSounds',
'SaveAdminOnly', 'ScatterGhostBricks', 'SelectPublicBricks',
'SymTableOnStart', 'WrenchAdminOnly',
'AdminOrb', 'AllowRepeat', 'IgnoreRayCasting', 'IgnoreRaycasting',
'AdminOrbPainting', 'ColorPick', 'DefaultToPaint', 'ShowBricks',
'SuperAdmin',
];
$shortKey = preg_replace('/^.*::/', '', $key);
foreach ($booleanKeys as $bk) {
if ($shortKey === $bk) {
return 'boolean';
}
}
if (!$pref['quoted'] && is_numeric($value)) {
return 'numeric';
}
if (preg_match('/(Password|WelcomeMessage|Info|Name|AutoAdminList|AutoSuperAdminList|ETardList|ConnectionError)$/', $shortKey)) {
return 'text';
}
return 'text';
}
protected function getHumanLabel(string $key): string
{
$short = preg_replace('/^[Pp]ref::(Server|Net)::/', '', $key);
$short = str_replace('::', ' > ', $short);
return $short;
}
public function form(Schema $form): Schema
{
if (!$this->prefsLoaded || empty($this->prefsMeta)) {
return $form->schema([])->statePath('data');
}
$prefs = $this->prefsMeta;
$grouped = BlocklandConfig::groupPrefs($prefs);
$globalIndexMap = [];
foreach ($prefs as $i => $pref) {
$globalIndexMap[$pref['key']] = $i;
}
$sections = [];
foreach ($grouped as $groupName => $groupPrefs) {
$fields = [];
foreach ($groupPrefs as $pref) {
$i = $globalIndexMap[$pref['key']] ?? null;
if ($i === null) {
continue;
}
$shortKey = preg_replace('/^.*::/', '', $pref['key']);
if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) {
continue;
}
$formKey = 'pref_' . $i;
$label = $this->getHumanLabel($pref['key']);
$fieldType = $this->detectFieldType($pref);
$validationRules = BlocklandConfigService::PREF_VALIDATION[$shortKey] ?? null;
$restartHint = BlocklandConfig::requiresRestart($pref['key']);
if ($fieldType === 'boolean') {
$fields[] = Toggle::make($formKey)
->label($label)
->inline(false)
->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null);
} elseif ($fieldType === 'numeric') {
$input = TextInput::make($formKey)
->label($label)
->numeric();
if ($validationRules) {
if (isset($validationRules['min'])) {
$input->minValue($validationRules['min']);
}
if (isset($validationRules['max'])) {
$input->maxValue($validationRules['max']);
}
}
$fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null);
} elseif (preg_match('/(WelcomeMessage|Info)$/', $pref['key'])) {
$input = Textarea::make($formKey)
->label($label)
->rows(2)
->default('');
if ($validationRules && isset($validationRules['maxLength'])) {
$input->maxLength($validationRules['maxLength']);
}
$fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null);
} elseif (preg_match('/(Password|SuperAdminPassword|AdminPassword)$/', $pref['key'])) {
$input = TextInput::make($formKey)
->label($label)
->password()
->revealable()
->default('');
if ($validationRules && isset($validationRules['maxLength'])) {
$input->maxLength($validationRules['maxLength']);
}
$fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null);
} else {
$input = TextInput::make($formKey)
->label($label)
->default('');
if ($validationRules && isset($validationRules['maxLength'])) {
$input->maxLength($validationRules['maxLength']);
}
$fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null);
}
}
if (count($fields) === 0) {
continue;
}
$translationKey = 'blockland-config::blockland-config.pref_groups.' . strtolower(str_replace(' ', '_', $groupName));
$label = trans($translationKey);
if ($label === $translationKey) {
$label = Str::title(str_replace('_', ' ', Str::snake($groupName)));
}
$sections[] = Section::make($label)
->schema($fields)
->collapsible()
->collapsed()
->columns(2);
}
return $form
->schema($sections)
->statePath('data');
}
protected function getHeaderActions(): array
{
return [
Action::make('save')
->label(trans('blockland-config::blockland-config.actions.save'))
->icon('tabler-device-floppy')
->disabled(fn () => !$this->prefsLoaded)
->action(function () {
$this->savePrefs();
}),
Action::make('save_and_restart')
->label(trans('blockland-config::blockland-config.actions.save_and_restart'))
->icon('tabler-refresh')
->color('warning')
->disabled(fn () => !$this->prefsLoaded)
->requiresConfirmation()
->modalHeading(trans('blockland-config::blockland-config.modals.restart_heading'))
->modalDescription(trans('blockland-config::blockland-config.modals.prefs_restart_description'))
->action(function () {
$this->savePrefs(restart: true);
}),
];
}
public function savePrefs(bool $restart = false): void
{
try {
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$formData = $this->form->getState();
$updatedPrefs = [];
foreach ($this->prefsMeta as $i => $meta) {
$shortKey = preg_replace('/^.*::/', '', $meta['key']);
if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) {
$updatedPrefs[] = [
'key' => $meta['key'],
'rawKey' => $meta['rawKey'],
'value' => $meta['value'],
'quoted' => $meta['quoted'],
];
continue;
}
$formKey = 'pref_' . $i;
$fieldType = $this->detectFieldType($meta);
if ($fieldType === 'boolean') {
$newValue = !empty($formData[$formKey]) ? '1' : '0';
} else {
$newValue = array_key_exists($formKey, $formData)
? (string) ($formData[$formKey] ?? '')
: $meta['value'];
}
$error = BlocklandConfig::validatePrefValue($meta['key'], $newValue);
if ($error !== null) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body($error)
->danger()
->send();
return;
}
$updatedPrefs[] = [
'key' => $meta['key'],
'rawKey' => $meta['rawKey'],
'value' => $newValue,
'quoted' => $meta['quoted'],
];
}
BlocklandConfig::savePrefs($server, $fileRepository, $updatedPrefs);
$oldMeta = $this->prefsMeta;
$this->prefsMeta = $updatedPrefs;
$changedPrefs = [];
foreach ($updatedPrefs as $i => $pref) {
$shortKey = preg_replace('/^.*::/', '', $pref['key'] ?? '');
if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) {
continue;
}
$orig = $oldMeta[$i] ?? null;
if ($orig === null || (string) ($orig['value'] ?? '') !== (string) ($pref['value'] ?? '')) {
$changedPrefs[] = $pref;
}
}
if ($restart) {
/** @var DaemonServerRepository $serverRepository */
$serverRepository = app(DaemonServerRepository::class);
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved_restarting'))
->success()
->send();
return;
}
if (! empty($changedPrefs)) {
try {
$commands = array_merge(
BlocklandConfig::buildPrefConsoleCommands($changedPrefs),
['webcom_postserver();']
);
if (!BlocklandConfig::isSilentMode($server, $fileRepository)) {
$commands[] = 'messageAll(\'MsgAdminForce\', "\\c2Server settings have been updated");';
}
BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved'))
->body(trans('blockland-config::blockland-config.notifications.prefs_applied_live'))
->success()
->send();
} catch (Exception) {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved'))
->body(trans('blockland-config::blockland-config.notifications.prefs_restart_to_apply'))
->success()
->send();
}
} else {
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.prefs_saved'))
->success()
->send();
}
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.notifications.save_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,199 @@
<?php
namespace Pelican\BlocklandConfig\Filament\Server\Pages;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Exception;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Pelican\BlocklandConfig\Facades\BlocklandConfig;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
class BlocklandSaveLoaderPage extends Page
{
use WithFileUploads;
protected string $view = 'blockland-config::pages.save-loader-page';
protected static string|\BackedEnum|null $navigationIcon = 'tabler-folder-open';
protected static ?string $slug = 'blockland-save-loader';
protected static ?int $navigationSort = 24;
public bool $savesLoaded = false;
/** @var array<int, array{name: string, path: string}> */
public array $saves = [];
public ?TemporaryUploadedFile $uploadedFile = null;
public ?TemporaryUploadedFile $uploadedPreviewFile = null;
public function mount(): void
{
}
public static function canAccess(): bool
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server) {
return false;
}
return parent::canAccess()
&& BlocklandConfig::isBlocklandServer($server)
&& BlocklandConfig::hasBlocklandFeature($server, BlocklandConfigService::FEATURE_SAVE_LOADER);
}
public static function getNavigationLabel(): string
{
return trans('blockland-config::blockland-config.pages.save_loader');
}
public function getTitle(): string
{
return static::getNavigationLabel();
}
public function loadSaves(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
$saves = BlocklandConfig::listSaveFiles($server, $fileRepository);
foreach ($saves as &$save) {
if (isset($save['preview_path'])) {
$save['preview_url'] = route('api:client:server.blockland-save-preview', ['server' => $server->uuid]) . '?file=' . rawurlencode($save['preview_path']);
}
}
$this->saves = $saves;
$this->savesLoaded = true;
}
public function copyToTemp(string $path): void
{
try {
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
BlocklandConfig::copySaveToTemp($server, $fileRepository, $path);
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.copy_success'))
->success()
->send();
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.copy_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function uploadSave(): void
{
$file = $this->uploadedFile ?? null;
if (!$file instanceof TemporaryUploadedFile || !$file->isValid()) {
if ($file && !$file->isValid()) {
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.upload_failed'))
->body($file->getErrorMessage())
->danger()
->send();
} else {
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.upload_failed'))
->body(trans('blockland-config::blockland-config.save_loader.upload_no_file'))
->danger()
->send();
}
return;
}
$filename = $file->getClientOriginalName();
if (!str_ends_with(strtolower($filename), '.bls')) {
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.upload_failed'))
->body(trans('blockland-config::blockland-config.save_loader.upload_invalid_type'))
->danger()
->send();
return;
}
try {
$content = file_get_contents($file->getRealPath());
/** @var Server $server */
$server = Filament::getTenant();
/** @var DaemonFileRepository $fileRepository */
$fileRepository = app(DaemonFileRepository::class);
BlocklandConfig::uploadSaveFile($server, $fileRepository, $content, $filename);
$base = substr($filename, 0, -4);
$previewFile = $this->uploadedPreviewFile;
if ($previewFile instanceof TemporaryUploadedFile && $previewFile->isValid()) {
$previewName = $previewFile->getClientOriginalName();
$previewLower = strtolower($previewName);
if (preg_match('/\.(jpg|jpeg|png)$/', $previewLower)) {
$expectedBase = $base;
$actualBase = preg_replace('/\.(jpg|jpeg|png)$/i', '', $previewName);
if (strcasecmp($actualBase, $expectedBase) === 0) {
try {
$previewContent = file_get_contents($previewFile->getRealPath());
BlocklandConfig::uploadPreviewFile($server, $fileRepository, $previewContent, $previewName);
} catch (Exception $e) {
report($e);
}
}
}
}
$this->uploadedFile = null;
$this->uploadedPreviewFile = null;
$this->loadSaves();
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.upload_success'))
->success()
->send();
} catch (Exception $e) {
report($e);
Notification::make()
->title(trans('blockland-config::blockland-config.save_loader.upload_failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function removeUploadedFile(): void
{
$this->uploadedFile = null;
}
public function removeUploadedPreviewFile(): void
{
$this->uploadedPreviewFile = null;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Pelican\BlocklandConfig\Http\Controllers\Api\Client\Servers;
use App\Enums\SubuserPermission;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SavePreviewController extends ClientApiController
{
public function __construct(
private DaemonFileRepository $fileRepository
) {
parent::__construct();
}
public function __invoke(Request $request, Server $server): Response|ResponseFactory
{
abort_unless(user()?->can(SubuserPermission::FileReadContent, $server), 403);
$file = $request->query('file');
if (!is_string($file) || $file === '') {
throw new NotFoundHttpException();
}
$path = rawurldecode($file);
if (!str_starts_with($path, BlocklandConfigService::SAVES_DIR . '/')) {
throw new NotFoundHttpException();
}
$lower = strtolower($path);
if (!preg_match('/\.(jpg|jpeg|png)$/', $lower)) {
throw new NotFoundHttpException();
}
try {
$content = $this->fileRepository->setServer($server)->getContent(
$path,
BlocklandConfigService::MAX_PREVIEW_SIZE_BYTES
);
} catch (\Exception) {
throw new NotFoundHttpException();
}
$mime = match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
default => 'application/octet-stream',
};
return response($content, 200, [
'Content-Type' => $mime,
'Cache-Control' => 'private, max-age=300',
]);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Pelican\BlocklandConfig\Providers;
use Illuminate\Support\ServiceProvider;
use Pelican\BlocklandConfig\Services\BlocklandConfigService;
class BlocklandConfigProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/blockland-config.php', 'blockland-config');
$this->app->singleton(BlocklandConfigService::class);
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'blockland-config');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'blockland-config');
}
}
@@ -0,0 +1,19 @@
<?php
namespace Pelican\BlocklandConfig\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class BlocklandConfigRoutesProvider extends ServiceProvider
{
public function boot(): void
{
$this->routes(function () {
Route::middleware(['api', 'client-api', 'throttle:api.client'])
->prefix('/api/client')
->scopeBindings()
->group(plugin_path('blockland-config', 'routes/api-client.php'));
});
}
}
File diff suppressed because it is too large Load Diff