Update .. workshop search

This commit is contained in:
Frank Harris 2026-01-18 18:06:36 -06:00
parent 4baa43bcbf
commit faf0de39a7
10 changed files with 982 additions and 22 deletions

View file

@ -6,3 +6,7 @@
## 2026-01-18
- Reworked Steam Workshop support detection to read installer AppIDs directly from the canonical game XMLs, ensuring admin mappings and Game Monitor buttons only appear for true Workshop-enabled titles.
## 2026-01-19
- Hid staging directory, install strategy, and post-install script controls from non-admins to keep panel defaults enforced for customers.
- Added the Steam Workshop mod picker, including search-backed UI, JSON state handling, and refreshed styling so customers can select mods without touching raw ID lists.

View file

@ -1 +1,2 @@
- Auto-detect which server configs actually support Steam Workshop before showing adapter controls.
- Allow players/admins to reorder selected Workshop mods in the new picker UI so load order matches game expectations.

View file

@ -22,7 +22,13 @@ class SteamWorkshopController
$isAdmin = $db->isAdmin($userId);
$action = $_GET['action'] ?? 'index';
if ($action === 'search') {
$this->handleSearch($userId, $isAdmin);
return;
}
echo '<link rel="stylesheet" type="text/css" href="modules/steam_workshop/steam_workshop.css" />';
echo '<script src="modules/steam_workshop/steam_workshop.js" defer></script>';
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$this->handleSave($userId, $isAdmin);
@ -117,6 +123,41 @@ class SteamWorkshopController
]);
}
private function handleSearch(int $userId, bool $isAdmin): void
{
header('Content-Type: application/json');
$homeId = isset($_GET['home_id']) ? (int)$_GET['home_id'] : 0;
$query = trim((string)($_GET['q'] ?? ''));
if ($homeId <= 0) {
echo json_encode(['ok' => false, 'error' => $this->lang['error_missing_home'] ?? 'Home ID missing.']);
return;
}
if ($query === '') {
echo json_encode(['ok' => false, 'error' => $this->lang['error_missing_query'] ?? 'Enter a search term.']);
return;
}
$home = $this->service->getHome($homeId, $userId, $isAdmin);
if ($home === null) {
echo json_encode(['ok' => false, 'error' => $this->lang['error_home_not_found'] ?? 'Home not found.']);
return;
}
$gameKey = (string)($home['game_key'] ?? '');
if ($gameKey === '') {
echo json_encode(['ok' => false, 'error' => $this->lang['error_home_not_found'] ?? 'Home not found.']);
return;
}
$results = $this->service->searchWorkshopItems($gameKey, $query);
if (empty($results)) {
echo json_encode(['ok' => true, 'results' => [], 'empty' => true]);
return;
}
echo json_encode(['ok' => true, 'results' => $results]);
}
private function applyGameAdapterOverride(array $home, array &$config): bool
{
$gameKey = isset($home['game_key']) ? (string)$home['game_key'] : '';

View file

@ -16,6 +16,7 @@ return [
'label_post_install_script' => 'Post-install script (absolute path)',
'label_mod_import' => 'Workshop IDs list (one "id,@ModName" per line)',
'hint_mod_import' => 'Paste from Modlist.txt or import from a collection. IDs are sanitized automatically.',
'hint_admin_only' => 'Managed by your administrator.',
'adapter_locked_note' => 'This adapter is enforced for the current game type by your administrator.',
'admin_heading_game_mapping' => 'Adapter mapping by game type',
'admin_subheading_game_mapping' => 'Pick which adapter becomes the default whenever a server of that game opens the Workshop UI.',
@ -64,6 +65,25 @@ return [
'message_adapter_saved' => 'Adapter saved.',
'message_adapter_deleted' => 'Adapter deleted.',
'error_admin_only' => 'Administrator access required.',
'mod_picker_heading' => 'Workshop library',
'mod_picker_hint' => 'Search Steam Workshop and add mods to keep them synced automatically.',
'mod_picker_search_label' => 'Search Steam Workshop',
'mod_picker_search_placeholder' => 'Example: 221100 or QoL tweaks',
'mod_picker_search_button' => 'Search',
'mod_picker_selected_heading' => 'Selected mods',
'mod_picker_selected_hint' => 'Enable syncing to pull each mod before game server restarts.',
'mod_picker_selected_empty' => 'No mods selected yet. Use the search above to add your first entry.',
'mod_picker_results_heading' => 'Search results',
'mod_picker_results_select' => 'Select',
'mod_picker_results_title' => 'Title',
'mod_picker_results_author' => 'Author',
'mod_picker_action_add' => 'Add',
'mod_picker_action_remove' => 'Remove',
'mod_picker_status_loading' => 'Searching Steam Workshop…',
'mod_picker_status_error' => 'Unable to contact the Steam Workshop. Try again in a moment.',
'mod_picker_results_empty' => 'No workshop items matched that search.',
'mod_picker_status_need_query' => 'Enter a Workshop ID or keyword before searching.',
'mod_picker_toggle_label' => 'Sync',
'error_game_key_required' => 'Select a valid game key before editing the adapter.',
'error_adapter_delete_failed' => 'Adapter could not be deleted.',
'button_edit_adapter' => 'Edit',
@ -79,4 +99,5 @@ return [
'label_adapter_hot_reload' => 'Supports hot reload',
'label_adapter_activation' => 'Activation template',
'label_adapter_notes' => 'Notes',
'error_missing_query' => 'Enter a search term before querying the Workshop.',
];

View file

@ -145,7 +145,13 @@ class SteamWorkshopService
{
$input = $payload['workshop'] ?? [];
$rawMods = trim((string)($input['raw_items'] ?? ''));
$items = $this->parseWorkshopItems($rawMods);
$selectedItems = $this->parseSelectedItemsJson((string)($input['selected_items'] ?? ''));
if (!empty($selectedItems)) {
$items = $selectedItems;
$rawMods = $this->serializeWorkshopItems($selectedItems);
} else {
$items = $this->parseWorkshopItems($rawMods);
}
return [
'workshop_enabled' => isset($input['workshop_enabled']) ? (bool)$input['workshop_enabled'] : false,
@ -568,6 +574,41 @@ class SteamWorkshopService
return array_values(array_unique($keys));
}
public function getSteamAppIdForGameKey(string $gameKey): ?string
{
$xml = $this->loadServerConfigXml($gameKey);
if ($xml === null) {
return null;
}
return $this->parseSteamAppIdFromConfig($xml);
}
public function searchWorkshopItems(string $gameKey, string $query, int $limit = 12): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$appId = $this->getSteamAppIdForGameKey($gameKey);
if ($appId === null) {
return [];
}
if (ctype_digit($query)) {
$item = $this->fetchWorkshopItemById($query);
return $item === null ? [] : [$item];
}
$html = $this->fetchWorkshopSearchHtml($appId, $query, $limit * 2);
if ($html === null) {
return [];
}
return $this->extractWorkshopItemsFromHtml($html, $limit);
}
private function sanitizeInterval(?int $minutes): int
{
if ($minutes === null || $minutes <= 0) {
@ -617,6 +658,8 @@ class SteamWorkshopService
$item['label'] = trim((string)($item['label'] ?? ''));
$item['enabled'] = !empty($item['enabled']);
$item['source'] = $item['source'] ?? 'manual';
$item['author'] = trim((string)($item['author'] ?? ''));
$item['preview_url'] = trim((string)($item['preview_url'] ?? ''));
return $item;
}, $config['workshop_items']);
@ -735,6 +778,7 @@ class SteamWorkshopService
file_put_contents($this->adapterMapFile, json_encode([], JSON_PRETTY_PRINT));
}
}
public function gameSupportsWorkshop($serverXml): bool
{
if (!($serverXml instanceof SimpleXMLElement)) {
@ -778,6 +822,218 @@ class SteamWorkshopService
return $candidate;
}
private function loadServerConfigXml(string $gameKey): ?SimpleXMLElement
{
$gameKey = $this->sanitizeGameKey($gameKey);
if ($gameKey === '') {
return null;
}
$directPath = sprintf('%s/%s.xml', $this->serverConfigDir, $gameKey);
if (is_file($directPath)) {
$xml = @simplexml_load_file($directPath);
if ($xml !== false) {
return $xml;
}
}
foreach (glob($this->serverConfigDir . '/*.xml') as $file) {
$xml = @simplexml_load_file($file);
if ($xml === false) {
continue;
}
$configuredKey = isset($xml->game_key) ? $this->sanitizeGameKey((string)$xml->game_key) : '';
if ($configuredKey === $gameKey) {
return $xml;
}
}
return null;
}
private function parseSelectedItemsJson(string $json): array
{
if ($json === '') {
return [];
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return [];
}
$result = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
$id = preg_replace('/[^0-9]/', '', (string)($item['id'] ?? ''));
if ($id === '') {
continue;
}
$label = trim((string)($item['label'] ?? ''));
if ($label === '') {
$label = '@' . $id;
}
$result[$id] = [
'id' => $id,
'label' => $label,
'author' => trim((string)($item['author'] ?? '')),
'preview_url' => trim((string)($item['preview_url'] ?? '')),
'enabled' => isset($item['enabled']) ? (bool)$item['enabled'] : true,
'source' => trim((string)($item['source'] ?? 'search')),
];
}
return array_values($result);
}
private function serializeWorkshopItems(array $items): string
{
$lines = [];
foreach ($items as $item) {
$id = preg_replace('/[^0-9]/', '', (string)($item['id'] ?? ''));
if ($id === '') {
continue;
}
$label = trim((string)($item['label'] ?? ''));
if ($label === '') {
$label = '@' . $id;
}
$lines[] = $id . ',' . $label;
}
return implode(PHP_EOL, $lines);
}
private function fetchWorkshopSearchHtml(string $appId, string $query, int $pageSize): ?string
{
$params = http_build_query([
'appid' => $appId,
'searchtext' => $query,
'numperpage' => max(5, $pageSize),
'format' => 'json',
'browsesort' => 'textsearch',
]);
$url = 'https://steamcommunity.com/workshop/browse/?' . $params;
$response = $this->httpRequest($url);
if ($response === null) {
return null;
}
$data = json_decode($response, true);
if (is_array($data) && isset($data['html'])) {
return (string)$data['html'];
}
return $response;
}
private function extractWorkshopItemsFromHtml(string $html, int $limit): array
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html);
libxml_clear_errors();
$xpath = new DOMXPath($doc);
$nodes = $xpath->query('//*[@data-publishedfileid]');
$results = [];
foreach ($nodes as $node) {
if (!($node instanceof DOMElement)) {
continue;
}
$id = $node->getAttribute('data-publishedfileid');
if ($id === '') {
continue;
}
$titleNode = $xpath->query('.//*[contains(@class,"workshopItemTitle")]', $node)->item(0);
$authorNode = $xpath->query('.//*[contains(@class,"workshopItemAuthorName")]', $node)->item(0);
$imgNode = $xpath->query('.//img[contains(@class,"workshopItemPreviewImage") or contains(@class,"workshopItemPreviewImageMain")]', $node)->item(0);
$results[$id] = [
'id' => $id,
'label' => trim($titleNode instanceof DOMNode ? $titleNode->textContent : ('@' . $id)),
'author' => trim($authorNode instanceof DOMNode ? $authorNode->textContent : ''),
'preview_url' => $imgNode instanceof DOMElement ? (string)$imgNode->getAttribute('src') : '',
'enabled' => true,
'source' => 'search',
];
if (count($results) >= $limit) {
break;
}
}
return array_values($results);
}
private function fetchWorkshopItemById(string $id): ?array
{
$postData = http_build_query([
'itemcount' => 1,
'publishedfileids[0]' => $id,
]);
$response = $this->httpRequest('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', $postData);
if ($response === null) {
return null;
}
$data = json_decode($response, true);
$details = $data['response']['publishedfiledetails'][0] ?? null;
if (!is_array($details) || (int)($details['result'] ?? 0) !== 1) {
return null;
}
$title = trim((string)($details['title'] ?? ''));
if ($title === '') {
$title = '@' . $id;
}
return [
'id' => $id,
'label' => $title,
'author' => (string)($details['creator'] ?? ''),
'preview_url' => (string)($details['preview_url'] ?? ''),
'enabled' => true,
'source' => 'search',
];
}
private function httpRequest(string $url, ?string $postFields = null): ?string
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)');
if ($postFields !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
}
$response = curl_exec($ch);
curl_close($ch);
return is_string($response) ? $response : null;
}
$contextOptions = [
'http' => [
'method' => $postFields !== null ? 'POST' : 'GET',
'timeout' => 10,
'header' => "User-Agent: GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)\r\n" . ($postFields !== null ? "Content-Type: application/x-www-form-urlencoded\r\n" : ''),
],
];
if ($postFields !== null) {
$contextOptions['http']['content'] = $postFields;
}
$context = stream_context_create($contextOptions);
$result = @file_get_contents($url, false, $context);
return $result === false ? null : $result;
}
private function buildWorkshopGroupKey(string $appId): string
{
return 'steamapp_' . $appId;

View file

@ -108,12 +108,22 @@
background: #fff;
}
.btn.btn-xs {
padding: 0.2rem 0.45rem;
font-size: 0.85rem;
}
.btn.primary {
background: #0b5ed7;
border-color: #0a58ca;
color: #fff;
}
.btn.secondary {
background: #f7f7f7;
border-color: #c7c7c7;
}
.sw-toggle {
display: flex;
align-items: center;
@ -227,3 +237,171 @@
.sw-admin__mapping-actions {
margin-top: 1rem;
}
.sw-picker {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid #dcdcdc;
border-radius: 8px;
padding: 1rem;
background: #fff;
}
.sw-picker__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sw-picker__header h4 {
margin: 0;
font-size: 1.1rem;
}
.sw-picker__hint {
margin: 0.25rem 0 0;
color: #555;
font-size: 0.9rem;
}
.sw-picker__search {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
}
.sw-picker__search label {
flex: 1;
}
.sw-picker__search-input {
width: 100%;
padding: 0.4rem;
border-radius: 4px;
border: 1px solid #c7c7c7;
}
.sw-picker__status {
min-height: 1.25rem;
font-size: 0.9rem;
}
.sw-picker__status--loading {
color: #0b5ed7;
}
.sw-picker__status--error {
color: #c0392b;
}
.sw-picker__status--info {
color: #555;
}
.sw-picker__status--clear {
color: inherit;
}
.sw-picker__selected,
.sw-picker__results {
border: 1px solid #e3e3e3;
border-radius: 6px;
padding: 0.75rem;
}
.sw-picker__selected-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.sw-picker__selected-header small {
color: #666;
}
.sw-picker__chip-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.sw-picker__chip {
display: flex;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid #d0dae9;
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: #f8fafd;
}
.sw-picker__chip-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sw-picker__chip-text span {
color: #555;
font-size: 0.85rem;
}
.sw-picker__chip-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.sw-picker__chip-remove {
border: 1px solid #c0392b;
background: #fff5f4;
color: #c0392b;
border-radius: 4px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.sw-picker__toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.sw-picker__results-table {
width: 100%;
border-collapse: collapse;
}
.sw-picker__results-table th,
.sw-picker__results-table td {
border: 1px solid #e3e3e3;
padding: 0.4rem;
vertical-align: top;
}
.sw-picker__result-meta {
color: #666;
font-size: 0.85rem;
}
.sw-picker__results-table-wrapper {
overflow-x: auto;
}
.sw-picker__action {
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #0b5ed7;
border: 1px solid #0a58ca;
color: #fff;
}
.sw-picker__empty {
color: #777;
font-size: 0.9rem;
}

View file

@ -0,0 +1,346 @@
(function () {
'use strict';
var Picker = /** @class */ (function () {
function Picker(root) {
this.root = root;
this.endpoint = root.getAttribute('data-endpoint') || '';
this.lang = {
add: root.getAttribute('data-lang-add') || 'Add',
remove: root.getAttribute('data-lang-remove') || 'Remove',
loading: root.getAttribute('data-lang-loading') || 'Loading…',
error: root.getAttribute('data-lang-error') || 'Something went wrong.',
empty: root.getAttribute('data-lang-empty') || 'No results found.',
query: root.getAttribute('data-lang-query') || 'Enter a Workshop ID or keyword.',
sync: root.getAttribute('data-lang-sync') || 'Sync',
};
this.selectedInput = root.querySelector('.js-sw-selected-input');
this.selectedList = root.querySelector('.js-sw-selected-list');
this.resultsBody = root.querySelector('.js-sw-results');
this.statusEl = root.querySelector('.js-sw-picker-status');
this.searchForm = root.querySelector('.js-sw-search-form');
this.searchInput = root.querySelector('.js-sw-search-input');
this.searchButton = root.querySelector('.js-sw-search-button');
this.state = {
selected: this.readInitialSelection(),
};
this.lastResults = [];
this.bindEvents();
this.renderSelected();
}
Picker.prototype.readInitialSelection = function () {
if (!this.selectedInput) {
return [];
}
try {
var parsed = JSON.parse(this.selectedInput.value || '[]');
if (Array.isArray(parsed)) {
return parsed.filter(function (item) { return item && item.id; })
.map(function (item) { return ({
id: String(item.id),
label: String(item.label || ('@' + item.id)),
author: String(item.author || ''),
preview_url: String(item.preview_url || ''),
enabled: !(item.enabled === false || item.enabled === 'false' || item.enabled === 0 || item.enabled === '0'),
source: String(item.source || 'manual'),
}); });
}
}
catch (err) {
console.warn('Invalid Workshop JSON state', err);
}
return [];
};
Picker.prototype.bindEvents = function () {
var _this = this;
if (this.searchForm && this.searchForm.tagName === 'FORM') {
this.searchForm.addEventListener('submit', function (event) {
event.preventDefault();
_this.performSearch();
});
}
if (this.searchButton) {
this.searchButton.addEventListener('click', function (event) {
event.preventDefault();
_this.performSearch();
});
}
if (this.searchInput && (!this.searchForm || this.searchForm.tagName !== 'FORM')) {
this.searchInput.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
_this.performSearch();
}
});
}
if (this.selectedList) {
this.selectedList.addEventListener('click', function (event) {
var target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.matches('.js-sw-remove')) {
var id = target.getAttribute('data-id');
if (id) {
_this.removeSelected(id);
}
}
});
this.selectedList.addEventListener('change', function (event) {
var target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
if (target.matches('.js-sw-toggle')) {
var id = target.getAttribute('data-id');
if (id) {
_this.toggleSelected(id, target.checked);
}
}
});
}
if (this.resultsBody) {
this.resultsBody.addEventListener('click', function (event) {
var target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.matches('.js-sw-add')) {
var payload = target.getAttribute('data-payload');
if (payload) {
try {
var data = JSON.parse(payload);
_this.addSelected(data);
}
catch (err) {
console.warn('Invalid payload', err);
}
}
}
});
}
};
Picker.prototype.performSearch = function () {
var _this = this;
if (!this.endpoint || !this.searchInput) {
return;
}
var term = this.searchInput.value.trim();
if (!term) {
this.setStatus(this.lang.query, 'error');
return;
}
if (this.isSearching) {
return;
}
this.isSearching = true;
this.setStatus(this.lang.loading, 'loading');
var url = this.endpoint + '&q=' + encodeURIComponent(term);
fetch(url, {
headers: { 'Accept': 'application/json' },
})
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function (data) {
if (!data || data.ok === false) {
var message = (data && data.error) ? data.error : _this.lang.error;
_this.setStatus(message, 'error');
_this.renderResults([]);
return;
}
if (Array.isArray(data.results) && data.results.length) {
_this.setStatus('', 'clear');
_this.renderResults(data.results);
}
else {
_this.setStatus(_this.lang.empty, 'info');
_this.renderResults([]);
}
})
.catch(function (error) {
console.error('Workshop search failed', error);
_this.setStatus(_this.lang.error, 'error');
_this.renderResults([]);
})
.finally(function () {
_this.isSearching = false;
});
};
Picker.prototype.setStatus = function (message, kind) {
if (!this.statusEl) {
return;
}
this.statusEl.textContent = message || '';
this.statusEl.className = 'sw-picker__status js-sw-picker-status' + (kind ? ' sw-picker__status--' + kind : '');
};
Picker.prototype.renderResults = function (results) {
if (!this.resultsBody) {
return;
}
this.resultsBody.innerHTML = '';
if (!Array.isArray(results) || !results.length) {
this.lastResults = [];
return;
}
this.lastResults = results.slice();
var _loop_1 = function (item) {
var normalized = {
id: String(item.id),
label: String(item.label || ('@' + item.id)),
author: String(item.author || ''),
preview_url: String(item.preview_url || ''),
enabled: true,
source: String(item.source || 'search'),
};
var row = document.createElement('tr');
var selectCell = document.createElement('td');
var action = document.createElement('button');
action.type = 'button';
action.className = 'btn btn-xs sw-picker__action js-sw-add';
action.textContent = this_1.lang.add;
action.setAttribute('data-payload', JSON.stringify(normalized));
if (this_1.isSelected(normalized.id)) {
action.disabled = true;
}
selectCell.appendChild(action);
var titleCell = document.createElement('td');
titleCell.innerHTML = '<strong>' + this_1.escape(normalized.label) + '</strong><div class="sw-picker__result-meta">#' + this_1.escape(normalized.id) + '</div>';
var authorCell = document.createElement('td');
authorCell.textContent = normalized.author;
row.appendChild(selectCell);
row.appendChild(titleCell);
row.appendChild(authorCell);
this_1.resultsBody.appendChild(row);
};
var this_1 = this;
for (var _i = 0, results_1 = results; _i < results_1.length; _i++) {
var item = results_1[_i];
_loop_1(item);
}
};
Picker.prototype.isSelected = function (id) {
return this.state.selected.some(function (item) { return item.id === id; });
};
Picker.prototype.addSelected = function (item) {
if (!item || !item.id || this.isSelected(String(item.id))) {
return;
}
this.state.selected.push({
id: String(item.id),
label: String(item.label || ('@' + item.id)),
author: String(item.author || ''),
preview_url: String(item.preview_url || ''),
enabled: true,
source: String(item.source || 'search'),
});
this.persist();
this.renderSelected();
this.renderResults(this.lastResults || []);
};
Picker.prototype.removeSelected = function (id) {
var next = this.state.selected.filter(function (item) { return item.id !== id; });
this.state.selected = next;
this.persist();
this.renderSelected();
this.renderResults(this.lastResults || []);
};
Picker.prototype.toggleSelected = function (id, enabled) {
var changed = false;
this.state.selected = this.state.selected.map(function (item) {
if (item.id === id) {
changed = true;
return {
id: item.id,
label: item.label,
author: item.author,
preview_url: item.preview_url,
enabled: enabled,
source: item.source,
};
}
return item;
});
if (changed) {
this.persist();
}
};
Picker.prototype.renderSelected = function () {
if (!this.selectedList) {
return;
}
this.selectedList.innerHTML = '';
if (!this.state.selected.length) {
var emptyText = this.selectedList.getAttribute('data-empty-text') || '';
if (emptyText) {
var empty = document.createElement('div');
empty.className = 'sw-picker__empty';
empty.textContent = emptyText;
this.selectedList.appendChild(empty);
}
return;
}
var this_2 = this;
for (var _i = 0, _a = this.state.selected; _i < _a.length; _i++) {
var item = _a[_i];
var chip = document.createElement('div');
chip.className = 'sw-picker__chip';
chip.innerHTML = '<div class="sw-picker__chip-text"><strong>' + this.escape(item.label) + '</strong><span>#' + this.escape(item.id) + '</span></div>';
var controls = document.createElement('div');
controls.className = 'sw-picker__chip-controls';
var toggle = document.createElement('label');
toggle.className = 'sw-picker__toggle';
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'js-sw-toggle';
checkbox.checked = item.enabled !== false;
checkbox.setAttribute('data-id', item.id);
toggle.appendChild(checkbox);
var toggleText = document.createElement('span');
toggleText.textContent = this_2.lang.sync;
toggle.appendChild(toggleText);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'sw-picker__chip-remove js-sw-remove';
removeBtn.setAttribute('data-id', item.id);
removeBtn.textContent = this_2.lang.remove;
controls.appendChild(toggle);
controls.appendChild(removeBtn);
chip.appendChild(controls);
this.selectedList.appendChild(chip);
}
this.persist();
};
Picker.prototype.persist = function () {
if (!this.selectedInput) {
return;
}
try {
this.selectedInput.value = JSON.stringify(this.state.selected);
}
catch (err) {
console.error('Unable to serialize workshop selection', err);
}
};
Picker.prototype.escape = function (value) {
var div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
};
return Picker;
}());
document.addEventListener('DOMContentLoaded', function () {
var nodes = document.querySelectorAll('.sw-picker');
Array.prototype.forEach.call(nodes, function (node) {
try {
new Picker(node);
}
catch (err) {
console.error('Failed to boot Steam Workshop picker', err);
}
});
});
})();

View file

@ -15,6 +15,7 @@ $homeId = (int)$home['home_id'];
<form method="post" action="?m=steam_workshop&amp;p=main&amp;action=save" class="sw-form">
<input type="hidden" name="home_id" value="<?php echo $homeId; ?>" />
<?php $formConfig = $config; include __DIR__ . '/partials/form_fields.php'; ?>
<?php include __DIR__ . '/partials/mod_picker.php'; ?>
<div class="sw-form__actions">
<button class="btn primary" type="submit"><?php echo htmlspecialchars($lang['button_save']); ?></button>
<a class="btn" href="?m=steam_workshop&amp;p=main"><?php echo htmlspecialchars($lang['button_cancel']); ?></a>

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
/** @var array $adapterOptions */
/** @var array $lang */
/** @var bool $adapterLocked */
/** @var bool $isAdmin */
$enabled = !empty($formConfig['workshop_enabled']);
$interval = (int)$formConfig['update_interval_minutes'];
$stagingDir = htmlspecialchars($formConfig['staging_dir']);
@ -41,19 +42,36 @@ $currentAdapterName = $adapterOptions[$formConfig['adapter_key']] ?? strtoupper(
<small><?php echo htmlspecialchars($lang['label_interval_hint']); ?></small>
</label>
<label>
<span><?php echo htmlspecialchars($lang['label_staging_dir']); ?></span>
<input type="text" name="workshop[staging_dir]" value="<?php echo $stagingDir; ?>" placeholder="/home/ogp_agent/workshop-staging" />
</label>
<?php if ($isAdmin): ?>
<label>
<span><?php echo htmlspecialchars($lang['label_staging_dir']); ?></span>
<input type="text" name="workshop[staging_dir]" value="<?php echo $stagingDir; ?>" placeholder="/home/ogp_agent/workshop-staging" />
</label>
<?php else: ?>
<label>
<span><?php echo htmlspecialchars($lang['label_staging_dir']); ?></span>
<input type="text" value="<?php echo $stagingDir; ?>" disabled />
<small><?php echo htmlspecialchars($lang['hint_admin_only']); ?></small>
</label>
<input type="hidden" name="workshop[staging_dir]" value="<?php echo $stagingDir; ?>" />
<?php endif; ?>
<label>
<span><?php echo htmlspecialchars($lang['label_install_strategy']); ?></span>
<select name="workshop[install_strategy]">
<option value="copy" <?php echo $installStrategy === 'copy' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_copy']); ?></option>
<option value="symlink" <?php echo $installStrategy === 'symlink' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_symlink']); ?></option>
<option value="staging" <?php echo $installStrategy === 'staging' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_staging']); ?></option>
</select>
</label>
<?php if ($isAdmin): ?>
<label>
<span><?php echo htmlspecialchars($lang['label_install_strategy']); ?></span>
<select name="workshop[install_strategy]">
<option value="copy" <?php echo $installStrategy === 'copy' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_copy']); ?></option>
<option value="symlink" <?php echo $installStrategy === 'symlink' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_symlink']); ?></option>
<option value="staging" <?php echo $installStrategy === 'staging' ? 'selected' : ''; ?>><?php echo htmlspecialchars($lang['install_staging']); ?></option>
</select>
</label>
<?php else: ?>
<label>
<span><?php echo htmlspecialchars($lang['label_install_strategy']); ?></span>
<input type="text" value="<?php echo htmlspecialchars($lang['install_' . $installStrategy] ?? $installStrategy); ?>" disabled />
</label>
<input type="hidden" name="workshop[install_strategy]" value="<?php echo htmlspecialchars($installStrategy); ?>" />
<?php endif; ?>
<label>
<span><?php echo htmlspecialchars($lang['label_on_update_action']); ?></span>
@ -63,14 +81,27 @@ $currentAdapterName = $adapterOptions[$formConfig['adapter_key']] ?? strtoupper(
</select>
</label>
<label>
<span><?php echo htmlspecialchars($lang['label_post_install_script']); ?></span>
<input type="text" name="workshop[post_install_script]" value="<?php echo $postInstall; ?>" placeholder="/home/ogp_agent/scripts/workshop-hook.sh" />
</label>
<?php if ($isAdmin): ?>
<label>
<span><?php echo htmlspecialchars($lang['label_post_install_script']); ?></span>
<input type="text" name="workshop[post_install_script]" value="<?php echo $postInstall; ?>" placeholder="/home/ogp_agent/scripts/workshop-hook.sh" />
</label>
<?php else: ?>
<label>
<span><?php echo htmlspecialchars($lang['label_post_install_script']); ?></span>
<input type="text" value="<?php echo $postInstall; ?>" disabled />
<small><?php echo htmlspecialchars($lang['hint_admin_only']); ?></small>
</label>
<input type="hidden" name="workshop[post_install_script]" value="<?php echo $postInstall; ?>" />
<?php endif; ?>
</div>
<label>
<span><?php echo htmlspecialchars($lang['label_mod_import']); ?></span>
<textarea name="workshop[raw_items]" rows="8" placeholder="123456789,@Example Mod&#10;987654321,@QoL Pack"><?php echo $rawDefinition; ?></textarea>
<small><?php echo htmlspecialchars($lang['hint_mod_import']); ?></small>
</label>
<?php if ($isAdmin): ?>
<label>
<span><?php echo htmlspecialchars($lang['label_mod_import']); ?></span>
<textarea name="workshop[raw_items]" rows="8" placeholder="123456789,@Example Mod&#10;987654321,@QoL Pack"><?php echo $rawDefinition; ?></textarea>
<small><?php echo htmlspecialchars($lang['hint_mod_import']); ?></small>
</label>
<?php else: ?>
<input type="hidden" name="workshop[raw_items]" value="<?php echo $rawDefinition; ?>" />
<?php endif; ?>

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/** @var array $lang */
/** @var array $config */
/** @var array $home */
/** @var int $homeId */
$homeId = (int)($home['home_id'] ?? 0);
$endpoint = sprintf('?m=steam_workshop&p=main&action=search&home_id=%d', $homeId);
$initialItems = [];
foreach ($config['workshop_items'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$id = preg_replace('/[^0-9]/', '', (string)($item['id'] ?? ''));
if ($id === '') {
continue;
}
$initialItems[] = [
'id' => $id,
'label' => (string)($item['label'] ?? ('@' . $id)),
'author' => (string)($item['author'] ?? ''),
'preview_url' => (string)($item['preview_url'] ?? ''),
'enabled' => !empty($item['enabled']),
'source' => (string)($item['source'] ?? 'manual'),
];
}
$initialJson = htmlspecialchars(json_encode($initialItems, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');
$pickerId = 'sw-picker-' . $homeId;
$langAttrs = [
'add' => $lang['mod_picker_action_add'] ?? 'Add',
'remove' => $lang['mod_picker_action_remove'] ?? 'Remove',
'loading' => $lang['mod_picker_status_loading'] ?? 'Searching Steam Workshop…',
'error' => $lang['mod_picker_status_error'] ?? 'Unable to load workshop data.',
'empty' => $lang['mod_picker_results_empty'] ?? 'No results matched your search.',
'query' => $lang['mod_picker_status_need_query'] ?? 'Enter a Workshop ID or keyword.',
'sync' => $lang['mod_picker_toggle_label'] ?? 'Sync',
];
?>
<div class="sw-picker" id="<?php echo $pickerId; ?>" data-endpoint="<?php echo htmlspecialchars($endpoint, ENT_QUOTES, 'UTF-8'); ?>"
<?php foreach ($langAttrs as $key => $value): ?>data-lang-<?php echo $key; ?>="<?php echo htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); ?>" <?php endforeach; ?>>
<div class="sw-picker__header">
<h4><?php echo htmlspecialchars($lang['mod_picker_heading'] ?? 'Workshop library'); ?></h4>
<p class="sw-picker__hint"><?php echo htmlspecialchars($lang['mod_picker_hint'] ?? 'Search by Workshop ID or keyword to add mods.'); ?></p>
</div>
<div class="sw-picker__search js-sw-search-form" data-home-id="<?php echo $homeId; ?>" role="search">
<label>
<span><?php echo htmlspecialchars($lang['mod_picker_search_label'] ?? 'Search Steam Workshop'); ?></span>
<input type="text" class="sw-picker__search-input js-sw-search-input" placeholder="<?php echo htmlspecialchars($lang['mod_picker_search_placeholder'] ?? 'Example: 221100 or QoL'); ?>" />
</label>
<button type="button" class="btn secondary js-sw-search-button"><?php echo htmlspecialchars($lang['mod_picker_search_button'] ?? 'Search'); ?></button>
</div>
<div class="sw-picker__status js-sw-picker-status" role="status" aria-live="polite"></div>
<div class="sw-picker__selected">
<div class="sw-picker__selected-header">
<h5><?php echo htmlspecialchars($lang['mod_picker_selected_heading'] ?? 'Selected mods'); ?></h5>
<small><?php echo htmlspecialchars($lang['mod_picker_selected_hint'] ?? 'Checked mods will stay synced automatically.'); ?></small>
</div>
<div class="sw-picker__chip-list js-sw-selected-list" data-empty-text="<?php echo htmlspecialchars($lang['mod_picker_selected_empty'] ?? 'No mods selected yet.'); ?>"></div>
</div>
<div class="sw-picker__results">
<h5><?php echo htmlspecialchars($lang['mod_picker_results_heading'] ?? 'Search results'); ?></h5>
<div class="sw-picker__results-table-wrapper">
<table class="sw-picker__results-table">
<thead>
<tr>
<th><?php echo htmlspecialchars($lang['mod_picker_results_select'] ?? 'Select'); ?></th>
<th><?php echo htmlspecialchars($lang['mod_picker_results_title'] ?? 'Title'); ?></th>
<th><?php echo htmlspecialchars($lang['mod_picker_results_author'] ?? 'Author'); ?></th>
</tr>
</thead>
<tbody class="js-sw-results"></tbody>
</table>
</div>
</div>
<input type="hidden" name="workshop[selected_items]" value="<?php echo $initialJson; ?>" class="js-sw-selected-input" />
</div>