Add PrismaUI HTML server browser & integration
Integrate a PrismaUI_F4-based HTML server browser and related tooling. Adds a ThirdParty submodule (framework-F4-Conversion), UI views (CommonwealthOnline browser HTML/JS/CSS), and multiple plugin headers/sources to bridge PrismaUI/Scaleform, handle menu input, and manage server browser data and actions (mock server list, join/connect flow). Key changes: - New build/deploy scripts: build-prismaui.bat, build-all.bat, deploy-all.bat, deploy-ui.bat and a PowerShell helper for linking the Ultralight SDK (ULTRALIGHT_SDK_PATH, default F:\\UltralightSDK). - Plugin additions: F4TPrismaUI, F4TScaleformUI, F4TServerBrowserBridge, F4TServerBrowserData, F4TMenuInput, F4TDebugOverlay and implementations to create/show/hide the HTML view, forward controller input, register Scaleform functions (openManager/closeManager), and push UI events. - Networking: add ConnectToServer(host,port) and keep ConnectToLocalServer() as wrapper; update connection logs and error handling. - Main menu SWF/AS updates: updated MainMenu.swf and MainMenu.as to call closeManager() when leaving multiplayer and to integrate open/close hooks. - Documentation: plugin/setup.md updated with PrismaUI build/deploy instructions. - Removed Main_ModManager.swf (deleted). Primary intent: provide an in-game HTML server browser (toggleable with F9 or Scaleform calls), wire up controller input, and add build/deploy workflow for PrismaUI/Ultralight so developers can build and deploy the UI and plugin together. Mock server data is used for now; network join/connect plumbing is provided for local/dev servers.
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.getElementById("coRoot");
|
||||
var panel = document.getElementById("coPanel");
|
||||
var joinOverlay = document.getElementById("coJoinOverlay");
|
||||
var joinMessage = document.getElementById("coJoinMessage");
|
||||
|
||||
var FOCUS_ZONES = ["tabs", "filters", "server-list", "details"];
|
||||
var REPEAT_INITIAL_MS = 400;
|
||||
var REPEAT_RATE_MS = 80;
|
||||
|
||||
var repeatTimer = null;
|
||||
var repeatAction = null;
|
||||
|
||||
var state = {
|
||||
activeTab: "server-browser",
|
||||
focusZone: "server-list",
|
||||
selectedTabIndex: 1,
|
||||
selectedServerIndex: 0,
|
||||
selectedFilterIndex: 0,
|
||||
filters: {
|
||||
search: "",
|
||||
region: "Any Region",
|
||||
mode: "Any Mode",
|
||||
worldspace: "Any Worldspace",
|
||||
ping: "Any",
|
||||
showFull: false,
|
||||
hasPassword: false,
|
||||
modded: false
|
||||
},
|
||||
allServers: [],
|
||||
servers: [],
|
||||
favorites: [],
|
||||
recent: [],
|
||||
connectionStatus: "Online",
|
||||
isRefreshing: false,
|
||||
directConnect: { address: "127.0.0.1", port: "7777" },
|
||||
joinOverlay: null
|
||||
};
|
||||
|
||||
function sendAction(action, payload) {
|
||||
var msg = JSON.stringify(Object.assign({ action: action }, payload || {}));
|
||||
if (typeof window.coAction === "function") {
|
||||
window.coAction(msg);
|
||||
return;
|
||||
}
|
||||
console.warn("[CO] coAction bridge unavailable:", action);
|
||||
handleActionLocal(action, payload || {});
|
||||
}
|
||||
|
||||
function handleActionLocal(action, payload) {
|
||||
switch (action) {
|
||||
case "refreshServerList":
|
||||
refreshLocalMock();
|
||||
break;
|
||||
case "setFilter":
|
||||
state.filters[payload.filterName] = payload.value;
|
||||
applyLocalFilters();
|
||||
render();
|
||||
break;
|
||||
case "resetFilters":
|
||||
resetLocalFilters();
|
||||
break;
|
||||
case "toggleFavorite":
|
||||
toggleFavoriteLocal(payload.serverId);
|
||||
break;
|
||||
case "joinServer":
|
||||
joinServerLocal(payload.serverId);
|
||||
break;
|
||||
case "openDirectConnect":
|
||||
joinDirectLocal(payload.address, payload.port);
|
||||
break;
|
||||
case "switchTab":
|
||||
switchTab(payload.tabName);
|
||||
break;
|
||||
case "closeBrowser":
|
||||
if (typeof window.closeBrowser === "function") window.closeBrowser();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLocalMock() {
|
||||
state.isRefreshing = true;
|
||||
render();
|
||||
setTimeout(function () {
|
||||
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
|
||||
applyLocalFilters();
|
||||
state.isRefreshing = false;
|
||||
render();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function resetLocalFilters() {
|
||||
state.filters = {
|
||||
search: "",
|
||||
region: "Any Region",
|
||||
mode: "Any Mode",
|
||||
worldspace: "Any Worldspace",
|
||||
ping: "Any",
|
||||
showFull: false,
|
||||
hasPassword: false,
|
||||
modded: false
|
||||
};
|
||||
sendAction("resetFilters", {});
|
||||
applyLocalFilters();
|
||||
render();
|
||||
}
|
||||
|
||||
function applyLocalFilters() {
|
||||
var list = state.allServers.slice();
|
||||
var f = state.filters;
|
||||
|
||||
if (f.search) {
|
||||
var q = f.search.toLowerCase();
|
||||
list = list.filter(function (s) {
|
||||
return s.name.toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
}
|
||||
if (f.region !== "Any Region") {
|
||||
list = list.filter(function (s) { return s.region === f.region; });
|
||||
}
|
||||
if (f.mode !== "Any Mode") {
|
||||
list = list.filter(function (s) { return s.mode === f.mode; });
|
||||
}
|
||||
if (f.worldspace !== "Any Worldspace") {
|
||||
list = list.filter(function (s) { return s.worldspace === f.worldspace; });
|
||||
}
|
||||
if (f.ping === "< 50 ms") list = list.filter(function (s) { return s.ping < 50; });
|
||||
else if (f.ping === "< 100 ms") list = list.filter(function (s) { return s.ping < 100; });
|
||||
else if (f.ping === "< 150 ms") list = list.filter(function (s) { return s.ping < 150; });
|
||||
if (!f.showFull) list = list.filter(function (s) { return s.status !== "Full"; });
|
||||
if (f.hasPassword) list = list.filter(function (s) { return s.passworded; });
|
||||
if (f.modded) list = list.filter(function (s) { return s.modded; });
|
||||
|
||||
state.servers = list;
|
||||
state.favorites = state.allServers.filter(function (s) { return s.favorite; });
|
||||
if (state.selectedServerIndex >= state.servers.length) {
|
||||
state.selectedServerIndex = Math.max(0, state.servers.length - 1);
|
||||
}
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
var el = document.getElementById("coServerCount");
|
||||
if (el) {
|
||||
el.textContent = " \u2014 " + state.servers.length + " servers found";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFavoriteLocal(serverId) {
|
||||
var server = findServerById(serverId);
|
||||
if (!server) return;
|
||||
server.favorite = !server.favorite;
|
||||
sendAction("toggleFavorite", { serverId: serverId });
|
||||
applyLocalFilters();
|
||||
render();
|
||||
}
|
||||
|
||||
function findServerById(id) {
|
||||
for (var i = 0; i < state.allServers.length; i++) {
|
||||
if (state.allServers[i].id === id) return state.allServers[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSelectedServer() {
|
||||
return getActiveList()[state.selectedServerIndex] || null;
|
||||
}
|
||||
|
||||
function getActiveList() {
|
||||
if (state.activeTab === "favorites") return state.favorites;
|
||||
if (state.activeTab === "recent") return state.recent;
|
||||
return state.servers;
|
||||
}
|
||||
|
||||
function joinServerLocal(serverId) {
|
||||
var server = findServerById(serverId);
|
||||
if (!server) return;
|
||||
showJoinOverlay("Connecting to " + server.name + "...");
|
||||
sendAction("joinServer", { serverId: serverId });
|
||||
}
|
||||
|
||||
function joinDirectLocal(address, port) {
|
||||
showJoinOverlay("Connecting to " + address + ":" + port + "...");
|
||||
sendAction("openDirectConnect", { address: address, port: port });
|
||||
}
|
||||
|
||||
function showJoinOverlay(message, isError) {
|
||||
state.joinOverlay = { message: message, error: !!isError };
|
||||
joinOverlay.classList.remove("hidden");
|
||||
if (isError) joinOverlay.classList.add("co-error");
|
||||
else joinOverlay.classList.remove("co-error");
|
||||
joinMessage.textContent = message;
|
||||
var spinner = joinOverlay.querySelector(".co-spinner");
|
||||
if (spinner) spinner.style.display = isError ? "none" : "block";
|
||||
render();
|
||||
}
|
||||
|
||||
function hideJoinOverlay() {
|
||||
state.joinOverlay = null;
|
||||
joinOverlay.classList.add("hidden");
|
||||
render();
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
state.activeTab = tabName;
|
||||
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
|
||||
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
|
||||
if (tabName === "server-browser") state.focusZone = "server-list";
|
||||
else if (tabName === "direct-connect") state.focusZone = "tabs";
|
||||
sendAction("switchTab", { tabName: tabName });
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!root) return;
|
||||
|
||||
panel.classList.toggle("co-loading", state.isRefreshing);
|
||||
|
||||
CO_Tabs.render(root, state);
|
||||
CO_ControllerPrompts.render(root, state);
|
||||
|
||||
document.getElementById("coConnectionStatus").textContent = state.connectionStatus;
|
||||
|
||||
document.querySelectorAll(".co-tab-pane").forEach(function (pane) {
|
||||
pane.classList.toggle("active", pane.getAttribute("data-pane") === state.activeTab);
|
||||
});
|
||||
|
||||
if (state.activeTab === "direct-connect") {
|
||||
CO_DirectConnect.render(root, state);
|
||||
} else {
|
||||
var listServers = state.servers;
|
||||
if (state.activeTab === "favorites") listServers = state.favorites;
|
||||
if (state.activeTab === "recent") listServers = state.recent;
|
||||
|
||||
var listState = Object.assign({}, state, { servers: listServers });
|
||||
if (state.activeTab === "favorites" || state.activeTab === "recent") {
|
||||
listState.focusZone = state.focusZone === "details" || state.focusZone === "filters" ? "server-list" : state.focusZone;
|
||||
}
|
||||
|
||||
if (state.activeTab === "server-browser") {
|
||||
CO_Filters.render(root, listState);
|
||||
CO_ServerDetails.render(root, listState);
|
||||
}
|
||||
|
||||
var activePane = root.querySelector('.co-tab-pane[data-pane="' + state.activeTab + '"]');
|
||||
if (activePane) {
|
||||
var listEl = activePane.querySelector(".co-server-list");
|
||||
if (listEl) {
|
||||
var tempRoot = { querySelector: function (sel) {
|
||||
if (sel === ".co-server-list-wrap") return activePane.querySelector(".co-server-list-wrap");
|
||||
if (sel === ".co-server-list") return listEl;
|
||||
return root.querySelector(sel);
|
||||
}};
|
||||
CO_ServerList.render(tempRoot, listState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
CO_Tabs.bind(root, function (tabName) {
|
||||
switchTab(tabName);
|
||||
});
|
||||
|
||||
CO_Filters.bind(root, function (name, value) {
|
||||
state.filters[name] = value;
|
||||
sendAction("setFilter", { filterName: name, value: value });
|
||||
applyLocalFilters();
|
||||
render();
|
||||
}, resetLocalFilters);
|
||||
|
||||
root.addEventListener("click", function (e) {
|
||||
var row = e.target.closest(".co-tab-pane.active .co-server-row");
|
||||
if (!row) return;
|
||||
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
|
||||
render();
|
||||
});
|
||||
|
||||
root.addEventListener("dblclick", function (e) {
|
||||
var row = e.target.closest(".co-tab-pane.active .co-server-row");
|
||||
if (!row) return;
|
||||
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
|
||||
joinSelected();
|
||||
});
|
||||
|
||||
CO_DirectConnect.bind(root, function (address, port) {
|
||||
state.directConnect = { address: address, port: port };
|
||||
joinDirectLocal(address, port);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
|
||||
hideJoinOverlay();
|
||||
return;
|
||||
}
|
||||
if (state.activeTab === "direct-connect") return;
|
||||
handleControllerButton(mapKeyToButton(e.key), "down");
|
||||
}
|
||||
|
||||
function mapKeyToButton(key) {
|
||||
var map = {
|
||||
ArrowUp: "dpad_up", ArrowDown: "dpad_down", ArrowLeft: "dpad_left", ArrowRight: "dpad_right",
|
||||
Enter: "a", Escape: "b", " ": "a", r: "x", f: "y", q: "lb", e: "rb"
|
||||
};
|
||||
return map[key] || null;
|
||||
}
|
||||
|
||||
function handleControllerButton(button, phase) {
|
||||
if (!button || phase !== "down") return;
|
||||
if (state.joinOverlay) {
|
||||
if (button === "b") hideJoinOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (button === "lb" || button === "rb") {
|
||||
var idx = state.selectedTabIndex;
|
||||
idx = button === "lb" ? idx - 1 : idx + 1;
|
||||
if (idx < 0) idx = CO_Tabs.TAB_ORDER.length - 1;
|
||||
if (idx >= CO_Tabs.TAB_ORDER.length) idx = 0;
|
||||
switchTab(CO_Tabs.TAB_ORDER[idx]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (button === "b") {
|
||||
sendAction("closeBrowser", {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeTab === "direct-connect") {
|
||||
if (button === "a") {
|
||||
var vals = CO_DirectConnect.getValues(root);
|
||||
joinDirectLocal(vals.address, vals.port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (button === "a") {
|
||||
joinSelected();
|
||||
return;
|
||||
}
|
||||
if (button === "x") {
|
||||
sendAction("refreshServerList", {});
|
||||
return;
|
||||
}
|
||||
if (button === "y") {
|
||||
var sel = getSelectedServer();
|
||||
if (sel) toggleFavoriteLocal(sel.id);
|
||||
return;
|
||||
}
|
||||
|
||||
navigateFocus(button);
|
||||
}
|
||||
|
||||
function joinSelected() {
|
||||
var sel = getSelectedServer();
|
||||
if (!sel) return;
|
||||
joinServerLocal(sel.id);
|
||||
}
|
||||
|
||||
function navigateFocus(button) {
|
||||
var zoneIdx = FOCUS_ZONES.indexOf(state.focusZone);
|
||||
var list = getActiveList();
|
||||
|
||||
if (button === "dpad_left") {
|
||||
if (zoneIdx > 0) {
|
||||
state.focusZone = FOCUS_ZONES[zoneIdx - 1];
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (button === "dpad_right") {
|
||||
if (zoneIdx < FOCUS_ZONES.length - 1 && state.activeTab === "server-browser") {
|
||||
state.focusZone = FOCUS_ZONES[zoneIdx + 1];
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.focusZone === "tabs") {
|
||||
if (button === "dpad_up" || button === "dpad_down") {
|
||||
var dir = button === "dpad_up" ? -1 : 1;
|
||||
state.selectedTabIndex = (state.selectedTabIndex + dir + CO_Tabs.TAB_ORDER.length) % CO_Tabs.TAB_ORDER.length;
|
||||
switchTab(CO_Tabs.TAB_ORDER[state.selectedTabIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.focusZone === "filters") {
|
||||
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
||||
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
|
||||
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
|
||||
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.focusZone === "server-list") {
|
||||
if (button === "dpad_up") state.selectedServerIndex = Math.max(0, state.selectedServerIndex - 1);
|
||||
if (button === "dpad_down") state.selectedServerIndex = Math.min(list.length - 1, state.selectedServerIndex + 1);
|
||||
render();
|
||||
CO_ServerList.scrollToSelected(root, state.selectedServerIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function startRepeat(action) {
|
||||
stopRepeat();
|
||||
repeatAction = action;
|
||||
repeatTimer = setTimeout(function tick() {
|
||||
if (repeatAction) repeatAction();
|
||||
repeatTimer = setTimeout(tick, REPEAT_RATE_MS);
|
||||
}, REPEAT_INITIAL_MS);
|
||||
}
|
||||
|
||||
function stopRepeat() {
|
||||
if (repeatTimer) clearTimeout(repeatTimer);
|
||||
repeatTimer = null;
|
||||
repeatAction = null;
|
||||
}
|
||||
|
||||
window.dispatchCoEvent = function (event) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "serverListUpdated":
|
||||
state.allServers = event.servers || [];
|
||||
applyLocalFilters();
|
||||
break;
|
||||
case "serverListRefreshStarted":
|
||||
state.isRefreshing = true;
|
||||
break;
|
||||
case "serverListRefreshFinished":
|
||||
state.isRefreshing = false;
|
||||
if (!event.success && event.error) {
|
||||
console.warn("[CO] Refresh failed:", event.error);
|
||||
}
|
||||
break;
|
||||
case "joinStarted":
|
||||
showJoinOverlay("Connecting...");
|
||||
break;
|
||||
case "joinFailed":
|
||||
showJoinOverlay(event.reason || "Connection failed", true);
|
||||
break;
|
||||
case "joinSucceeded":
|
||||
hideJoinOverlay();
|
||||
addRecent(event.serverId);
|
||||
break;
|
||||
case "connectionStatusChanged":
|
||||
state.connectionStatus = event.status || "Online";
|
||||
break;
|
||||
case "menuOpened":
|
||||
break;
|
||||
case "menuClosed":
|
||||
hideJoinOverlay();
|
||||
break;
|
||||
case "controllerInput":
|
||||
if (event.phase === "down") {
|
||||
handleControllerButton(event.button, "down");
|
||||
if (event.button === "dpad_up" || event.button === "dpad_down") {
|
||||
startRepeat(function () { handleControllerButton(event.button, "down"); });
|
||||
}
|
||||
} else if (event.phase === "up") {
|
||||
stopRepeat();
|
||||
}
|
||||
break;
|
||||
case "uiSound":
|
||||
// TODO: Wire to Fallout 4 menu sound via C++ when audio hooks are available.
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
function addRecent(serverId) {
|
||||
var server = findServerById(serverId);
|
||||
if (!server) return;
|
||||
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
|
||||
state.recent.unshift(server);
|
||||
if (state.recent.length > 8) state.recent.pop();
|
||||
}
|
||||
|
||||
window.setConnectionStatus = function (connected, logPrefix) {
|
||||
state.connectionStatus = connected ? "Connected" : "Online";
|
||||
render();
|
||||
};
|
||||
|
||||
window.onBrowserShown = function () {
|
||||
dispatchCoEvent({ type: "menuOpened" });
|
||||
render();
|
||||
};
|
||||
|
||||
window.coAction = window.coAction || function () {
|
||||
console.warn("[CO] coAction bridge not ready");
|
||||
};
|
||||
|
||||
window.CO_App = {
|
||||
joinSelected: joinSelected,
|
||||
getState: function () { return state; }
|
||||
};
|
||||
|
||||
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
|
||||
applyLocalFilters();
|
||||
bindUi();
|
||||
render();
|
||||
console.log("[CO] Server browser loaded");
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
window.CO_ControllerPrompts = {
|
||||
render: function (root, state) {
|
||||
var el = root.querySelector(".co-prompts");
|
||||
if (!el) return;
|
||||
|
||||
var prompts = CO_ControllerPrompts.getPrompts(state);
|
||||
el.innerHTML = prompts.map(function (p) {
|
||||
return (
|
||||
'<div class="co-prompt">' +
|
||||
'<span class="co-prompt-key">' + p.key + "</span>" +
|
||||
"<span>" + p.label + "</span>" +
|
||||
"</div>"
|
||||
);
|
||||
}).join("");
|
||||
},
|
||||
|
||||
getPrompts: function (state) {
|
||||
if (state.joinOverlay) {
|
||||
if (state.joinOverlay.error) {
|
||||
return [{ key: "B", label: "Dismiss" }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (state.activeTab === "direct-connect") {
|
||||
return [
|
||||
{ key: "A", label: "Connect" },
|
||||
{ key: "B", label: "Back" }
|
||||
];
|
||||
}
|
||||
|
||||
var base = [
|
||||
{ key: "A", label: "Join" },
|
||||
{ key: "X", label: "Refresh" },
|
||||
{ key: "Y", label: "Favorite" },
|
||||
{ key: "B", label: "Back" }
|
||||
];
|
||||
|
||||
if (state.focusZone === "tabs") {
|
||||
return [
|
||||
{ key: "LB", label: "Prev Tab" },
|
||||
{ key: "RB", label: "Next Tab" },
|
||||
{ key: "B", label: "Back" }
|
||||
];
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
window.CO_DirectConnect = {
|
||||
render: function (root, state) {
|
||||
var pane = root.querySelector('[data-pane="direct-connect"]');
|
||||
if (!pane) return;
|
||||
|
||||
var address = state.directConnect.address || "127.0.0.1";
|
||||
var port = state.directConnect.port || "7777";
|
||||
|
||||
pane.innerHTML =
|
||||
'<div class="co-direct-connect">' +
|
||||
"<h2>Direct Connect</h2>" +
|
||||
'<div class="co-direct-row">' +
|
||||
'<label for="co-dc-address">Server Address</label>' +
|
||||
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
|
||||
"</div>" +
|
||||
'<div class="co-direct-row">' +
|
||||
'<label for="co-dc-port">Port</label>' +
|
||||
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
|
||||
"</div>" +
|
||||
'<button type="button" class="co-btn co-dc-connect">Connect</button>' +
|
||||
'<p class="co-detail-desc" style="margin-top:16px">Enter a server address to connect directly without browsing the list.</p>' +
|
||||
"</div>";
|
||||
},
|
||||
|
||||
bind: function (root, onConnect) {
|
||||
var pane = root.querySelector('[data-pane="direct-connect"]');
|
||||
if (!pane) return;
|
||||
|
||||
pane.addEventListener("click", function (e) {
|
||||
if (!e.target.closest(".co-dc-connect")) return;
|
||||
var address = pane.querySelector("#co-dc-address");
|
||||
var port = pane.querySelector("#co-dc-port");
|
||||
onConnect(address ? address.value : "", port ? port.value : "");
|
||||
});
|
||||
},
|
||||
|
||||
getValues: function (root) {
|
||||
var pane = root.querySelector('[data-pane="direct-connect"]');
|
||||
if (!pane) return { address: "127.0.0.1", port: "7777" };
|
||||
var address = pane.querySelector("#co-dc-address");
|
||||
var port = pane.querySelector("#co-dc-port");
|
||||
return {
|
||||
address: address ? address.value : "127.0.0.1",
|
||||
port: port ? port.value : "7777"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,98 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var FILTER_ROWS = [
|
||||
{ key: "search", type: "text", label: "Search", placeholder: "Search servers..." },
|
||||
{ key: "region", type: "select", label: "Region", options: ["Any Region", "Oceania", "North America", "Europe", "Local"] },
|
||||
{ key: "mode", type: "select", label: "Mode", options: ["Any Mode", "Co-op", "Roleplay", "Survival"] },
|
||||
{ key: "worldspace", type: "select", label: "Worldspace", options: ["Any Worldspace", "Commonwealth", "Far Harbor", "Nuka-World"] },
|
||||
{ key: "ping", type: "select", label: "Ping", options: ["Any", "< 50 ms", "< 100 ms", "< 150 ms"] },
|
||||
{ key: "showFull", type: "checkbox", label: "Show Full" },
|
||||
{ key: "hasPassword", type: "checkbox", label: "Has Password" },
|
||||
{ key: "modded", type: "checkbox", label: "Modded" }
|
||||
];
|
||||
|
||||
window.CO_Filters = {
|
||||
FILTER_ROWS: FILTER_ROWS,
|
||||
|
||||
render: function (root, state) {
|
||||
var el = root.querySelector(".co-filters");
|
||||
if (!el) return;
|
||||
|
||||
var focusedClass = state.focusZone === "filters" ? " focused" : "";
|
||||
var html = FILTER_ROWS.map(function (row, index) {
|
||||
var rowFocused = state.focusZone === "filters" && state.selectedFilterIndex === index ? " focused" : "";
|
||||
if (row.type === "text") {
|
||||
return (
|
||||
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
|
||||
'<div class="co-filter-label">' + row.label + '</div>' +
|
||||
'<input class="co-filter-input" type="text" data-filter="' + row.key + '" placeholder="' + row.placeholder + '" value="' + escapeAttr(state.filters[row.key] || "") + '">' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
if (row.type === "select") {
|
||||
var opts = row.options.map(function (opt) {
|
||||
var sel = (state.filters[row.key] || row.options[0]) === opt ? " selected" : "";
|
||||
return '<option value="' + escapeAttr(opt) + '"' + sel + ">" + opt + "</option>";
|
||||
}).join("");
|
||||
return (
|
||||
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
|
||||
'<div class="co-filter-label">' + row.label + '</div>' +
|
||||
'<select class="co-filter-select" data-filter="' + row.key + '">' + opts + "</select>" +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
var checked = state.filters[row.key] ? " checked" : "";
|
||||
return (
|
||||
'<label class="co-checkbox-row co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
|
||||
'<span class="co-checkbox' + checked + '" data-filter="' + row.key + '"></span>' +
|
||||
row.label +
|
||||
"</label>"
|
||||
);
|
||||
}).join("");
|
||||
|
||||
html += '<button type="button" class="co-btn co-reset-btn">Reset Filters</button>';
|
||||
el.className = "co-filters" + focusedClass;
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
bind: function (root, onFilterChange, onReset) {
|
||||
var el = root.querySelector(".co-filters");
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener("change", function (e) {
|
||||
var target = e.target;
|
||||
if (!target.dataset.filter) return;
|
||||
onFilterChange(target.dataset.filter, target.type === "checkbox" ? target.checked : target.value);
|
||||
});
|
||||
|
||||
el.addEventListener("input", function (e) {
|
||||
var target = e.target;
|
||||
if (target.classList.contains("co-filter-input")) {
|
||||
onFilterChange(target.dataset.filter, target.value);
|
||||
}
|
||||
});
|
||||
|
||||
el.addEventListener("click", function (e) {
|
||||
if (e.target.closest(".co-reset-btn")) {
|
||||
onReset();
|
||||
return;
|
||||
}
|
||||
var row = e.target.closest(".co-checkbox-row");
|
||||
if (row) {
|
||||
var box = row.querySelector(".co-checkbox");
|
||||
if (box) {
|
||||
onFilterChange(box.dataset.filter, !box.classList.contains("checked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,100 @@
|
||||
window.CO_MOCK_SERVERS = [
|
||||
{
|
||||
id: "nz-1",
|
||||
name: "Commonwealth NZ #1",
|
||||
region: "Oceania",
|
||||
players: 6,
|
||||
maxPlayers: 12,
|
||||
ping: 32,
|
||||
worldspace: "Commonwealth",
|
||||
mode: "Co-op",
|
||||
status: "Open",
|
||||
modded: true,
|
||||
passworded: false,
|
||||
favorite: true,
|
||||
description: "Relaxed co-op server for explorers and builders. No PvP. Help each other rebuild the Commonwealth.",
|
||||
rules: ["No griefing or stealing", "Respect build sites", "Mic optional"]
|
||||
},
|
||||
{
|
||||
id: "vault111",
|
||||
name: "Vault 111 Co-op",
|
||||
region: "North America",
|
||||
players: 3,
|
||||
maxPlayers: 8,
|
||||
ping: 48,
|
||||
worldspace: "Commonwealth",
|
||||
mode: "Co-op",
|
||||
status: "Open",
|
||||
modded: false,
|
||||
passworded: false,
|
||||
favorite: false,
|
||||
description: "Story-friendly co-op. Stay in sync with main quest progression.",
|
||||
rules: ["No skipping major quest beats without group consent"]
|
||||
},
|
||||
{
|
||||
id: "dc-rp",
|
||||
name: "DC Wasteland RP",
|
||||
region: "North America",
|
||||
players: 10,
|
||||
maxPlayers: 10,
|
||||
ping: 72,
|
||||
worldspace: "Commonwealth",
|
||||
mode: "Roleplay",
|
||||
status: "Full",
|
||||
modded: true,
|
||||
passworded: true,
|
||||
favorite: false,
|
||||
description: "Light roleplay server set around Diamond City trade routes.",
|
||||
rules: ["Stay in character in voice", "No combat logging"]
|
||||
},
|
||||
{
|
||||
id: "far-harbor",
|
||||
name: "Far Harbor Expedition",
|
||||
region: "Europe",
|
||||
players: 4,
|
||||
maxPlayers: 6,
|
||||
ping: 118,
|
||||
worldspace: "Far Harbor",
|
||||
mode: "Co-op",
|
||||
status: "Open",
|
||||
modded: true,
|
||||
passworded: false,
|
||||
favorite: true,
|
||||
description: "DLC-focused co-op run through Far Harbor content.",
|
||||
rules: ["DLC content required", "Spoiler tags in chat for newcomers"]
|
||||
},
|
||||
{
|
||||
id: "local-relay",
|
||||
name: "Local Relay (Dev)",
|
||||
region: "Local",
|
||||
players: 0,
|
||||
maxPlayers: 16,
|
||||
ping: 1,
|
||||
worldspace: "Commonwealth",
|
||||
mode: "Co-op",
|
||||
status: "Open",
|
||||
modded: false,
|
||||
passworded: false,
|
||||
favorite: false,
|
||||
host: "127.0.0.1",
|
||||
port: 7777,
|
||||
description: "Python relay used by the Commonwealth Online prototype. Start server/server.py before joining.",
|
||||
rules: ["Development server only"]
|
||||
},
|
||||
{
|
||||
id: "survival-hard",
|
||||
name: "Survival Hardcore",
|
||||
region: "Europe",
|
||||
players: 2,
|
||||
maxPlayers: 4,
|
||||
ping: 95,
|
||||
worldspace: "Commonwealth",
|
||||
mode: "Survival",
|
||||
status: "Open",
|
||||
modded: true,
|
||||
passworded: false,
|
||||
favorite: false,
|
||||
description: "Hardcore survival with limited respawns and shared resources.",
|
||||
rules: ["Permadeath on disconnect during combat", "Shared stash rules apply"]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
window.CO_ServerDetails = {
|
||||
render: function (root, state) {
|
||||
var el = root.querySelector(".co-details");
|
||||
if (!el) return;
|
||||
|
||||
el.className = "co-details" + (state.focusZone === "details" ? " focused" : "");
|
||||
|
||||
var server = state.servers[state.selectedServerIndex];
|
||||
if (!server) {
|
||||
el.innerHTML = '<div class="co-details-title">Server Details</div><div class="co-empty">Select a server</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rulesHtml = "";
|
||||
if (server.rules && server.rules.length) {
|
||||
rulesHtml = "<ul class=\"co-detail-rules\">" + server.rules.map(function (r) {
|
||||
return "<li>" + escapeHtml(r) + "</li>";
|
||||
}).join("") + "</ul>";
|
||||
}
|
||||
|
||||
var previewHtml = server.previewImage
|
||||
? '<img src="' + escapeAttr(server.previewImage) + '" alt="Preview">'
|
||||
: "Preview Unavailable";
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="co-details-title">Server Details</div>' +
|
||||
'<div class="co-preview">' + previewHtml + "</div>" +
|
||||
'<div class="co-detail-name">' + escapeHtml(server.name) + "</div>" +
|
||||
detailRow("Region", server.region) +
|
||||
detailRow("Players", server.players + "/" + server.maxPlayers) +
|
||||
detailRow("Ping", server.ping + " ms") +
|
||||
detailRow("Worldspace", server.worldspace) +
|
||||
detailRow("Mode", server.mode) +
|
||||
detailRow("Status", server.status) +
|
||||
detailRow("Modded", server.modded ? "Yes" : "No") +
|
||||
detailRow("Password", server.passworded ? "Required" : "None") +
|
||||
'<div class="co-detail-desc">' + escapeHtml(server.description || "") + "</div>" +
|
||||
rulesHtml +
|
||||
'<div class="co-detail-desc" style="margin-top:8px;color:var(--co-text-dim)">Mod list: coming soon</div>';
|
||||
}
|
||||
};
|
||||
|
||||
function detailRow(label, value) {
|
||||
return '<div class="co-detail-row"><span>' + label + '</span><span>' + escapeHtml(String(value)) + "</span></div>";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,80 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
window.CO_ServerList = {
|
||||
pingClass: function (ping) {
|
||||
if (ping <= 50) return "co-ping-good";
|
||||
if (ping <= 100) return "co-ping-mid";
|
||||
return "co-ping-bad";
|
||||
},
|
||||
|
||||
render: function (root, state) {
|
||||
var wrap = root.querySelector(".co-server-list-wrap");
|
||||
var list = root.querySelector(".co-server-list");
|
||||
if (!wrap || !list) return;
|
||||
|
||||
wrap.className = "co-server-list-wrap" + (state.focusZone === "server-list" ? " focused" : "");
|
||||
|
||||
if (!state.servers.length) {
|
||||
list.innerHTML = '<div class="co-empty">No servers found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = state.servers.map(function (server, index) {
|
||||
var selected = index === state.selectedServerIndex ? " selected" : "";
|
||||
var fav = server.favorite ? " on" : "";
|
||||
var pingCls = CO_ServerList.pingClass(server.ping);
|
||||
return (
|
||||
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id) + '">' +
|
||||
'<span class="co-fav' + fav + '">' + (server.favorite ? "\u2605" : "\u2606") + "</span>" +
|
||||
"<span>" + escapeHtml(server.name) + "</span>" +
|
||||
"<span>" + server.players + "/" + server.maxPlayers + "</span>" +
|
||||
'<span class="co-ping ' + pingCls + '">' + server.ping + " ms</span>" +
|
||||
"<span>" + escapeHtml(server.worldspace) + "</span>" +
|
||||
"<span>" + escapeHtml(server.mode) + "</span>" +
|
||||
"<span>" + escapeHtml(server.status) + "</span>" +
|
||||
"</div>"
|
||||
);
|
||||
}).join("");
|
||||
},
|
||||
|
||||
bind: function (root, onSelect) {
|
||||
var list = root.querySelector(".co-server-list");
|
||||
if (!list) return;
|
||||
|
||||
list.addEventListener("click", function (e) {
|
||||
var row = e.target.closest(".co-server-row");
|
||||
if (!row) return;
|
||||
onSelect(parseInt(row.getAttribute("data-index"), 10));
|
||||
});
|
||||
|
||||
list.addEventListener("dblclick", function (e) {
|
||||
var row = e.target.closest(".co-server-row");
|
||||
if (!row) return;
|
||||
onSelect(parseInt(row.getAttribute("data-index"), 10));
|
||||
if (window.CO_App && window.CO_App.joinSelected) {
|
||||
window.CO_App.joinSelected();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
scrollToSelected: function (root, index) {
|
||||
var list = root.querySelector(".co-server-list");
|
||||
var row = list && list.querySelector('.co-server-row[data-index="' + index + '"]');
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TAB_ORDER = ["direct-connect", "server-browser", "favorites", "recent"];
|
||||
|
||||
window.CO_Tabs = {
|
||||
TAB_ORDER: TAB_ORDER,
|
||||
|
||||
render: function (root, state) {
|
||||
var tabsEl = root.querySelector(".co-tabs");
|
||||
if (!tabsEl) return;
|
||||
|
||||
var labels = {
|
||||
"direct-connect": "Direct Connect",
|
||||
"server-browser": "Server Browser",
|
||||
favorites: "Favorites",
|
||||
recent: "Recent"
|
||||
};
|
||||
|
||||
tabsEl.innerHTML = TAB_ORDER.map(function (tabId, index) {
|
||||
var active = state.activeTab === tabId ? " active" : "";
|
||||
var focused = state.focusZone === "tabs" && state.selectedTabIndex === index ? " focused" : "";
|
||||
return (
|
||||
'<button type="button" class="co-tab' + active + focused + '" data-tab="' + tabId + '" data-index="' + index + '">' +
|
||||
labels[tabId] +
|
||||
"</button>"
|
||||
);
|
||||
}).join("");
|
||||
},
|
||||
|
||||
bind: function (root, onTabClick) {
|
||||
var tabsEl = root.querySelector(".co-tabs");
|
||||
if (!tabsEl) return;
|
||||
|
||||
tabsEl.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest(".co-tab");
|
||||
if (!btn) return;
|
||||
onTabClick(btn.getAttribute("data-tab"));
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Commonwealth Online</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="co-root" id="coRoot">
|
||||
<div class="co-panel" id="coPanel">
|
||||
<header class="co-header">
|
||||
<h1>Commonwealth Online</h1>
|
||||
<div class="co-subtitle">Server Browser</div>
|
||||
</header>
|
||||
|
||||
<nav class="co-tabs"></nav>
|
||||
|
||||
<div class="co-body">
|
||||
<div class="co-tab-content">
|
||||
<div class="co-tab-pane" data-pane="direct-connect"></div>
|
||||
<div class="co-tab-pane active" data-pane="server-browser">
|
||||
<div class="co-browser-layout">
|
||||
<aside class="co-filters"></aside>
|
||||
<div class="co-server-list-wrap">
|
||||
<div class="co-server-list-header">
|
||||
<span></span>
|
||||
<span>Server Name</span>
|
||||
<span>Players</span>
|
||||
<span>Ping</span>
|
||||
<span>Worldspace</span>
|
||||
<span>Mode</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div class="co-server-list"></div>
|
||||
</div>
|
||||
<aside class="co-details"></aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="co-tab-pane" data-pane="favorites">
|
||||
<div class="co-browser-layout co-favorites-layout">
|
||||
<div class="co-server-list-wrap">
|
||||
<div class="co-server-list-header">
|
||||
<span></span>
|
||||
<span>Server Name</span>
|
||||
<span>Players</span>
|
||||
<span>Ping</span>
|
||||
<span>Worldspace</span>
|
||||
<span>Mode</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div class="co-server-list co-favorites-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="co-tab-pane" data-pane="recent">
|
||||
<div class="co-browser-layout co-recent-layout">
|
||||
<div class="co-server-list-wrap">
|
||||
<div class="co-server-list-header">
|
||||
<span></span>
|
||||
<span>Server Name</span>
|
||||
<span>Players</span>
|
||||
<span>Ping</span>
|
||||
<span>Worldspace</span>
|
||||
<span>Mode</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div class="co-server-list co-recent-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="co-action-bar">
|
||||
<div class="co-status-text">
|
||||
Status: <span id="coConnectionStatus">Online</span>
|
||||
<span class="co-count" id="coServerCount"></span>
|
||||
</div>
|
||||
<div class="co-prompts"></div>
|
||||
</footer>
|
||||
|
||||
<div class="co-join-overlay hidden" id="coJoinOverlay">
|
||||
<div class="co-spinner"></div>
|
||||
<p id="coJoinMessage">Connecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./components/mock-data.js"></script>
|
||||
<script src="./components/tabs.js"></script>
|
||||
<script src="./components/filters.js"></script>
|
||||
<script src="./components/server-list.js"></script>
|
||||
<script src="./components/server-details.js"></script>
|
||||
<script src="./components/controller-prompts.js"></script>
|
||||
<script src="./components/direct-connect.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,675 @@
|
||||
:root {
|
||||
--co-green: #4dff3a;
|
||||
--co-green-soft: rgba(77, 255, 58, 0.88);
|
||||
--co-green-dim: rgba(57, 255, 34, 0.35);
|
||||
--co-bg: rgba(0, 14, 0, 0.92);
|
||||
--co-bg-dark: rgba(0, 8, 0, 0.96);
|
||||
--co-bg-list: rgba(0, 12, 0, 0.88);
|
||||
--co-selected: rgba(77, 255, 58, 0.92);
|
||||
--co-selected-text: #041200;
|
||||
--co-border: rgba(77, 255, 58, 0.9);
|
||||
--co-text-dim: rgba(77, 255, 58, 0.68);
|
||||
--co-ping-good: #4dff3a;
|
||||
--co-ping-mid: #c8ff3a;
|
||||
--co-ping-bad: #ffd43a;
|
||||
--co-danger: #ff6a6a;
|
||||
--co-font: "Consolas", "Courier New", monospace;
|
||||
--co-spacing: 10px;
|
||||
--co-transition: 120ms ease;
|
||||
--co-text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
||||
/*
|
||||
* Fallout 4 main-menu safe zones (1280×720 UI scaled to viewport):
|
||||
* Left — menu list + green selection bar (~0–46%)
|
||||
* Top — "Fallout 4 Updates" / installed-content panel (~0–26% height)
|
||||
* Bottom — Bethesda logo / controller hints (~0–8% height)
|
||||
*/
|
||||
--co-panel-left: max(44vw, 500px);
|
||||
--co-panel-right: max(2vw, 24px);
|
||||
--co-panel-top: max(26vh, 220px);
|
||||
--co-panel-bottom: max(8vh, 72px);
|
||||
--co-font-sm: clamp(11px, 0.95vw, 13px);
|
||||
--co-font-md: clamp(13px, 1.1vw, 16px);
|
||||
--co-font-lg: clamp(15px, 1.25vw, 19px);
|
||||
--co-font-xl: clamp(18px, 1.5vw, 24px);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: var(--co-font);
|
||||
color: var(--co-green);
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.co-root {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Panel is inset from all Fallout main-menu chrome so vanilla UI stays visible. */
|
||||
.co-panel {
|
||||
position: absolute;
|
||||
left: var(--co-panel-left);
|
||||
right: var(--co-panel-right);
|
||||
top: var(--co-panel-top);
|
||||
bottom: var(--co-panel-bottom);
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid var(--co-border);
|
||||
background: var(--co-bg);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(77, 255, 58, 0.12),
|
||||
0 0 48px rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 80px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
transition: opacity var(--co-transition);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.co-panel.co-loading {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.co-header {
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid var(--co-green-dim);
|
||||
background: rgba(0, 6, 0, 0.45);
|
||||
}
|
||||
|
||||
.co-header h1 {
|
||||
font-size: var(--co-font-xl);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
text-shadow: var(--co-text-shadow), 0 0 12px rgba(77, 255, 58, 0.2);
|
||||
}
|
||||
|
||||
.co-header .co-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-dim);
|
||||
}
|
||||
|
||||
.co-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--co-green-dim);
|
||||
padding: 0 16px;
|
||||
background: rgba(0, 6, 0, 0.3);
|
||||
}
|
||||
|
||||
.co-tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--co-text-dim);
|
||||
font: inherit;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
transition: color var(--co-transition), border-color var(--co-transition);
|
||||
}
|
||||
|
||||
.co-tab:hover {
|
||||
color: var(--co-green-soft);
|
||||
}
|
||||
|
||||
.co-tab.active {
|
||||
color: var(--co-green);
|
||||
border-color: var(--co-border);
|
||||
background: rgba(57, 255, 34, 0.06);
|
||||
}
|
||||
|
||||
.co-tab.focused {
|
||||
outline: 1px solid var(--co-green);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.co-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.co-browser-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(240px, 30%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.co-filters {
|
||||
grid-column: 1 / -1;
|
||||
border-bottom: 1px solid var(--co-green-dim);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px 14px;
|
||||
overflow: visible;
|
||||
background: rgba(0, 8, 0, 0.55);
|
||||
}
|
||||
|
||||
.co-filters.focused {
|
||||
outline: 1px solid var(--co-green);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.co-filter-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.co-filter-row[data-filter-index="0"] {
|
||||
flex: 1 1 180px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.co-filter-row[data-filter-index="1"],
|
||||
.co-filter-row[data-filter-index="2"],
|
||||
.co-filter-row[data-filter-index="3"],
|
||||
.co-filter-row[data-filter-index="4"] {
|
||||
flex: 0 1 130px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.co-filter-label {
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-dim);
|
||||
}
|
||||
|
||||
.co-filter-input,
|
||||
.co-filter-select {
|
||||
width: 100%;
|
||||
background: var(--co-bg-dark);
|
||||
border: 1px solid var(--co-green-dim);
|
||||
color: var(--co-green);
|
||||
font: inherit;
|
||||
font-size: var(--co-font-sm);
|
||||
padding: 8px 10px;
|
||||
text-shadow: var(--co-text-shadow);
|
||||
}
|
||||
|
||||
.co-filter-input:focus,
|
||||
.co-filter-select:focus {
|
||||
border-color: var(--co-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.co-filter-row.focused .co-filter-input,
|
||||
.co-filter-row.focused .co-filter-select,
|
||||
.co-filter-row.focused .co-checkbox {
|
||||
outline: 1px solid var(--co-green);
|
||||
}
|
||||
|
||||
.co-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 8px 0 0;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.co-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--co-border);
|
||||
background: var(--co-bg-dark);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.co-checkbox.checked::after {
|
||||
content: "X";
|
||||
font-size: 11px;
|
||||
color: var(--co-green);
|
||||
}
|
||||
|
||||
.co-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--co-border);
|
||||
background: rgba(77, 255, 58, 0.1);
|
||||
color: var(--co-green);
|
||||
font: inherit;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
align-self: flex-end;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--co-transition);
|
||||
}
|
||||
|
||||
.co-btn:hover {
|
||||
background: rgba(57, 255, 34, 0.18);
|
||||
}
|
||||
|
||||
.co-btn.focused {
|
||||
outline: 1px solid var(--co-green);
|
||||
background: rgba(57, 255, 34, 0.22);
|
||||
}
|
||||
|
||||
.co-server-list-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--co-green-dim);
|
||||
background: var(--co-bg-list);
|
||||
}
|
||||
|
||||
.co-server-list-wrap.focused {
|
||||
outline: 1px solid var(--co-green);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.co-server-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 2.2fr 72px 64px 1fr 88px 72px;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-dim);
|
||||
border-bottom: 1px solid var(--co-green-dim);
|
||||
background: rgba(0, 6, 0, 0.5);
|
||||
}
|
||||
|
||||
.co-server-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--co-green-dim) var(--co-bg-dark);
|
||||
}
|
||||
|
||||
.co-server-list::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.co-server-list::-webkit-scrollbar-track {
|
||||
background: var(--co-bg-dark);
|
||||
}
|
||||
|
||||
.co-server-list::-webkit-scrollbar-thumb {
|
||||
background: var(--co-green-dim);
|
||||
border: 1px solid var(--co-border);
|
||||
}
|
||||
|
||||
.co-server-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 2.2fr 72px 64px 1fr 88px 72px;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: var(--co-font-md);
|
||||
line-height: 1.35;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(77, 255, 58, 0.1);
|
||||
transition: background var(--co-transition);
|
||||
text-shadow: var(--co-text-shadow);
|
||||
}
|
||||
|
||||
.co-server-row > span:nth-child(2) {
|
||||
font-size: var(--co-font-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.co-server-row > span:nth-child(3) {
|
||||
font-size: var(--co-font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.co-server-row:hover {
|
||||
background: rgba(57, 255, 34, 0.06);
|
||||
}
|
||||
|
||||
.co-server-row.selected {
|
||||
background: var(--co-selected);
|
||||
color: var(--co-selected-text);
|
||||
}
|
||||
|
||||
.co-server-row.selected .co-ping,
|
||||
.co-server-row.selected .co-fav {
|
||||
color: var(--co-selected-text);
|
||||
}
|
||||
|
||||
.co-fav {
|
||||
text-align: center;
|
||||
color: var(--co-text-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.co-fav.on {
|
||||
color: var(--co-green);
|
||||
}
|
||||
|
||||
.co-server-row.selected .co-fav.on {
|
||||
color: var(--co-selected-text);
|
||||
}
|
||||
|
||||
.co-ping-good { color: var(--co-ping-good); }
|
||||
.co-ping-mid { color: var(--co-ping-mid); }
|
||||
.co-ping-bad { color: var(--co-ping-bad); }
|
||||
|
||||
.co-server-row.selected .co-ping-good,
|
||||
.co-server-row.selected .co-ping-mid,
|
||||
.co-server-row.selected .co-ping-bad {
|
||||
color: var(--co-selected-text);
|
||||
}
|
||||
|
||||
.co-details {
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
font-size: var(--co-font-md);
|
||||
background: rgba(0, 8, 0, 0.45);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.co-details.focused {
|
||||
outline: 1px solid var(--co-green);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.co-details-title {
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.co-preview {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
border: 1px solid var(--co-green-dim);
|
||||
background: var(--co-bg-dark);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--co-text-dim);
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.co-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.co-detail-name {
|
||||
font-size: var(--co-font-lg);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
text-shadow: var(--co-text-shadow);
|
||||
}
|
||||
|
||||
.co-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(77, 255, 58, 0.08);
|
||||
}
|
||||
|
||||
.co-detail-row span:first-child {
|
||||
color: var(--co-text-dim);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.co-detail-row span:last-child {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.co-detail-desc {
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--co-green-soft);
|
||||
font-size: var(--co-font-sm);
|
||||
}
|
||||
|
||||
.co-detail-rules {
|
||||
margin-top: 10px;
|
||||
padding-left: 16px;
|
||||
color: var(--co-text-dim);
|
||||
font-size: var(--co-font-sm);
|
||||
}
|
||||
|
||||
.co-detail-rules li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.co-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--co-green-dim);
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0, 6, 0, 0.45);
|
||||
}
|
||||
|
||||
.co-status-text {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.co-status-text .co-count {
|
||||
color: var(--co-text-dim);
|
||||
}
|
||||
|
||||
.co-prompts {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.co-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.co-prompt-key {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--co-border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.co-direct-connect {
|
||||
padding: 28px 36px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.co-direct-connect h2 {
|
||||
font-size: var(--co-font-lg);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.co-direct-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.co-direct-row label {
|
||||
display: block;
|
||||
font-size: var(--co-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.co-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--co-text-dim);
|
||||
font-size: var(--co-font-md);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.co-join-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 10, 0, 0.88);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.co-join-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.co-join-overlay p {
|
||||
font-size: var(--co-font-md);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.co-join-overlay.co-error p {
|
||||
color: var(--co-danger);
|
||||
}
|
||||
|
||||
.co-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--co-green-dim);
|
||||
border-top-color: var(--co-green);
|
||||
animation: co-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes co-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.co-tab-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.co-tab-pane {
|
||||
display: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.co-tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.co-filters::-webkit-scrollbar,
|
||||
.co-details::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.co-filters::-webkit-scrollbar-thumb,
|
||||
.co-details::-webkit-scrollbar-thumb {
|
||||
background: var(--co-green-dim);
|
||||
}
|
||||
|
||||
.co-favorites-layout,
|
||||
.co-recent-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.co-favorites-layout .co-server-list-wrap,
|
||||
.co-recent-layout .co-server-list-wrap {
|
||||
grid-column: 1;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
:root {
|
||||
--co-panel-left: max(46vw, 520px);
|
||||
--co-panel-right: max(2vw, 16px);
|
||||
--co-panel-top: max(28vh, 200px);
|
||||
--co-panel-bottom: max(9vh, 64px);
|
||||
}
|
||||
|
||||
.co-browser-layout {
|
||||
grid-template-columns: 1fr minmax(200px, 34%);
|
||||
}
|
||||
|
||||
.co-filter-row[data-filter-index="1"],
|
||||
.co-filter-row[data-filter-index="2"],
|
||||
.co-filter-row[data-filter-index="3"],
|
||||
.co-filter-row[data-filter-index="4"] {
|
||||
flex: 0 1 100px;
|
||||
min-width: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 2560px) {
|
||||
:root {
|
||||
--co-panel-left: max(44vw, 960px);
|
||||
--co-panel-right: max(2.5vw, 48px);
|
||||
--co-panel-top: max(24vh, 280px);
|
||||
--co-panel-bottom: max(7vh, 96px);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user