Update .. workshop search
This commit is contained in:
parent
4baa43bcbf
commit
faf0de39a7
10 changed files with 982 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'] : '';
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
346
modules/steam_workshop/steam_workshop.js
Normal file
346
modules/steam_workshop/steam_workshop.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -15,6 +15,7 @@ $homeId = (int)$home['home_id'];
|
|||
<form method="post" action="?m=steam_workshop&p=main&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&p=main"><?php echo htmlspecialchars($lang['button_cancel']); ?></a>
|
||||
|
|
|
|||
|
|
@ -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 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 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; ?>
|
||||
|
|
|
|||
81
modules/steam_workshop/views/partials/mod_picker.php
Normal file
81
modules/steam_workshop/views/partials/mod_picker.php
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue