Introduce shared web UI runtime and migrate org/store bridges
- add common ForgeWebUI runtime, site loader, and SQF WebUI bridge base declarations - migrate org and store web UIs to src-driven bundles and new bridge/bootstrap flow - update addon configs/prep hooks and document the shared CT_WEBBROWSER framework
This commit is contained in:
parent
7a214d835d
commit
e15d4b3066
@ -1,3 +1,5 @@
|
||||
# forge_client_common
|
||||
|
||||
Common functionality shared between addons.
|
||||
|
||||
See [WEB_UI_FRAMEWORK.md](./WEB_UI_FRAMEWORK.md) for the proposed shared `CT_WEBBROWSER` UI framework layout and API.
|
||||
|
||||
991
arma/client/addons/common/WEB_UI_FRAMEWORK.md
Normal file
991
arma/client/addons/common/WEB_UI_FRAMEWORK.md
Normal file
@ -0,0 +1,991 @@
|
||||
# Web UI Framework Proposal
|
||||
|
||||
## Goal
|
||||
|
||||
Create a shared web UI framework inside `forge_client_common` that provides one browser runtime for all `CT_WEBBROWSER` interfaces:
|
||||
|
||||
- store
|
||||
- bank
|
||||
- garage
|
||||
- org
|
||||
- actor
|
||||
- notifications
|
||||
|
||||
The framework should standardize:
|
||||
|
||||
- browser bootstrapping
|
||||
- Arma to JS messaging
|
||||
- JS to Arma messaging
|
||||
- reactive state updates
|
||||
- shared UI primitives
|
||||
- asset loading
|
||||
- teardown and remount behavior
|
||||
|
||||
## Why This Should Live In `common`
|
||||
|
||||
The current client web UIs already share the same underlying concerns:
|
||||
|
||||
- `A3API.RequestFile` for loading scripts and styles
|
||||
- `A3API.SendAlert` for outbound events
|
||||
- `ctrlWebBrowserAction ["ExecJS", ...]` for inbound events
|
||||
- full-page rerender on every signal update
|
||||
- duplicated runtime and bridge code across addons
|
||||
|
||||
That makes `forge_client_common` the right owner for:
|
||||
|
||||
- the browser runtime
|
||||
- the bridge contract
|
||||
- reusable DOM helpers
|
||||
- shared components and styles
|
||||
|
||||
Each addon should keep only:
|
||||
|
||||
- app-specific state
|
||||
- app-specific event names
|
||||
- app-specific SQF handlers
|
||||
- app-specific views and theme assets
|
||||
|
||||
## Constraints From `CT_WEBBROWSER`
|
||||
|
||||
This framework should be built for the actual browser host, not for a generic modern frontend stack.
|
||||
|
||||
- Browser engine should be treated as conservative Chromium/CEF.
|
||||
- HTML is hosted inside the Arma browser control, not a normal web server app.
|
||||
- Asset loading must work through `A3API.RequestFile`.
|
||||
- Game integration must work through `A3API.SendAlert` and SQF `ExecJS`.
|
||||
- Browser controls are opened and destroyed by UI displays, so mount/unmount must be explicit.
|
||||
- Startup latency matters because players open these UIs interactively in-game.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Keep the runtime small.
|
||||
2. Avoid framework dependencies like React or Vue.
|
||||
3. Prefer one shared bundle plus one app bundle per UI.
|
||||
4. Support coarse-grained reactivity first, then targeted DOM patching where it matters.
|
||||
5. Make the Arma bridge a first-class host adapter, not an afterthought.
|
||||
6. Keep app logic plain JavaScript so views are easy to reason about.
|
||||
7. Make every UI follow the same bootstrap contract.
|
||||
|
||||
## Proposed Ownership
|
||||
|
||||
### Common addon
|
||||
|
||||
`forge_client_common` should own:
|
||||
|
||||
- browser host adapter
|
||||
- reactive runtime
|
||||
- DOM renderer
|
||||
- shared event bus
|
||||
- base CSS tokens and utility classes
|
||||
- shared components
|
||||
- generic bootstrap helper
|
||||
- SQF bridge base class
|
||||
|
||||
### Feature addons
|
||||
|
||||
Each feature addon should own:
|
||||
|
||||
- one app entrypoint
|
||||
- feature store/state
|
||||
- feature bridge schema
|
||||
- feature views/components
|
||||
- feature-specific CSS layer
|
||||
- feature SQF bridge subclass/instance
|
||||
|
||||
## Proposed Folder Layout
|
||||
|
||||
```text
|
||||
arma/client/addons/common/
|
||||
ui/
|
||||
src/
|
||||
runtime.js
|
||||
host.js
|
||||
bridge.js
|
||||
app.js
|
||||
index.js
|
||||
_site/
|
||||
forge-webui.js
|
||||
functions/
|
||||
fnc_initWebUIBridge.sqf
|
||||
fnc_openWebUI.sqf
|
||||
fnc_sendWebUIEvent.sqf
|
||||
README.md
|
||||
WEB_UI_FRAMEWORK.md
|
||||
```
|
||||
|
||||
Feature addon structure would then look like:
|
||||
|
||||
```text
|
||||
arma/client/addons/org/
|
||||
ui/
|
||||
_site/
|
||||
index.html
|
||||
app.js
|
||||
views/
|
||||
components/
|
||||
theme.css
|
||||
functions/
|
||||
fnc_initOrgUIBridge.sqf
|
||||
fnc_openUI.sqf
|
||||
fnc_handleUIEvents.sqf
|
||||
```
|
||||
|
||||
## Runtime API Sketch
|
||||
|
||||
The shared runtime should expose a small API on `window.ForgeWebUI`.
|
||||
|
||||
### Core API
|
||||
|
||||
```js
|
||||
ForgeWebUI = {
|
||||
h,
|
||||
text,
|
||||
fragment,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
batch,
|
||||
mount,
|
||||
unmount,
|
||||
createApp,
|
||||
createBridge,
|
||||
createAssetLoader,
|
||||
createNoticeCenter,
|
||||
};
|
||||
```
|
||||
|
||||
### Reactive primitives
|
||||
|
||||
```js
|
||||
const count = signal(0);
|
||||
const doubled = computed(() => count() * 2);
|
||||
|
||||
effect(() => {
|
||||
console.log("count", count());
|
||||
});
|
||||
|
||||
count.set(5);
|
||||
```
|
||||
|
||||
Design notes:
|
||||
|
||||
- `signal()` returns a getter function with `.set()` and `.update()`.
|
||||
- `computed()` caches until one of its dependencies changes.
|
||||
- `effect()` is for bridge sync, timers, DOM subscriptions, and cleanup.
|
||||
- `batch()` groups several writes into one render pass.
|
||||
|
||||
### DOM/rendering
|
||||
|
||||
```js
|
||||
function CounterView() {
|
||||
return h("button", {
|
||||
onClick() {
|
||||
count.update((value) => value + 1);
|
||||
}
|
||||
}, `Count: ${count()}`);
|
||||
}
|
||||
|
||||
mount(document.getElementById("app"), CounterView);
|
||||
```
|
||||
|
||||
The renderer should support:
|
||||
|
||||
- keyed child reconciliation
|
||||
- event binding
|
||||
- text node updates
|
||||
- conditional sections
|
||||
- list rendering
|
||||
- SVG nodes
|
||||
- mount cleanup
|
||||
|
||||
It should not rebuild the whole root on every write.
|
||||
|
||||
## App Bootstrap Contract
|
||||
|
||||
Every app should use the same bootstrap shape:
|
||||
|
||||
```js
|
||||
const app = ForgeWebUI.createApp({
|
||||
name: "org",
|
||||
root: "#app",
|
||||
setup({ host, bridge, assets, notices }) {
|
||||
const store = createOrgStore();
|
||||
|
||||
bridge.on("org::sync", (payload) => {
|
||||
store.hydrate(payload);
|
||||
});
|
||||
|
||||
bridge.ready();
|
||||
|
||||
return () => OrgApp({ store, host, notices });
|
||||
}
|
||||
});
|
||||
|
||||
app.start();
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `createApp()` locates the root node
|
||||
- waits for DOM readiness
|
||||
- sets up host services
|
||||
- mounts the view
|
||||
- wires bridge event listeners
|
||||
- exposes teardown hooks
|
||||
|
||||
## Host Adapter API
|
||||
|
||||
The Arma host layer should hide `A3API` details behind one consistent service.
|
||||
|
||||
```js
|
||||
const host = {
|
||||
isArma: true,
|
||||
requestFile(path),
|
||||
requestTexture(path, size),
|
||||
send(event, data),
|
||||
exec(name, data),
|
||||
on(event, handler),
|
||||
off(event, handler),
|
||||
ready(data),
|
||||
close(data),
|
||||
};
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `send()` wraps `A3API.SendAlert(JSON.stringify(...))`
|
||||
- `on()` and `off()` subscribe to messages injected from SQF
|
||||
- `ready()` announces page readiness to SQF
|
||||
- `close()` sends a standard close event
|
||||
- if `A3API` is unavailable, fallback behavior supports local browser testing
|
||||
|
||||
## JS Bridge Contract
|
||||
|
||||
Each page should expose one stable bridge object to SQF:
|
||||
|
||||
```js
|
||||
window.ForgeBridge.receive({
|
||||
event: "org::sync",
|
||||
data: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
This replaces app-specific globals like:
|
||||
|
||||
- `StoreUIBridge`
|
||||
- `OrgUIBridge`
|
||||
|
||||
Recommended interface:
|
||||
|
||||
```js
|
||||
window.ForgeBridge = {
|
||||
receive(payload),
|
||||
receiveMany(events),
|
||||
reset(),
|
||||
ping(),
|
||||
};
|
||||
```
|
||||
|
||||
Feature apps should register handlers with the shared bridge:
|
||||
|
||||
```js
|
||||
bridge.on("store::hydrate", handleHydrate);
|
||||
bridge.on("store::checkout::success", handleCheckoutSuccess);
|
||||
```
|
||||
|
||||
That removes duplicated payload parsing from each app bridge file.
|
||||
|
||||
## SQF Bridge Base Class
|
||||
|
||||
The SQF side should also be normalized in `common`.
|
||||
|
||||
### Shared base responsibilities
|
||||
|
||||
- find active browser control
|
||||
- execute JS safely
|
||||
- send `{ event, data }` payloads
|
||||
- queue payloads until page ready
|
||||
- flush pending payloads on ready
|
||||
- standardize close handling
|
||||
- standardize logging and diagnostics
|
||||
|
||||
### SQF API sketch
|
||||
|
||||
```sqf
|
||||
GVAR(WebUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "WebUIBridgeBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["pendingEvents", []];
|
||||
_self set ["isReady", false];
|
||||
}],
|
||||
["getActiveBrowserControl", compileFinal { ... }],
|
||||
["execJS", compileFinal { ... }],
|
||||
["sendEvent", compileFinal { ... }],
|
||||
["queueEvent", compileFinal { ... }],
|
||||
["flushPendingEvents", compileFinal { ... }],
|
||||
["handleReady", compileFinal { ... }],
|
||||
["handleClose", compileFinal { ... }]
|
||||
];
|
||||
```
|
||||
|
||||
Feature bridges like org or store would then extend only the behavior they need:
|
||||
|
||||
- payload building
|
||||
- server RPC dispatch
|
||||
- feature response mapping
|
||||
|
||||
## SQF Type Model With `createHashMapObject`
|
||||
|
||||
The SQF side should lean into `createHashMapObject` instead of using plain hash maps for everything.
|
||||
|
||||
This gives us:
|
||||
|
||||
- inheritance through `#base`
|
||||
- explicit type tagging through `#type`
|
||||
- constructors through `#create`
|
||||
- cleanup through `#delete`
|
||||
|
||||
That is a strong fit for browser UI infrastructure because the UI layer already has clear object roles.
|
||||
|
||||
### Recommended types
|
||||
|
||||
At minimum, define these object families in `forge_client_common`:
|
||||
|
||||
- `IWebUIBridge`
|
||||
- `IWebUIScreen`
|
||||
- `IWebUIRequest`
|
||||
- `IWebUISubscription`
|
||||
|
||||
Feature addons can then define their own types on top:
|
||||
|
||||
- `OrgUIBridge`
|
||||
- `StoreUIBridge`
|
||||
- `BankUIBridge`
|
||||
- `GarageUIBridge`
|
||||
|
||||
### Example hierarchy
|
||||
|
||||
```sqf
|
||||
private _webUIBridgeDeclaration = [
|
||||
["#type", "IWebUIBridge"],
|
||||
["#create", { ... }],
|
||||
["getActiveBrowserControl", { ... }],
|
||||
["sendEvent", { ... }],
|
||||
["handleReady", { ... }],
|
||||
["dispose", { ... }]
|
||||
];
|
||||
|
||||
private _orgUIBridgeDeclaration = [
|
||||
["#base", _webUIBridgeDeclaration],
|
||||
["#type", "OrgUIBridge"],
|
||||
["buildHydratePayload", { ... }],
|
||||
["handleCreditResponse", { ... }]
|
||||
];
|
||||
```
|
||||
|
||||
Type checks then become straightforward:
|
||||
|
||||
```sqf
|
||||
if ("IWebUIBridge" in (_bridge get "#type")) then {
|
||||
_bridge call ["sendEvent", ["ui::ping", createHashMap]];
|
||||
};
|
||||
```
|
||||
|
||||
### Why Example 4 matters
|
||||
|
||||
Example 4 on the wiki shows the important lifecycle property:
|
||||
|
||||
- constructor creates a resource
|
||||
- object holds that resource
|
||||
- destructor deletes that resource when the object is released
|
||||
|
||||
That pattern maps directly to UI/session resources.
|
||||
|
||||
### Good uses of `#delete` in this framework
|
||||
|
||||
- clear pending request queues
|
||||
- unregister display event handlers
|
||||
- null out active browser control references
|
||||
- stop polling/update loops
|
||||
- remove temporary mission event handlers
|
||||
- release temporary response trackers
|
||||
|
||||
### Example use: request/response object
|
||||
|
||||
```sqf
|
||||
private _requestDeclaration = [
|
||||
["#type", "IWebUIRequest"],
|
||||
["#create", {
|
||||
params ["_requestId", "_onTimeout"];
|
||||
_self set ["requestId", _requestId];
|
||||
_self set ["onTimeout", _onTimeout];
|
||||
_self set ["isResolved", false];
|
||||
}],
|
||||
["resolve", {
|
||||
_self set ["isResolved", true];
|
||||
}],
|
||||
["#delete", {
|
||||
if !(_self getOrDefault ["isResolved", false]) then {
|
||||
private _onTimeout = _self getOrDefault ["onTimeout", {}];
|
||||
call _onTimeout;
|
||||
};
|
||||
}]
|
||||
];
|
||||
```
|
||||
|
||||
This is the same concept as Example 4:
|
||||
|
||||
- object owns a resource or responsibility
|
||||
- when the object is released, cleanup happens automatically
|
||||
|
||||
## Lifecycle Guidance
|
||||
|
||||
Use destructors as a cleanup safety net, not as the only control path.
|
||||
|
||||
Reason:
|
||||
|
||||
- `#delete` runs when the last reference is removed
|
||||
- that is useful, but not always the best moment for gameplay/UI logic
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
1. expose an explicit `dispose` or `close` method
|
||||
2. perform normal cleanup there
|
||||
3. let `#delete` catch anything missed
|
||||
|
||||
That keeps UI shutdown deterministic while still benefiting from automatic cleanup.
|
||||
|
||||
## Typed Screen Objects
|
||||
|
||||
We can also model each open browser UI as a typed screen object instead of just storing a control reference.
|
||||
|
||||
Example:
|
||||
|
||||
```sqf
|
||||
private _screenDeclaration = [
|
||||
["#type", "IWebUIScreen"],
|
||||
["#create", {
|
||||
params ["_displayName", "_control"];
|
||||
_self set ["displayName", _displayName];
|
||||
_self set ["control", _control];
|
||||
_self set ["isReady", false];
|
||||
_self set ["pendingEvents", []];
|
||||
}],
|
||||
["markReady", {
|
||||
_self set ["isReady", true];
|
||||
}],
|
||||
["queueEvent", { ... }],
|
||||
["flushPendingEvents", { ... }],
|
||||
["dispose", {
|
||||
_self set ["pendingEvents", []];
|
||||
_self set ["control", controlNull];
|
||||
}]
|
||||
];
|
||||
```
|
||||
|
||||
That gives us a cleaner split:
|
||||
|
||||
- bridge object owns app-level behavior
|
||||
- screen object owns one live browser control/session
|
||||
- request objects own transient async work
|
||||
|
||||
## Recommended Application To Current Addons
|
||||
|
||||
The current org and store bridge objects already use `createHashMapObject`.
|
||||
|
||||
This should evolve into:
|
||||
|
||||
- one shared `IWebUIBridge` base declaration in `common`
|
||||
- one shared `IWebUIScreen` declaration in `common`
|
||||
- feature bridge types inheriting from `IWebUIBridge`
|
||||
- optional transient request/session helper types where async cleanup matters
|
||||
|
||||
That will make the SQF side more explicit, easier to test, and safer around UI teardown.
|
||||
|
||||
## Event Naming
|
||||
|
||||
Keep namespaced events. The current event style is good.
|
||||
|
||||
Examples:
|
||||
|
||||
- `org::ready`
|
||||
- `org::sync`
|
||||
- `org::create::request`
|
||||
- `store::checkout::request`
|
||||
- `notifications::ready`
|
||||
|
||||
Standardize a small set of host-level events:
|
||||
|
||||
- `ui::ready`
|
||||
- `ui::close`
|
||||
- `ui::error`
|
||||
- `ui::ping`
|
||||
|
||||
And keep feature events under their own namespace.
|
||||
|
||||
## State Model
|
||||
|
||||
The framework should support two store patterns:
|
||||
|
||||
### Local signal store
|
||||
|
||||
Good for:
|
||||
|
||||
- form state
|
||||
- modal state
|
||||
- selection state
|
||||
- optimistic UI flags
|
||||
|
||||
### Domain store wrapper
|
||||
|
||||
Good for:
|
||||
|
||||
- hydrated server payloads
|
||||
- catalog data
|
||||
- actor action lists
|
||||
- organization portal data
|
||||
|
||||
Recommended store API:
|
||||
|
||||
```js
|
||||
function createStore(initialState) {
|
||||
const state = signal(initialState);
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state();
|
||||
},
|
||||
patch(partial) {
|
||||
state.set({ ...state(), ...partial });
|
||||
},
|
||||
replace(next) {
|
||||
state.set(next);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Component Update Model
|
||||
|
||||
The framework should update component subtrees, not the full UI root.
|
||||
|
||||
That means:
|
||||
|
||||
- no browser page reload
|
||||
- no `innerHTML = ""` on the app root for every state change
|
||||
- only components that read changed state should rerender
|
||||
|
||||
### Practical expectation
|
||||
|
||||
Examples:
|
||||
|
||||
- adding a member updates `MembersCard` and any member count badge
|
||||
- granting a credit line updates `TreasuryCard` and the specific member row
|
||||
- updating funds updates treasury summary components only
|
||||
- showing a modal or notice updates only the overlay layer
|
||||
|
||||
## Store Contract
|
||||
|
||||
Each app store should expose three layers:
|
||||
|
||||
1. domain state signals
|
||||
2. derived selectors/computed values
|
||||
3. mutation methods
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```js
|
||||
function createOrgStore() {
|
||||
const org = signal({
|
||||
id: "",
|
||||
name: "",
|
||||
ownerUid: "",
|
||||
});
|
||||
|
||||
const session = signal({
|
||||
actorUid: "",
|
||||
actorName: "",
|
||||
role: "",
|
||||
ceo: false,
|
||||
});
|
||||
|
||||
const treasury = signal({
|
||||
funds: 0,
|
||||
reputation: 0,
|
||||
creditLines: [],
|
||||
});
|
||||
|
||||
const roster = signal({
|
||||
members: [],
|
||||
});
|
||||
|
||||
const ui = signal({
|
||||
modal: null,
|
||||
notices: [],
|
||||
treasuryTab: "overview",
|
||||
});
|
||||
|
||||
const memberCount = computed(() => roster().members.length);
|
||||
const activeCreditCount = computed(() => treasury().creditLines.length);
|
||||
|
||||
return {
|
||||
org,
|
||||
session,
|
||||
treasury,
|
||||
roster,
|
||||
ui,
|
||||
memberCount,
|
||||
activeCreditCount,
|
||||
hydrate(payload) { ... },
|
||||
addMember(member) { ... },
|
||||
removeMember(memberUid) { ... },
|
||||
upsertCreditLine(line) { ... },
|
||||
setFunds(amount) { ... },
|
||||
openModal(type, data) { ... },
|
||||
closeModal() { ... },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- component code reads signals directly from the store
|
||||
- mutation methods are the only place that update domain state
|
||||
- derived values use `computed()` instead of recalculating in every component
|
||||
- UI state stays separate from domain state
|
||||
|
||||
## Component Contract
|
||||
|
||||
Components should be plain functions that subscribe only to the signals they read.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
function MembersCard({ store, actions }) {
|
||||
const members = store.roster().members;
|
||||
const canManageMembers = store.canManageMembers();
|
||||
|
||||
return Card({
|
||||
title: "Members",
|
||||
body: List({
|
||||
items: members,
|
||||
key: (member) => member.uid,
|
||||
renderItem: (member) =>
|
||||
MemberRow({
|
||||
member,
|
||||
canRemove: canManageMembers && !store.isProtectedMember(member),
|
||||
onRemove: () => actions.removeMember(member.uid),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
In this model:
|
||||
|
||||
- `MembersCard` rerenders when `roster().members` changes
|
||||
- it does not rerender when treasury funds change
|
||||
- `TreasuryCard` rerenders when `treasury()` changes
|
||||
- modal components rerender when `ui().modal` changes
|
||||
|
||||
## Patch-Oriented Mutations
|
||||
|
||||
Interactive actions should prefer small patch events over full app hydration.
|
||||
|
||||
Recommended event examples:
|
||||
|
||||
- `org::member::added`
|
||||
- `org::member::removed`
|
||||
- `org::member::creditUpdated`
|
||||
- `org::treasury::fundsUpdated`
|
||||
- `org::notice::show`
|
||||
|
||||
Initial load can still use a hydrate event:
|
||||
|
||||
- `org::hydrate`
|
||||
|
||||
But actions like assigning credit lines should not require rebuilding the full portal payload.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
bridge.on("org::member::creditUpdated", ({ memberUid, memberName, amount }) => {
|
||||
store.upsertCreditLine({
|
||||
uid: memberUid,
|
||||
member: memberName,
|
||||
amount,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## List Reconciliation
|
||||
|
||||
To make targeted updates real, list rendering must be keyed.
|
||||
|
||||
Requirement:
|
||||
|
||||
- every repeated domain item must have a stable key
|
||||
|
||||
Examples:
|
||||
|
||||
- members use `uid`
|
||||
- credit lines use `uid`
|
||||
- assets use `className` or inventory id
|
||||
- fleet entries use vehicle id
|
||||
|
||||
Without keyed reconciliation, a list change still forces the entire list DOM to be replaced.
|
||||
|
||||
## Org UI Example
|
||||
|
||||
Using the current organization portal as the model:
|
||||
|
||||
### `MembersCard`
|
||||
|
||||
Depends on:
|
||||
|
||||
- `store.roster().members`
|
||||
- membership permission selectors
|
||||
|
||||
Should update when:
|
||||
|
||||
- a member is added
|
||||
- a member is removed
|
||||
- a member name or role changes
|
||||
|
||||
Should not update when:
|
||||
|
||||
- treasury funds change
|
||||
- a modal opens
|
||||
- a fleet item changes
|
||||
|
||||
### `TreasuryCard`
|
||||
|
||||
Depends on:
|
||||
|
||||
- `store.treasury().funds`
|
||||
- `store.treasury().creditLines`
|
||||
- treasury permissions
|
||||
- `store.ui().treasuryTab`
|
||||
|
||||
Should update when:
|
||||
|
||||
- funds change
|
||||
- a credit line is added or updated
|
||||
- the user changes treasury tab
|
||||
|
||||
Should not update when:
|
||||
|
||||
- member roster changes unrelated to treasury display
|
||||
- fleet changes
|
||||
|
||||
### `ModalLayer`
|
||||
|
||||
Depends on:
|
||||
|
||||
- `store.ui().modal`
|
||||
|
||||
Should update when:
|
||||
|
||||
- a modal opens
|
||||
- a modal closes
|
||||
- modal payload changes
|
||||
|
||||
Should not update when unrelated domain state changes.
|
||||
|
||||
## Mutation Examples
|
||||
|
||||
### Add member
|
||||
|
||||
```js
|
||||
addMember(member) {
|
||||
this.roster.update((state) => ({
|
||||
...state,
|
||||
members: [...state.members, member],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Only subscribers to `roster` rerender.
|
||||
|
||||
### Update credit line
|
||||
|
||||
```js
|
||||
upsertCreditLine(nextLine) {
|
||||
this.treasury.update((state) => {
|
||||
const exists = state.creditLines.some((line) => line.uid === nextLine.uid);
|
||||
|
||||
return {
|
||||
...state,
|
||||
creditLines: exists
|
||||
? state.creditLines.map((line) =>
|
||||
line.uid === nextLine.uid ? nextLine : line
|
||||
)
|
||||
: [...state.creditLines, nextLine],
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Only subscribers to `treasury` rerender.
|
||||
|
||||
## Bridge Response Strategy
|
||||
|
||||
For responsive UIs, each server-backed action should define:
|
||||
|
||||
- request event
|
||||
- success patch event
|
||||
- failure notice event or payload
|
||||
|
||||
Example credit line flow:
|
||||
|
||||
1. JS sends `org::credit::request`
|
||||
2. SQF/server validates and persists
|
||||
3. SQF sends:
|
||||
- `org::member::creditUpdated` on success
|
||||
- `org::credit::failure` on failure
|
||||
4. JS store applies a targeted patch
|
||||
5. `TreasuryCard` and any dependent member row update
|
||||
|
||||
This is preferable to sending a full `org::sync` after every action.
|
||||
|
||||
## Shared Components
|
||||
|
||||
The common addon should provide plain, themeable primitives only.
|
||||
|
||||
Recommended first set:
|
||||
|
||||
- app shell
|
||||
- title bar
|
||||
- navbar
|
||||
- modal
|
||||
- notice/toast
|
||||
- stat card
|
||||
- empty state
|
||||
- action row
|
||||
- form field
|
||||
- spinner
|
||||
- error banner
|
||||
|
||||
These should accept data and callbacks, not own business logic.
|
||||
|
||||
## Styling Model
|
||||
|
||||
Use layered CSS:
|
||||
|
||||
1. common tokens
|
||||
2. common primitives
|
||||
3. feature theme
|
||||
4. feature view styles
|
||||
|
||||
The common layer should define:
|
||||
|
||||
- spacing scale
|
||||
- type scale
|
||||
- colors
|
||||
- elevation/shadows
|
||||
- radius
|
||||
- focus states
|
||||
- motion durations
|
||||
|
||||
Feature UIs should override tokens rather than rewriting primitive CSS.
|
||||
|
||||
## Asset Loading
|
||||
|
||||
The loader should support:
|
||||
|
||||
- `A3API.RequestFile`
|
||||
- `A3API.RequestTexture`
|
||||
- local `fetch()` fallback for browser testing
|
||||
|
||||
Recommended change:
|
||||
|
||||
- stop loading many small scripts individually in production
|
||||
- build one common runtime file and one feature app file
|
||||
- keep source files split in repo, but ship bundled outputs into `_site`
|
||||
|
||||
That reduces browser startup cost and simplifies ordering problems.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The framework should standardize:
|
||||
|
||||
- bridge unavailable errors
|
||||
- malformed payload errors
|
||||
- timeout handling for requests that expect responses
|
||||
- visible in-UI notices for recoverable failures
|
||||
- `console.error` plus `diag_log` friendly payloads
|
||||
|
||||
Recommended bridge helper:
|
||||
|
||||
```js
|
||||
bridge.request("store::checkout::request", payload, {
|
||||
pending: "Submitting order...",
|
||||
timeoutMs: 15000,
|
||||
onTimeout() {
|
||||
notices.error("The checkout request timed out.");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1
|
||||
|
||||
Extract common pieces without changing app behavior:
|
||||
|
||||
- shared JS host adapter
|
||||
- shared JS bridge
|
||||
- shared signal/runtime
|
||||
- shared SQF bridge base class
|
||||
|
||||
### Phase 2
|
||||
|
||||
Migrate `org` and `store` first because they already use the same custom runtime pattern.
|
||||
|
||||
### Phase 3
|
||||
|
||||
Migrate `bank`, `garage`, and `notifications`.
|
||||
|
||||
### Phase 4
|
||||
|
||||
Migrate `actor`, which may need more event-heavy interaction handling.
|
||||
|
||||
### Phase 5
|
||||
|
||||
Bundle all `_site` apps into production-ready outputs.
|
||||
|
||||
## First Implementation Targets
|
||||
|
||||
The first concrete files to build should be:
|
||||
|
||||
1. `arma/client/addons/common/ui/src/host.js`
|
||||
2. `arma/client/addons/common/ui/src/runtime.js`
|
||||
3. `arma/client/addons/common/ui/src/bridge.js`
|
||||
4. `arma/client/addons/common/ui/src/app.js`
|
||||
5. `arma/client/addons/common/functions/fnc_initWebUIBridge.sqf`
|
||||
|
||||
Those five pieces establish the core contract. After that, `org` and `store` can be migrated with low risk.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
At least initially, this framework should not try to provide:
|
||||
|
||||
- client-side routing between pages
|
||||
- SSR or pre-rendering
|
||||
- JSX compilation
|
||||
- TypeScript-only tooling assumptions
|
||||
- a giant component system
|
||||
- generalized diffing for every possible DOM edge case
|
||||
|
||||
This should stay focused on Arma in-browser application UIs.
|
||||
|
||||
## Recommended Direction
|
||||
|
||||
Use `forge_client_common` as the host for a small custom reactive framework, not as a dumping ground for copied app utilities.
|
||||
|
||||
The correct abstraction boundary is:
|
||||
|
||||
- `common` owns the browser platform
|
||||
- each addon owns the application
|
||||
|
||||
That gives one UI system across the repo without forcing all screens into one monolithic app.
|
||||
@ -1 +1,2 @@
|
||||
|
||||
PREP(initWebUIBridge);
|
||||
|
||||
209
arma/client/addons/common/functions/fnc_initWebUIBridge.sqf
Normal file
209
arma/client/addons/common/functions/fnc_initWebUIBridge.sqf
Normal file
@ -0,0 +1,209 @@
|
||||
#include "..\script_component.hpp"
|
||||
|
||||
/*
|
||||
* File: fnc_initWebUIBridge.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-13
|
||||
* Last Update: 2026-03-13
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* Initializes the shared web UI bridge and screen declarations used by
|
||||
* CT_WEBBROWSER feature bridges.
|
||||
*
|
||||
* Arguments:
|
||||
* None
|
||||
*
|
||||
* Return Value:
|
||||
* Web UI bridge declarations [HASHMAP]
|
||||
*
|
||||
* Example:
|
||||
* call forge_client_common_fnc_initWebUIBridge
|
||||
*/
|
||||
|
||||
if !(isNil QGVAR(WebUIScreenDeclaration) || { isNil QGVAR(WebUIBridgeDeclaration) }) exitWith {
|
||||
createHashMapFromArray [
|
||||
["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)],
|
||||
["screenDeclaration", GVAR(WebUIScreenDeclaration)]
|
||||
]
|
||||
};
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(WebUIScreenDeclaration) = compileFinal createHashMapFromArray [
|
||||
["#type", "IWebUIScreen"],
|
||||
["#create", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
_self set ["control", _control];
|
||||
_self set ["readyState", false];
|
||||
_self set ["pendingEvents", []];
|
||||
}],
|
||||
["dispose", compileFinal {
|
||||
_self set ["control", controlNull];
|
||||
_self set ["readyState", false];
|
||||
_self set ["pendingEvents", []];
|
||||
|
||||
true
|
||||
}],
|
||||
["getControl", compileFinal {
|
||||
_self getOrDefault ["control", controlNull]
|
||||
}],
|
||||
["consumePendingEvents", compileFinal {
|
||||
private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]);
|
||||
_self set ["pendingEvents", []];
|
||||
|
||||
_pendingEvents
|
||||
}],
|
||||
["isReady", compileFinal {
|
||||
_self getOrDefault ["readyState", false]
|
||||
}],
|
||||
["markReady", compileFinal {
|
||||
params [["_isReady", true, [false]]];
|
||||
|
||||
_self set ["readyState", _isReady];
|
||||
_isReady
|
||||
}],
|
||||
["queueEvent", compileFinal {
|
||||
params [["_payload", createHashMap, [createHashMap]]];
|
||||
|
||||
private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]);
|
||||
_pendingEvents pushBack _payload;
|
||||
_self set ["pendingEvents", _pendingEvents];
|
||||
|
||||
count _pendingEvents
|
||||
}],
|
||||
["setControl", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
_self set ["control", _control];
|
||||
_control
|
||||
}],
|
||||
["#delete", compileFinal {
|
||||
_self call ["dispose", []];
|
||||
}]
|
||||
];
|
||||
|
||||
GVAR(WebUIBridgeDeclaration) = compileFinal createHashMapFromArray [
|
||||
["#type", "IWebUIBridge"],
|
||||
["#create", compileFinal {
|
||||
_self set ["screen", createHashMapObject [GVAR(WebUIScreenDeclaration)]];
|
||||
}],
|
||||
["deliverPayload", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]], ["_payload", createHashMap, [createHashMap]]];
|
||||
|
||||
if (isNull _control) exitWith { false };
|
||||
|
||||
private _json = toJSON _payload;
|
||||
_control ctrlWebBrowserAction ["ExecJS", format ["ForgeBridge.receive(%1)", _json]];
|
||||
|
||||
true
|
||||
}],
|
||||
["execJS", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]], ["_statement", "", [""]]];
|
||||
|
||||
if (isNull _control || { _statement isEqualTo "" }) exitWith { false };
|
||||
|
||||
_control ctrlWebBrowserAction ["ExecJS", _statement];
|
||||
true
|
||||
}],
|
||||
["flushPendingEvents", compileFinal {
|
||||
private _screen = _self call ["getScreen", []];
|
||||
private _control = _self call ["getActiveBrowserControl", []];
|
||||
if (isNull _control) exitWith { 0 };
|
||||
|
||||
private _pendingEvents = _screen call ["consumePendingEvents", []];
|
||||
|
||||
{
|
||||
_self call ["deliverPayload", [_control, _x]];
|
||||
} forEach _pendingEvents;
|
||||
|
||||
count _pendingEvents
|
||||
}],
|
||||
["getActiveBrowserControl", compileFinal {
|
||||
private _screen = _self call ["getScreen", []];
|
||||
_screen call ["getControl", []]
|
||||
}],
|
||||
["getScreen", compileFinal {
|
||||
private _hasScreen = "screen" in _self;
|
||||
private _screen = if (_hasScreen) then {
|
||||
_self get "screen"
|
||||
} else {
|
||||
createHashMap
|
||||
};
|
||||
|
||||
if (!_hasScreen) then {
|
||||
_screen = createHashMapObject [GVAR(WebUIScreenDeclaration)];
|
||||
_self set ["screen", _screen];
|
||||
};
|
||||
|
||||
_screen
|
||||
}],
|
||||
["handleClose", compileFinal {
|
||||
private _screen = _self call ["getScreen", []];
|
||||
_screen call ["dispose", []]
|
||||
}],
|
||||
["handleReady", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]];
|
||||
|
||||
private _screen = _self call ["getScreen", []];
|
||||
_screen call ["setControl", [_control]];
|
||||
_screen call ["markReady", [true]];
|
||||
|
||||
_self call ["flushPendingEvents", []];
|
||||
true
|
||||
}],
|
||||
["queueEvent", compileFinal {
|
||||
params [["_payload", createHashMap, [createHashMap]]];
|
||||
|
||||
private _screen = _self call ["getScreen", []];
|
||||
_screen call ["queueEvent", [_payload]]
|
||||
}],
|
||||
["sendEvent", compileFinal {
|
||||
params [
|
||||
["_event", "", [""]],
|
||||
["_data", createHashMap, [createHashMap]],
|
||||
["_control", controlNull, [controlNull]]
|
||||
];
|
||||
|
||||
if (_event isEqualTo "") exitWith { false };
|
||||
|
||||
private _payload = createHashMapFromArray [
|
||||
["event", _event],
|
||||
["data", _data]
|
||||
];
|
||||
private _screen = _self call ["getScreen", []];
|
||||
private _targetControl = _control;
|
||||
|
||||
if (isNull _targetControl) then {
|
||||
_targetControl = _self call ["getActiveBrowserControl", []];
|
||||
};
|
||||
|
||||
if (isNull _targetControl) exitWith {
|
||||
_self call ["queueEvent", [_payload]];
|
||||
false
|
||||
};
|
||||
|
||||
_screen call ["setControl", [_targetControl]];
|
||||
|
||||
if !(_screen call ["isReady", []]) exitWith {
|
||||
_self call ["queueEvent", [_payload]];
|
||||
false
|
||||
};
|
||||
|
||||
_self call ["deliverPayload", [_targetControl, _payload]]
|
||||
}],
|
||||
["setActiveBrowserControl", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
private _screen = _self call ["getScreen", []];
|
||||
_screen call ["setControl", [_control]]
|
||||
}],
|
||||
["#delete", compileFinal {
|
||||
_self call ["handleClose", []];
|
||||
}]
|
||||
];
|
||||
|
||||
createHashMapFromArray [
|
||||
["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)],
|
||||
["screenDeclaration", GVAR(WebUIScreenDeclaration)]
|
||||
]
|
||||
127
arma/client/addons/common/ui/_site/forge-site-loader.js
Normal file
127
arma/client/addons/common/ui/_site/forge-site-loader.js
Normal file
@ -0,0 +1,127 @@
|
||||
/* Generated by tools/build-webui.mjs for Forge Web UI site loader. Do not edit directly. */
|
||||
(function (global) {
|
||||
const ForgeSiteLoader = (global.ForgeSiteLoader =
|
||||
global.ForgeSiteLoader || {});
|
||||
const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\";
|
||||
const defaultBrowserCommonBase = "../../../common/ui/_site/";
|
||||
|
||||
function isArmaAvailable() {
|
||||
return (
|
||||
typeof A3API !== "undefined" &&
|
||||
A3API &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function isAbsoluteAddonPath(path) {
|
||||
return typeof path === "string" && path.startsWith("forge\\");
|
||||
}
|
||||
|
||||
function normalizeAddonRoot(addonName) {
|
||||
return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`;
|
||||
}
|
||||
|
||||
function normalizeBrowserPath(basePath, assetPath) {
|
||||
const normalizedBase = String(basePath || "./").replace(/\\/g, "/");
|
||||
const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/");
|
||||
return `${normalizedBase}${normalizedAssetPath}`;
|
||||
}
|
||||
|
||||
function requestText({ addonRoot, browserBase, assetPath }) {
|
||||
if (isArmaAvailable()) {
|
||||
const resolvedPath = isAbsoluteAddonPath(assetPath)
|
||||
? assetPath
|
||||
: addonRoot + String(assetPath || "").replace(/\//g, "\\");
|
||||
return A3API.RequestFile(resolvedPath);
|
||||
}
|
||||
|
||||
const browserPath = isAbsoluteAddonPath(assetPath)
|
||||
? assetPath
|
||||
: normalizeBrowserPath(browserBase, assetPath);
|
||||
|
||||
return fetch(browserPath).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${browserPath}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
function appendStyle(cssText) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function appendScript(jsText) {
|
||||
const script = document.createElement("script");
|
||||
script.text = jsText;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
async function boot(config) {
|
||||
const addonName = config && config.addonName ? config.addonName : "";
|
||||
|
||||
if (!addonName) {
|
||||
throw new Error(
|
||||
"ForgeSiteLoader requires a config.addonName value.",
|
||||
);
|
||||
}
|
||||
|
||||
const addonRoot = normalizeAddonRoot(addonName);
|
||||
const browserAddonBase = config.browserAddonBase || "./";
|
||||
const browserCommonBase =
|
||||
config.browserCommonBase || defaultBrowserCommonBase;
|
||||
const styles = Array.isArray(config.styles) ? config.styles : [];
|
||||
const commonScripts = Array.isArray(config.commonScripts)
|
||||
? config.commonScripts
|
||||
: [];
|
||||
const scripts = Array.isArray(config.scripts) ? config.scripts : [];
|
||||
|
||||
const styleChunks = await Promise.all(
|
||||
styles.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot,
|
||||
browserBase: browserAddonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
styleChunks.forEach(appendStyle);
|
||||
|
||||
const commonScriptChunks = await Promise.all(
|
||||
commonScripts.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot: commonAddonRoot,
|
||||
browserBase: browserCommonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
commonScriptChunks.forEach(appendScript);
|
||||
|
||||
const scriptChunks = await Promise.all(
|
||||
scripts.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot,
|
||||
browserBase: browserAddonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
scriptChunks.forEach(appendScript);
|
||||
}
|
||||
|
||||
ForgeSiteLoader.boot = boot;
|
||||
|
||||
if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) {
|
||||
boot(global.ForgeSiteConfig).catch((error) => {
|
||||
const logLabel =
|
||||
global.ForgeSiteConfig.logLabel ||
|
||||
global.ForgeSiteConfig.addonName ||
|
||||
"Forge UI";
|
||||
console.error(`[${logLabel}] Failed to load site assets.`, error);
|
||||
});
|
||||
}
|
||||
})(window);
|
||||
933
arma/client/addons/common/ui/_site/forge-webui.js
Normal file
933
arma/client/addons/common/ui/_site/forge-webui.js
Normal file
@ -0,0 +1,933 @@
|
||||
/* Generated by tools/build-webui.mjs for Forge Web UI runtime. Do not edit directly. */
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SVG_TAGS = new Set([
|
||||
"svg",
|
||||
"path",
|
||||
"circle",
|
||||
"rect",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"g",
|
||||
"defs",
|
||||
"use",
|
||||
"text",
|
||||
"tspan",
|
||||
"clipPath",
|
||||
"mask",
|
||||
]);
|
||||
|
||||
const injectedStyles = new Set();
|
||||
const scheduledObservers = new Set();
|
||||
let activeObserver = null;
|
||||
let batchDepth = 0;
|
||||
let flushQueued = false;
|
||||
|
||||
function queueFlush() {
|
||||
if (flushQueued || batchDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushQueued = true;
|
||||
queueMicrotask(() => {
|
||||
flushQueued = false;
|
||||
flushObservers();
|
||||
});
|
||||
}
|
||||
|
||||
function flushObservers() {
|
||||
while (scheduledObservers.size > 0) {
|
||||
const queue = Array.from(scheduledObservers);
|
||||
scheduledObservers.clear();
|
||||
queue.forEach((observer) => runObserver(observer));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupObserver(observer) {
|
||||
if (typeof observer.cleanup === "function") {
|
||||
try {
|
||||
observer.cleanup();
|
||||
} catch (error) {
|
||||
console.error("[ForgeWebUI] Observer cleanup failed.", error);
|
||||
}
|
||||
}
|
||||
|
||||
observer.cleanup = null;
|
||||
observer.dependencies.forEach((dependency) => {
|
||||
dependency.delete(observer);
|
||||
});
|
||||
observer.dependencies.clear();
|
||||
}
|
||||
|
||||
function runObserver(observer) {
|
||||
if (!observer || observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupObserver(observer);
|
||||
|
||||
const previousObserver = activeObserver;
|
||||
activeObserver = observer;
|
||||
|
||||
try {
|
||||
const cleanup = observer.fn();
|
||||
if (typeof cleanup === "function") {
|
||||
observer.cleanup = cleanup;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ForgeWebUI] Observer execution failed.", error);
|
||||
} finally {
|
||||
activeObserver = previousObserver;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleObserver(observer) {
|
||||
if (!observer || observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledObservers.add(observer);
|
||||
queueFlush();
|
||||
}
|
||||
|
||||
function trackDependency(dependency) {
|
||||
if (!activeObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
dependency.add(activeObserver);
|
||||
activeObserver.dependencies.add(dependency);
|
||||
}
|
||||
|
||||
function createSignalValue(initialValue) {
|
||||
let value = initialValue;
|
||||
const subscribers = new Set();
|
||||
|
||||
function read() {
|
||||
trackDependency(subscribers);
|
||||
return value;
|
||||
}
|
||||
|
||||
read.peek = () => value;
|
||||
read.set = (nextValue) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function" ? nextValue(value) : nextValue;
|
||||
|
||||
if (Object.is(resolvedValue, value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value = resolvedValue;
|
||||
subscribers.forEach((observer) => scheduleObserver(observer));
|
||||
return value;
|
||||
};
|
||||
read.update = (updater) => read.set(updater);
|
||||
read.subscribe = (listener) =>
|
||||
effect(() => {
|
||||
listener(read());
|
||||
});
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
function createSignal(initialValue) {
|
||||
const signal = createSignalValue(initialValue);
|
||||
return [signal, signal.set];
|
||||
}
|
||||
|
||||
function computed(factory) {
|
||||
const valueSignal = createSignalValue(undefined);
|
||||
let initialized = false;
|
||||
|
||||
effect(() => {
|
||||
const nextValue = factory();
|
||||
if (!initialized || !Object.is(nextValue, valueSignal.peek())) {
|
||||
initialized = true;
|
||||
valueSignal.set(nextValue);
|
||||
}
|
||||
});
|
||||
|
||||
return valueSignal;
|
||||
}
|
||||
|
||||
function effect(fn) {
|
||||
const observer = {
|
||||
cleanup: null,
|
||||
dependencies: new Set(),
|
||||
disposed: false,
|
||||
fn,
|
||||
};
|
||||
|
||||
observer.dispose = () => {
|
||||
if (observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.disposed = true;
|
||||
scheduledObservers.delete(observer);
|
||||
cleanupObserver(observer);
|
||||
};
|
||||
|
||||
runObserver(observer);
|
||||
return observer.dispose;
|
||||
}
|
||||
|
||||
function batch(fn) {
|
||||
batchDepth += 1;
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
batchDepth = Math.max(0, batchDepth - 1);
|
||||
if (batchDepth === 0) {
|
||||
flushObservers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendChild(node, child) {
|
||||
if (child === null || child === undefined || child === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry) => appendChild(node, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof child === "string" ||
|
||||
typeof child === "number" ||
|
||||
typeof child === "bigint"
|
||||
) {
|
||||
node.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof Node) {
|
||||
node.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function fragment(...children) {
|
||||
const node = document.createDocumentFragment();
|
||||
children.forEach((child) => appendChild(node, child));
|
||||
return node;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return document.createTextNode(String(value ?? ""));
|
||||
}
|
||||
|
||||
function applyProp(node, key, value, isSvg) {
|
||||
if (key === "key") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ref" && typeof value === "function") {
|
||||
value(node);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "className") {
|
||||
if (isSvg) {
|
||||
node.setAttribute("class", value || "");
|
||||
} else {
|
||||
node.className = value || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "style" && value && typeof value === "object") {
|
||||
Object.assign(node.style, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "dataset" && value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([name, datasetValue]) => {
|
||||
node.dataset[name] = datasetValue;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "value" && "value" in node) {
|
||||
node.value = value ?? "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "checked" && "checked" in node) {
|
||||
node.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "selected" && "selected" in node) {
|
||||
node.selected = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value) {
|
||||
node.setAttribute(key, "");
|
||||
} else {
|
||||
node.removeAttribute(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
node.removeAttribute(key);
|
||||
return;
|
||||
}
|
||||
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const isSvg = SVG_TAGS.has(tag);
|
||||
const node = isSvg
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
if (props && typeof props === "object") {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
applyProp(node, key, value, isSvg);
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach((child) => appendChild(node, child));
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeNode(node) {
|
||||
if (node === null || node === undefined || node === false) {
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return fragment(...node);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof node === "string" ||
|
||||
typeof node === "number" ||
|
||||
typeof node === "bigint"
|
||||
) {
|
||||
return text(node);
|
||||
}
|
||||
|
||||
if (node instanceof Node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
function captureScrollState(container) {
|
||||
return Array.from(
|
||||
container.querySelectorAll("[data-preserve-scroll-id]"),
|
||||
).map((node) => ({
|
||||
id: node.getAttribute("data-preserve-scroll-id"),
|
||||
scrollLeft: node.scrollLeft,
|
||||
scrollTop: node.scrollTop,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreScrollState(container, scrollState) {
|
||||
if (!Array.isArray(scrollState) || scrollState.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollState.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = container.querySelector(
|
||||
`[data-preserve-scroll-id="${entry.id}"]`,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollTop = Number(entry.scrollTop || 0);
|
||||
target.scrollLeft = Number(entry.scrollLeft || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function mount(container, render, options = {}) {
|
||||
const preserveScroll = options.preserveScroll !== false;
|
||||
|
||||
const dispose = effect(() => {
|
||||
const scrollState = preserveScroll
|
||||
? captureScrollState(container)
|
||||
: [];
|
||||
const nextNode = normalizeNode(render());
|
||||
|
||||
container.replaceChildren(nextNode);
|
||||
|
||||
if (preserveScroll && scrollState.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollState(container, scrollState);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
container,
|
||||
dispose,
|
||||
rerender() {
|
||||
container.replaceChildren(normalizeNode(render()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function render(component, container, options = {}) {
|
||||
return mount(container, component, options);
|
||||
}
|
||||
|
||||
function unmount(mountHandle) {
|
||||
if (!mountHandle || typeof mountHandle.dispose !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
mountHandle.dispose();
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
if (!id || !cssText || injectedStyles.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-ui-style", id);
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
injectedStyles.add(id);
|
||||
}
|
||||
|
||||
ForgeWebUI.batch = batch;
|
||||
ForgeWebUI.computed = computed;
|
||||
ForgeWebUI.createSignal = createSignal;
|
||||
ForgeWebUI.effect = effect;
|
||||
ForgeWebUI.ensureScopedStyle = ensureScopedStyle;
|
||||
ForgeWebUI.fragment = fragment;
|
||||
ForgeWebUI.h = h;
|
||||
ForgeWebUI.mount = mount;
|
||||
ForgeWebUI.render = render;
|
||||
ForgeWebUI.signal = createSignalValue;
|
||||
ForgeWebUI.text = text;
|
||||
ForgeWebUI.unmount = unmount;
|
||||
})(window);
|
||||
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function createHost() {
|
||||
const api = global.A3API;
|
||||
|
||||
return {
|
||||
isArma: Boolean(api),
|
||||
close(event = "ui::close", data = {}) {
|
||||
return this.send(event, data);
|
||||
},
|
||||
exec(statement) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.Exec !== "function" ||
|
||||
typeof statement !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api.Exec(statement);
|
||||
return true;
|
||||
},
|
||||
requestFile(path) {
|
||||
if (api && typeof api.RequestFile === "function") {
|
||||
return api.RequestFile(path);
|
||||
}
|
||||
|
||||
return fetch(path).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${path}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
},
|
||||
requestTexture(path, size = 512) {
|
||||
if (api && typeof api.RequestTexture === "function") {
|
||||
return api.RequestTexture(path, size);
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new Error("Texture requests are unavailable outside Arma."),
|
||||
);
|
||||
},
|
||||
send(event, data = {}) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.SendAlert !== "function" ||
|
||||
typeof event !== "string" ||
|
||||
event === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ForgeWebUI.createHost = createHost;
|
||||
})(window);
|
||||
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function createBridge(options = {}) {
|
||||
const host =
|
||||
options.host && typeof options.host === "object"
|
||||
? options.host
|
||||
: ForgeWebUI.createHost();
|
||||
const globalName = options.globalName || "ForgeBridge";
|
||||
const readyEvent = options.readyEvent || "ui::ready";
|
||||
const closeEvent = options.closeEvent || "ui::close";
|
||||
const listeners = new Map();
|
||||
|
||||
function getListeners(eventName) {
|
||||
if (!listeners.has(eventName)) {
|
||||
listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
return listeners.get(eventName);
|
||||
}
|
||||
|
||||
function emit(eventName, payload) {
|
||||
const eventListeners = listeners.get(eventName);
|
||||
if (!eventListeners || eventListeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(payload);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ForgeWebUI] Bridge listener failed for ${eventName}.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function receive(eventOrPayload, data = {}) {
|
||||
const eventName =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? String(eventOrPayload.event || "")
|
||||
: String(eventOrPayload || "");
|
||||
const payload =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.data || {}
|
||||
: data;
|
||||
|
||||
if (!eventName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(eventName, payload);
|
||||
emit("*", { data: payload, event: eventName });
|
||||
return true;
|
||||
}
|
||||
|
||||
function receiveMany(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
events.forEach((payload) => receive(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
const globalBridge = {
|
||||
ping() {
|
||||
return true;
|
||||
},
|
||||
receive,
|
||||
receiveMany,
|
||||
reset() {
|
||||
listeners.clear();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const api = {
|
||||
close(data = {}) {
|
||||
return host.send(closeEvent, data);
|
||||
},
|
||||
emit,
|
||||
host,
|
||||
installCompatibility(name) {
|
||||
if (name) {
|
||||
global[name] = globalBridge;
|
||||
}
|
||||
|
||||
return api;
|
||||
},
|
||||
off(eventName, listener) {
|
||||
const eventListeners = listeners.get(eventName);
|
||||
if (!eventListeners) {
|
||||
return false;
|
||||
}
|
||||
|
||||
eventListeners.delete(listener);
|
||||
if (eventListeners.size === 0) {
|
||||
listeners.delete(eventName);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
on(eventName, listener) {
|
||||
getListeners(eventName).add(listener);
|
||||
return () => api.off(eventName, listener);
|
||||
},
|
||||
ready(data = { loaded: true }) {
|
||||
return host.send(readyEvent, data);
|
||||
},
|
||||
receive,
|
||||
receiveMany,
|
||||
request(eventName, payload = {}) {
|
||||
return host.send(eventName, payload);
|
||||
},
|
||||
send(eventName, payload = {}) {
|
||||
return host.send(eventName, payload);
|
||||
},
|
||||
};
|
||||
|
||||
global[globalName] = globalBridge;
|
||||
return api;
|
||||
}
|
||||
|
||||
ForgeWebUI.createBridge = createBridge;
|
||||
})(window);
|
||||
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function resolveRoot(root) {
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof root === "string") {
|
||||
return document.querySelector(root);
|
||||
}
|
||||
|
||||
return root instanceof Element ? root : null;
|
||||
}
|
||||
|
||||
function createApp(options = {}) {
|
||||
const name = options.name || "app";
|
||||
const root = options.root || "#app";
|
||||
const setup =
|
||||
typeof options.setup === "function" ? options.setup : () => {};
|
||||
let started = false;
|
||||
|
||||
function start() {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
const boot = () => {
|
||||
const rootNode = resolveRoot(root);
|
||||
if (!rootNode) {
|
||||
console.error(
|
||||
`[ForgeWebUI] Root node not found for ${name}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setup({
|
||||
name,
|
||||
root: rootNode,
|
||||
runtime: ForgeWebUI,
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
boot();
|
||||
}
|
||||
|
||||
return { start };
|
||||
}
|
||||
|
||||
ForgeWebUI.createApp = createApp;
|
||||
})(window);
|
||||
|
||||
(function (global) {
|
||||
const ForgeWebUI = global.ForgeWebUI;
|
||||
const SharedUI = (global.SharedUI = global.SharedUI || {});
|
||||
const { h, ensureScopedStyle } = ForgeWebUI;
|
||||
const titleBarCss = `
|
||||
.ui-window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: var(--ui-titlebar-min-height, 3.5rem);
|
||||
padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem);
|
||||
background: var(
|
||||
--ui-titlebar-bg,
|
||||
linear-gradient(180deg, #12325b 0%, #0d2643 100%)
|
||||
);
|
||||
color: var(--ui-titlebar-text, #f4f8fd);
|
||||
border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1));
|
||||
box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18));
|
||||
position: var(--ui-titlebar-position, relative);
|
||||
top: var(--ui-titlebar-top, auto);
|
||||
z-index: var(--ui-titlebar-z-index, 5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-kicker {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72));
|
||||
}
|
||||
|
||||
.ui-window-titlebar-title {
|
||||
font-size: var(--ui-titlebar-title-size, 1rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: var(--ui-titlebar-title-spacing, -0.03em);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.ui-window-control-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0.38rem;
|
||||
border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16));
|
||||
background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04));
|
||||
color: var(--ui-window-control-text, rgb(237 244 251 / 0.88));
|
||||
line-height: 1;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-window-control-btn + .ui-window-control-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ui-window-control-btn:hover {
|
||||
background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04));
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ui-window-control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-window-control-btn.is-close {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1));
|
||||
}
|
||||
|
||||
.ui-window-control-btn.is-close:hover {
|
||||
background: var(
|
||||
--ui-window-control-close-hover-bg,
|
||||
rgb(185 67 67 / 0.9)
|
||||
);
|
||||
border-color: var(
|
||||
--ui-window-control-close-hover-border,
|
||||
rgb(255 222 222 / 0.45)
|
||||
);
|
||||
}
|
||||
|
||||
.ui-window-control-icon {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ui-window-titlebar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
SharedUI.componentFns = SharedUI.componentFns || {};
|
||||
|
||||
function WindowControlIcon({ type }) {
|
||||
if (type === "minimize") {
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "maximize") {
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }),
|
||||
);
|
||||
}
|
||||
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }),
|
||||
h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }),
|
||||
);
|
||||
}
|
||||
|
||||
SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({
|
||||
kicker = "",
|
||||
title = "",
|
||||
onClose = null,
|
||||
closeLabel = "Close interface",
|
||||
minimizeLabel = "Minimize unavailable",
|
||||
maximizeLabel = "Maximize unavailable",
|
||||
} = {}) {
|
||||
ensureScopedStyle("shared-window-titlebar", titleBarCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar-brand" },
|
||||
kicker
|
||||
? h(
|
||||
"span",
|
||||
{ className: "ui-window-titlebar-kicker" },
|
||||
kicker,
|
||||
)
|
||||
: null,
|
||||
h("span", { className: "ui-window-titlebar-title" }, title),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn",
|
||||
disabled: true,
|
||||
title: minimizeLabel,
|
||||
"aria-label": minimizeLabel,
|
||||
},
|
||||
WindowControlIcon({ type: "minimize" }),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn",
|
||||
disabled: true,
|
||||
title: maximizeLabel,
|
||||
"aria-label": maximizeLabel,
|
||||
},
|
||||
WindowControlIcon({ type: "maximize" }),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn is-close",
|
||||
title: "Close",
|
||||
"aria-label": closeLabel,
|
||||
onClick:
|
||||
typeof onClose === "function" ? onClose : () => {},
|
||||
},
|
||||
WindowControlIcon({ type: "close" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
})(window);
|
||||
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
ForgeWebUI.version = "0.1.0";
|
||||
})(window);
|
||||
60
arma/client/addons/common/ui/src/app.js
Normal file
60
arma/client/addons/common/ui/src/app.js
Normal file
@ -0,0 +1,60 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function resolveRoot(root) {
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof root === "string") {
|
||||
return document.querySelector(root);
|
||||
}
|
||||
|
||||
return root instanceof Element ? root : null;
|
||||
}
|
||||
|
||||
function createApp(options = {}) {
|
||||
const name = options.name || "app";
|
||||
const root = options.root || "#app";
|
||||
const setup =
|
||||
typeof options.setup === "function" ? options.setup : () => {};
|
||||
let started = false;
|
||||
|
||||
function start() {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
const boot = () => {
|
||||
const rootNode = resolveRoot(root);
|
||||
if (!rootNode) {
|
||||
console.error(
|
||||
`[ForgeWebUI] Root node not found for ${name}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setup({
|
||||
name,
|
||||
root: rootNode,
|
||||
runtime: ForgeWebUI,
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
boot();
|
||||
}
|
||||
|
||||
return { start };
|
||||
}
|
||||
|
||||
ForgeWebUI.createApp = createApp;
|
||||
})(window);
|
||||
128
arma/client/addons/common/ui/src/bridge.js
Normal file
128
arma/client/addons/common/ui/src/bridge.js
Normal file
@ -0,0 +1,128 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function createBridge(options = {}) {
|
||||
const host =
|
||||
options.host && typeof options.host === "object"
|
||||
? options.host
|
||||
: ForgeWebUI.createHost();
|
||||
const globalName = options.globalName || "ForgeBridge";
|
||||
const readyEvent = options.readyEvent || "ui::ready";
|
||||
const closeEvent = options.closeEvent || "ui::close";
|
||||
const listeners = new Map();
|
||||
|
||||
function getListeners(eventName) {
|
||||
if (!listeners.has(eventName)) {
|
||||
listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
return listeners.get(eventName);
|
||||
}
|
||||
|
||||
function emit(eventName, payload) {
|
||||
const eventListeners = listeners.get(eventName);
|
||||
if (!eventListeners || eventListeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(payload);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ForgeWebUI] Bridge listener failed for ${eventName}.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function receive(eventOrPayload, data = {}) {
|
||||
const eventName =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? String(eventOrPayload.event || "")
|
||||
: String(eventOrPayload || "");
|
||||
const payload =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.data || {}
|
||||
: data;
|
||||
|
||||
if (!eventName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(eventName, payload);
|
||||
emit("*", { data: payload, event: eventName });
|
||||
return true;
|
||||
}
|
||||
|
||||
function receiveMany(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
events.forEach((payload) => receive(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
const globalBridge = {
|
||||
ping() {
|
||||
return true;
|
||||
},
|
||||
receive,
|
||||
receiveMany,
|
||||
reset() {
|
||||
listeners.clear();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const api = {
|
||||
close(data = {}) {
|
||||
return host.send(closeEvent, data);
|
||||
},
|
||||
emit,
|
||||
host,
|
||||
installCompatibility(name) {
|
||||
if (name) {
|
||||
global[name] = globalBridge;
|
||||
}
|
||||
|
||||
return api;
|
||||
},
|
||||
off(eventName, listener) {
|
||||
const eventListeners = listeners.get(eventName);
|
||||
if (!eventListeners) {
|
||||
return false;
|
||||
}
|
||||
|
||||
eventListeners.delete(listener);
|
||||
if (eventListeners.size === 0) {
|
||||
listeners.delete(eventName);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
on(eventName, listener) {
|
||||
getListeners(eventName).add(listener);
|
||||
return () => api.off(eventName, listener);
|
||||
},
|
||||
ready(data = { loaded: true }) {
|
||||
return host.send(readyEvent, data);
|
||||
},
|
||||
receive,
|
||||
receiveMany,
|
||||
request(eventName, payload = {}) {
|
||||
return host.send(eventName, payload);
|
||||
},
|
||||
send(eventName, payload = {}) {
|
||||
return host.send(eventName, payload);
|
||||
},
|
||||
};
|
||||
|
||||
global[globalName] = globalBridge;
|
||||
return api;
|
||||
}
|
||||
|
||||
ForgeWebUI.createBridge = createBridge;
|
||||
})(window);
|
||||
68
arma/client/addons/common/ui/src/host.js
Normal file
68
arma/client/addons/common/ui/src/host.js
Normal file
@ -0,0 +1,68 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
function createHost() {
|
||||
const api = global.A3API;
|
||||
|
||||
return {
|
||||
isArma: Boolean(api),
|
||||
close(event = "ui::close", data = {}) {
|
||||
return this.send(event, data);
|
||||
},
|
||||
exec(statement) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.Exec !== "function" ||
|
||||
typeof statement !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api.Exec(statement);
|
||||
return true;
|
||||
},
|
||||
requestFile(path) {
|
||||
if (api && typeof api.RequestFile === "function") {
|
||||
return api.RequestFile(path);
|
||||
}
|
||||
|
||||
return fetch(path).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${path}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
},
|
||||
requestTexture(path, size = 512) {
|
||||
if (api && typeof api.RequestTexture === "function") {
|
||||
return api.RequestTexture(path, size);
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new Error("Texture requests are unavailable outside Arma."),
|
||||
);
|
||||
},
|
||||
send(event, data = {}) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.SendAlert !== "function" ||
|
||||
typeof event !== "string" ||
|
||||
event === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ForgeWebUI.createHost = createHost;
|
||||
})(window);
|
||||
5
arma/client/addons/common/ui/src/index.js
Normal file
5
arma/client/addons/common/ui/src/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
ForgeWebUI.version = "0.1.0";
|
||||
})(window);
|
||||
428
arma/client/addons/common/ui/src/runtime.js
Normal file
428
arma/client/addons/common/ui/src/runtime.js
Normal file
@ -0,0 +1,428 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SVG_TAGS = new Set([
|
||||
"svg",
|
||||
"path",
|
||||
"circle",
|
||||
"rect",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"g",
|
||||
"defs",
|
||||
"use",
|
||||
"text",
|
||||
"tspan",
|
||||
"clipPath",
|
||||
"mask",
|
||||
]);
|
||||
|
||||
const injectedStyles = new Set();
|
||||
const scheduledObservers = new Set();
|
||||
let activeObserver = null;
|
||||
let batchDepth = 0;
|
||||
let flushQueued = false;
|
||||
|
||||
function queueFlush() {
|
||||
if (flushQueued || batchDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushQueued = true;
|
||||
queueMicrotask(() => {
|
||||
flushQueued = false;
|
||||
flushObservers();
|
||||
});
|
||||
}
|
||||
|
||||
function flushObservers() {
|
||||
while (scheduledObservers.size > 0) {
|
||||
const queue = Array.from(scheduledObservers);
|
||||
scheduledObservers.clear();
|
||||
queue.forEach((observer) => runObserver(observer));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupObserver(observer) {
|
||||
if (typeof observer.cleanup === "function") {
|
||||
try {
|
||||
observer.cleanup();
|
||||
} catch (error) {
|
||||
console.error("[ForgeWebUI] Observer cleanup failed.", error);
|
||||
}
|
||||
}
|
||||
|
||||
observer.cleanup = null;
|
||||
observer.dependencies.forEach((dependency) => {
|
||||
dependency.delete(observer);
|
||||
});
|
||||
observer.dependencies.clear();
|
||||
}
|
||||
|
||||
function runObserver(observer) {
|
||||
if (!observer || observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupObserver(observer);
|
||||
|
||||
const previousObserver = activeObserver;
|
||||
activeObserver = observer;
|
||||
|
||||
try {
|
||||
const cleanup = observer.fn();
|
||||
if (typeof cleanup === "function") {
|
||||
observer.cleanup = cleanup;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ForgeWebUI] Observer execution failed.", error);
|
||||
} finally {
|
||||
activeObserver = previousObserver;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleObserver(observer) {
|
||||
if (!observer || observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledObservers.add(observer);
|
||||
queueFlush();
|
||||
}
|
||||
|
||||
function trackDependency(dependency) {
|
||||
if (!activeObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
dependency.add(activeObserver);
|
||||
activeObserver.dependencies.add(dependency);
|
||||
}
|
||||
|
||||
function createSignalValue(initialValue) {
|
||||
let value = initialValue;
|
||||
const subscribers = new Set();
|
||||
|
||||
function read() {
|
||||
trackDependency(subscribers);
|
||||
return value;
|
||||
}
|
||||
|
||||
read.peek = () => value;
|
||||
read.set = (nextValue) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function" ? nextValue(value) : nextValue;
|
||||
|
||||
if (Object.is(resolvedValue, value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value = resolvedValue;
|
||||
subscribers.forEach((observer) => scheduleObserver(observer));
|
||||
return value;
|
||||
};
|
||||
read.update = (updater) => read.set(updater);
|
||||
read.subscribe = (listener) =>
|
||||
effect(() => {
|
||||
listener(read());
|
||||
});
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
function createSignal(initialValue) {
|
||||
const signal = createSignalValue(initialValue);
|
||||
return [signal, signal.set];
|
||||
}
|
||||
|
||||
function computed(factory) {
|
||||
const valueSignal = createSignalValue(undefined);
|
||||
let initialized = false;
|
||||
|
||||
effect(() => {
|
||||
const nextValue = factory();
|
||||
if (!initialized || !Object.is(nextValue, valueSignal.peek())) {
|
||||
initialized = true;
|
||||
valueSignal.set(nextValue);
|
||||
}
|
||||
});
|
||||
|
||||
return valueSignal;
|
||||
}
|
||||
|
||||
function effect(fn) {
|
||||
const observer = {
|
||||
cleanup: null,
|
||||
dependencies: new Set(),
|
||||
disposed: false,
|
||||
fn,
|
||||
};
|
||||
|
||||
observer.dispose = () => {
|
||||
if (observer.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.disposed = true;
|
||||
scheduledObservers.delete(observer);
|
||||
cleanupObserver(observer);
|
||||
};
|
||||
|
||||
runObserver(observer);
|
||||
return observer.dispose;
|
||||
}
|
||||
|
||||
function batch(fn) {
|
||||
batchDepth += 1;
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
batchDepth = Math.max(0, batchDepth - 1);
|
||||
if (batchDepth === 0) {
|
||||
flushObservers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendChild(node, child) {
|
||||
if (child === null || child === undefined || child === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry) => appendChild(node, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof child === "string" ||
|
||||
typeof child === "number" ||
|
||||
typeof child === "bigint"
|
||||
) {
|
||||
node.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof Node) {
|
||||
node.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function fragment(...children) {
|
||||
const node = document.createDocumentFragment();
|
||||
children.forEach((child) => appendChild(node, child));
|
||||
return node;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return document.createTextNode(String(value ?? ""));
|
||||
}
|
||||
|
||||
function applyProp(node, key, value, isSvg) {
|
||||
if (key === "key") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ref" && typeof value === "function") {
|
||||
value(node);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "className") {
|
||||
if (isSvg) {
|
||||
node.setAttribute("class", value || "");
|
||||
} else {
|
||||
node.className = value || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "style" && value && typeof value === "object") {
|
||||
Object.assign(node.style, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "dataset" && value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([name, datasetValue]) => {
|
||||
node.dataset[name] = datasetValue;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "value" && "value" in node) {
|
||||
node.value = value ?? "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "checked" && "checked" in node) {
|
||||
node.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "selected" && "selected" in node) {
|
||||
node.selected = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value) {
|
||||
node.setAttribute(key, "");
|
||||
} else {
|
||||
node.removeAttribute(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
node.removeAttribute(key);
|
||||
return;
|
||||
}
|
||||
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const isSvg = SVG_TAGS.has(tag);
|
||||
const node = isSvg
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
if (props && typeof props === "object") {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
applyProp(node, key, value, isSvg);
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach((child) => appendChild(node, child));
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeNode(node) {
|
||||
if (node === null || node === undefined || node === false) {
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return fragment(...node);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof node === "string" ||
|
||||
typeof node === "number" ||
|
||||
typeof node === "bigint"
|
||||
) {
|
||||
return text(node);
|
||||
}
|
||||
|
||||
if (node instanceof Node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
function captureScrollState(container) {
|
||||
return Array.from(
|
||||
container.querySelectorAll("[data-preserve-scroll-id]"),
|
||||
).map((node) => ({
|
||||
id: node.getAttribute("data-preserve-scroll-id"),
|
||||
scrollLeft: node.scrollLeft,
|
||||
scrollTop: node.scrollTop,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreScrollState(container, scrollState) {
|
||||
if (!Array.isArray(scrollState) || scrollState.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollState.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = container.querySelector(
|
||||
`[data-preserve-scroll-id="${entry.id}"]`,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollTop = Number(entry.scrollTop || 0);
|
||||
target.scrollLeft = Number(entry.scrollLeft || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function mount(container, render, options = {}) {
|
||||
const preserveScroll = options.preserveScroll !== false;
|
||||
|
||||
const dispose = effect(() => {
|
||||
const scrollState = preserveScroll
|
||||
? captureScrollState(container)
|
||||
: [];
|
||||
const nextNode = normalizeNode(render());
|
||||
|
||||
container.replaceChildren(nextNode);
|
||||
|
||||
if (preserveScroll && scrollState.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollState(container, scrollState);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
container,
|
||||
dispose,
|
||||
rerender() {
|
||||
container.replaceChildren(normalizeNode(render()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function render(component, container, options = {}) {
|
||||
return mount(container, component, options);
|
||||
}
|
||||
|
||||
function unmount(mountHandle) {
|
||||
if (!mountHandle || typeof mountHandle.dispose !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
mountHandle.dispose();
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
if (!id || !cssText || injectedStyles.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-ui-style", id);
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
injectedStyles.add(id);
|
||||
}
|
||||
|
||||
ForgeWebUI.batch = batch;
|
||||
ForgeWebUI.computed = computed;
|
||||
ForgeWebUI.createSignal = createSignal;
|
||||
ForgeWebUI.effect = effect;
|
||||
ForgeWebUI.ensureScopedStyle = ensureScopedStyle;
|
||||
ForgeWebUI.fragment = fragment;
|
||||
ForgeWebUI.h = h;
|
||||
ForgeWebUI.mount = mount;
|
||||
ForgeWebUI.render = render;
|
||||
ForgeWebUI.signal = createSignalValue;
|
||||
ForgeWebUI.text = text;
|
||||
ForgeWebUI.unmount = unmount;
|
||||
})(window);
|
||||
126
arma/client/addons/common/ui/src/siteLoader.js
Normal file
126
arma/client/addons/common/ui/src/siteLoader.js
Normal file
@ -0,0 +1,126 @@
|
||||
(function (global) {
|
||||
const ForgeSiteLoader = (global.ForgeSiteLoader =
|
||||
global.ForgeSiteLoader || {});
|
||||
const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\";
|
||||
const defaultBrowserCommonBase = "../../../common/ui/_site/";
|
||||
|
||||
function isArmaAvailable() {
|
||||
return (
|
||||
typeof A3API !== "undefined" &&
|
||||
A3API &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function isAbsoluteAddonPath(path) {
|
||||
return typeof path === "string" && path.startsWith("forge\\");
|
||||
}
|
||||
|
||||
function normalizeAddonRoot(addonName) {
|
||||
return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`;
|
||||
}
|
||||
|
||||
function normalizeBrowserPath(basePath, assetPath) {
|
||||
const normalizedBase = String(basePath || "./").replace(/\\/g, "/");
|
||||
const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/");
|
||||
return `${normalizedBase}${normalizedAssetPath}`;
|
||||
}
|
||||
|
||||
function requestText({ addonRoot, browserBase, assetPath }) {
|
||||
if (isArmaAvailable()) {
|
||||
const resolvedPath = isAbsoluteAddonPath(assetPath)
|
||||
? assetPath
|
||||
: addonRoot + String(assetPath || "").replace(/\//g, "\\");
|
||||
return A3API.RequestFile(resolvedPath);
|
||||
}
|
||||
|
||||
const browserPath = isAbsoluteAddonPath(assetPath)
|
||||
? assetPath
|
||||
: normalizeBrowserPath(browserBase, assetPath);
|
||||
|
||||
return fetch(browserPath).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${browserPath}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
function appendStyle(cssText) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function appendScript(jsText) {
|
||||
const script = document.createElement("script");
|
||||
script.text = jsText;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
async function boot(config) {
|
||||
const addonName = config && config.addonName ? config.addonName : "";
|
||||
|
||||
if (!addonName) {
|
||||
throw new Error(
|
||||
"ForgeSiteLoader requires a config.addonName value.",
|
||||
);
|
||||
}
|
||||
|
||||
const addonRoot = normalizeAddonRoot(addonName);
|
||||
const browserAddonBase = config.browserAddonBase || "./";
|
||||
const browserCommonBase =
|
||||
config.browserCommonBase || defaultBrowserCommonBase;
|
||||
const styles = Array.isArray(config.styles) ? config.styles : [];
|
||||
const commonScripts = Array.isArray(config.commonScripts)
|
||||
? config.commonScripts
|
||||
: [];
|
||||
const scripts = Array.isArray(config.scripts) ? config.scripts : [];
|
||||
|
||||
const styleChunks = await Promise.all(
|
||||
styles.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot,
|
||||
browserBase: browserAddonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
styleChunks.forEach(appendStyle);
|
||||
|
||||
const commonScriptChunks = await Promise.all(
|
||||
commonScripts.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot: commonAddonRoot,
|
||||
browserBase: browserCommonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
commonScriptChunks.forEach(appendScript);
|
||||
|
||||
const scriptChunks = await Promise.all(
|
||||
scripts.map((assetPath) =>
|
||||
requestText({
|
||||
addonRoot,
|
||||
browserBase: browserAddonBase,
|
||||
assetPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
scriptChunks.forEach(appendScript);
|
||||
}
|
||||
|
||||
ForgeSiteLoader.boot = boot;
|
||||
|
||||
if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) {
|
||||
boot(global.ForgeSiteConfig).catch((error) => {
|
||||
const logLabel =
|
||||
global.ForgeSiteConfig.logLabel ||
|
||||
global.ForgeSiteConfig.addonName ||
|
||||
"Forge UI";
|
||||
console.error(`[${logLabel}] Failed to load site assets.`, error);
|
||||
});
|
||||
}
|
||||
})(window);
|
||||
238
arma/client/addons/common/ui/src/windowTitleBar.js
Normal file
238
arma/client/addons/common/ui/src/windowTitleBar.js
Normal file
@ -0,0 +1,238 @@
|
||||
(function (global) {
|
||||
const ForgeWebUI = global.ForgeWebUI;
|
||||
const SharedUI = (global.SharedUI = global.SharedUI || {});
|
||||
const { h, ensureScopedStyle } = ForgeWebUI;
|
||||
const titleBarCss = `
|
||||
.ui-window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: var(--ui-titlebar-min-height, 3.5rem);
|
||||
padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem);
|
||||
background: var(
|
||||
--ui-titlebar-bg,
|
||||
linear-gradient(180deg, #12325b 0%, #0d2643 100%)
|
||||
);
|
||||
color: var(--ui-titlebar-text, #f4f8fd);
|
||||
border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1));
|
||||
box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18));
|
||||
position: var(--ui-titlebar-position, relative);
|
||||
top: var(--ui-titlebar-top, auto);
|
||||
z-index: var(--ui-titlebar-z-index, 5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-kicker {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72));
|
||||
}
|
||||
|
||||
.ui-window-titlebar-title {
|
||||
font-size: var(--ui-titlebar-title-size, 1rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: var(--ui-titlebar-title-spacing, -0.03em);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.ui-window-control-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0.38rem;
|
||||
border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16));
|
||||
background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04));
|
||||
color: var(--ui-window-control-text, rgb(237 244 251 / 0.88));
|
||||
line-height: 1;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-window-control-btn + .ui-window-control-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ui-window-control-btn:hover {
|
||||
background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04));
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ui-window-control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-window-control-btn.is-close {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1));
|
||||
}
|
||||
|
||||
.ui-window-control-btn.is-close:hover {
|
||||
background: var(
|
||||
--ui-window-control-close-hover-bg,
|
||||
rgb(185 67 67 / 0.9)
|
||||
);
|
||||
border-color: var(
|
||||
--ui-window-control-close-hover-border,
|
||||
rgb(255 222 222 / 0.45)
|
||||
);
|
||||
}
|
||||
|
||||
.ui-window-control-icon {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ui-window-titlebar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ui-window-titlebar-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
SharedUI.componentFns = SharedUI.componentFns || {};
|
||||
|
||||
function WindowControlIcon({ type }) {
|
||||
if (type === "minimize") {
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "maximize") {
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }),
|
||||
);
|
||||
}
|
||||
|
||||
return h(
|
||||
"svg",
|
||||
{
|
||||
className: "ui-window-control-icon",
|
||||
viewBox: "0 0 16 16",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }),
|
||||
h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }),
|
||||
);
|
||||
}
|
||||
|
||||
SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({
|
||||
kicker = "",
|
||||
title = "",
|
||||
onClose = null,
|
||||
closeLabel = "Close interface",
|
||||
minimizeLabel = "Minimize unavailable",
|
||||
maximizeLabel = "Maximize unavailable",
|
||||
} = {}) {
|
||||
ensureScopedStyle("shared-window-titlebar", titleBarCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar-brand" },
|
||||
kicker
|
||||
? h(
|
||||
"span",
|
||||
{ className: "ui-window-titlebar-kicker" },
|
||||
kicker,
|
||||
)
|
||||
: null,
|
||||
h("span", { className: "ui-window-titlebar-title" }, title),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "ui-window-titlebar-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn",
|
||||
disabled: true,
|
||||
title: minimizeLabel,
|
||||
"aria-label": minimizeLabel,
|
||||
},
|
||||
WindowControlIcon({ type: "minimize" }),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn",
|
||||
disabled: true,
|
||||
title: maximizeLabel,
|
||||
"aria-label": maximizeLabel,
|
||||
},
|
||||
WindowControlIcon({ type: "maximize" }),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "ui-window-control-btn is-close",
|
||||
title: "Close",
|
||||
"aria-label": closeLabel,
|
||||
onClick:
|
||||
typeof onClose === "function" ? onClose : () => {},
|
||||
},
|
||||
WindowControlIcon({ type: "close" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
})(window);
|
||||
@ -8,6 +8,7 @@ class CfgPatches {
|
||||
name = COMPONENT_NAME;
|
||||
requiredVersion = REQUIRED_VERSION;
|
||||
requiredAddons[] = {
|
||||
"forge_client_common",
|
||||
"forge_client_main"
|
||||
};
|
||||
units[] = {};
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* File: fnc_initOrgUIBridge.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-10
|
||||
* Last Update: 2026-03-12
|
||||
* Last Update: 2026-03-13
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
@ -21,11 +21,12 @@
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
private _webUIDeclarations = call EFUNC(common,initWebUIBridge);
|
||||
private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration";
|
||||
|
||||
GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#base", _webUIBridgeDeclaration],
|
||||
["#type", "OrgUIBridgeBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["pendingBrowserControl", controlNull];
|
||||
}],
|
||||
["setPendingBrowserControl", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
@ -40,44 +41,14 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
}],
|
||||
["getActiveBrowserControl", compileFinal {
|
||||
private _display = uiNamespace getVariable ["RscOrg", displayNull];
|
||||
if (isNull _display) exitWith { controlNull };
|
||||
|
||||
_display displayCtrl 1003
|
||||
}],
|
||||
["execBridge", compileFinal {
|
||||
params [
|
||||
["_control", controlNull, [controlNull]],
|
||||
["_fnName", "", [""]],
|
||||
["_payload", createHashMap, [createHashMap]]
|
||||
];
|
||||
|
||||
if (isNull _control || { _fnName isEqualTo "" }) exitWith { false };
|
||||
|
||||
private _json = toJSON _payload;
|
||||
_control ctrlWebBrowserAction ["ExecJS", format ["OrgUIBridge.%1(%2)", _fnName, _json]];
|
||||
|
||||
true
|
||||
}],
|
||||
["sendBridgeEvent", compileFinal {
|
||||
params [
|
||||
["_event", "", [""]],
|
||||
["_data", createHashMap, [createHashMap]],
|
||||
["_control", controlNull, [controlNull]]
|
||||
];
|
||||
|
||||
if (_event isEqualTo "") exitWith { false };
|
||||
|
||||
private _targetControl = _control;
|
||||
if (isNull _targetControl) then {
|
||||
_targetControl = _self call ["getActiveBrowserControl", []];
|
||||
if (isNull _display) exitWith {
|
||||
_self call ["setActiveBrowserControl", [controlNull]];
|
||||
controlNull
|
||||
};
|
||||
|
||||
if (isNull _targetControl) exitWith { false };
|
||||
|
||||
_self call ["execBridge", [_targetControl, "receive", createHashMapFromArray [
|
||||
["event", _event],
|
||||
["data", _data]
|
||||
]]]
|
||||
private _control = _display displayCtrl 1003;
|
||||
_self call ["setActiveBrowserControl", [_control]];
|
||||
_control
|
||||
}],
|
||||
["handleLoginRequest", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
@ -87,24 +58,21 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
private _orgName = _orgData getOrDefault ["name", ""];
|
||||
|
||||
if (_orgId isEqualTo "" && { _orgName isEqualTo "" }) exitWith {
|
||||
_self call ["execBridge", [_control, "receiveLoginFailure", createHashMapFromArray [
|
||||
_self call ["sendEvent", ["org::login::failure", createHashMapFromArray [
|
||||
["message", "No organization data is available for this player."]
|
||||
]]];
|
||||
], _control]];
|
||||
};
|
||||
|
||||
_self call ["execBridge", [_control, "receiveLoginSuccess", GVAR(OrgClass) call ["buildPortalPayload", []]]];
|
||||
_self call ["sendEvent", ["org::login::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]];
|
||||
}],
|
||||
["handleCreateRequest", compileFinal {
|
||||
params [
|
||||
["_control", controlNull, [controlNull]],
|
||||
["_data", createHashMap, [createHashMap]]
|
||||
];
|
||||
params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]];
|
||||
|
||||
private _orgName = _data getOrDefault ["orgName", ""];
|
||||
if (_orgName isEqualTo "") exitWith {
|
||||
_self call ["execBridge", [_control, "receiveCreateFailure", createHashMapFromArray [
|
||||
_self call ["sendEvent", ["org::create::failure", createHashMapFromArray [
|
||||
["message", "Enter an organization name."]
|
||||
]]];
|
||||
], _control]];
|
||||
};
|
||||
|
||||
_self call ["setPendingBrowserControl", [_control]];
|
||||
@ -118,16 +86,16 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
if (!_success) exitWith {
|
||||
if (isNull _control) exitWith {};
|
||||
|
||||
_self call ["execBridge", [_control, "receiveCreateFailure", createHashMapFromArray [
|
||||
_self call ["sendEvent", ["org::create::failure", createHashMapFromArray [
|
||||
["message", _payload getOrDefault ["message", "Organization registration failed."]]
|
||||
]]];
|
||||
], _control]];
|
||||
};
|
||||
|
||||
private _orgData = _payload getOrDefault ["org", createHashMap];
|
||||
GVAR(OrgClass) call ["sync", [_orgData, true]];
|
||||
|
||||
if (isNull _control) exitWith {};
|
||||
_self call ["execBridge", [_control, "receiveCreateSuccess", GVAR(OrgClass) call ["buildPortalPayload", []]]];
|
||||
_self call ["sendEvent", ["org::create::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]];
|
||||
}],
|
||||
["handleDisbandResponse", compileFinal {
|
||||
params [["_payload", createHashMap, [createHashMap]]];
|
||||
@ -138,7 +106,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
"org::disband::failure"
|
||||
};
|
||||
|
||||
_self call ["sendBridgeEvent", [_eventName, _payload]];
|
||||
_self call ["sendEvent", [_eventName, _payload]];
|
||||
}],
|
||||
["handleLeaveResponse", compileFinal {
|
||||
params [["_payload", createHashMap, [createHashMap]]];
|
||||
@ -148,7 +116,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
"org::leave::success"
|
||||
] select (_payload getOrDefault ["success", false]);
|
||||
|
||||
_self call ["sendBridgeEvent", [_eventName, _payload]];
|
||||
_self call ["sendEvent", [_eventName, _payload]];
|
||||
}],
|
||||
["handleCreditLineResponse", compileFinal {
|
||||
params [["_payload", createHashMap, [createHashMap]]];
|
||||
@ -158,7 +126,18 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
"org::credit::success"
|
||||
] select (_payload getOrDefault ["success", false]);
|
||||
|
||||
_self call ["sendBridgeEvent", [_eventName, _payload]];
|
||||
_self call ["sendEvent", [_eventName, _payload]];
|
||||
|
||||
if (_payload getOrDefault ["success", false]) then {
|
||||
private _memberUid = _payload getOrDefault ["memberUid", ""];
|
||||
if (_memberUid isNotEqualTo "") then {
|
||||
_self call ["sendEvent", ["org::member::creditUpdated", createHashMapFromArray [
|
||||
["amount", _payload getOrDefault ["amount", 0]],
|
||||
["memberName", _payload getOrDefault ["memberName", ""]],
|
||||
["memberUid", _memberUid]
|
||||
]]];
|
||||
};
|
||||
};
|
||||
}],
|
||||
["requestDisband", compileFinal {
|
||||
[SRPC(org,requestDisbandOrg), [getPlayerUID player]] call CFUNC(serverEvent);
|
||||
@ -179,18 +158,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
private _control = _self call ["getActiveBrowserControl", []];
|
||||
if (isNull _control) exitWith { false };
|
||||
|
||||
_self call ["sendBridgeEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]
|
||||
}],
|
||||
["handleReady", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
_self call ["sendBridgeEvent", [
|
||||
"org::ready",
|
||||
createHashMapFromArray [
|
||||
["loaded", true]
|
||||
],
|
||||
_control
|
||||
]];
|
||||
_self call ["sendEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]
|
||||
}]
|
||||
];
|
||||
|
||||
|
||||
6
arma/client/addons/org/ui/_site/bootstrap.js
vendored
6
arma/client/addons/org/ui/_site/bootstrap.js
vendored
@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Registry app bootstrap
|
||||
*/
|
||||
|
||||
const root = document.getElementById("app");
|
||||
window.RegistryApp.runtime.render(window.RegistryApp.components.App, root);
|
||||
@ -1,238 +0,0 @@
|
||||
(function () {
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const store = RegistryApp.store;
|
||||
|
||||
function sendEvent(event, data) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestLogin(credentials) {
|
||||
store.startLogin();
|
||||
|
||||
const sent = sendEvent("org::login::request", credentials);
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.failLogin("Arma login bridge is unavailable.");
|
||||
}
|
||||
|
||||
function requestCreateOrg(registration) {
|
||||
store.startCreate();
|
||||
|
||||
const sent = sendEvent("org::create::request", registration);
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.failCreate("Arma registration bridge is unavailable.");
|
||||
}
|
||||
|
||||
function requestDisbandOrg() {
|
||||
const sent = sendEvent("org::disband::request", {});
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma disband bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestLeaveOrg() {
|
||||
const sent = sendEvent("org::leave::request", {});
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma leave bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestCreditLine(payload) {
|
||||
const sent = sendEvent("org::credit::request", payload);
|
||||
if (sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma credit line bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function receive(eventOrPayload, data = {}) {
|
||||
const event =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.event
|
||||
: eventOrPayload;
|
||||
const payloadData =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.data || {}
|
||||
: data;
|
||||
|
||||
if (event === "org::login::success") {
|
||||
store.completeLogin(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::login::failure") {
|
||||
store.failLogin(payloadData.message || "Authentication failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::create::success") {
|
||||
store.completeCreate(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::create::failure") {
|
||||
store.failCreate(
|
||||
payloadData.message || "Organization registration failed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::sync") {
|
||||
if (store && typeof store.hydratePortal === "function") {
|
||||
store.hydratePortal(payloadData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (event === "org::credit::success") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"success",
|
||||
payloadData.message || "Credit line assigned.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::credit::failure") {
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Unable to assign credit line.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::disband::success") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
OrgPortal.store.setOrgDisbanded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::disband::failure") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Organization disbanding failed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::leave::success") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
store.failLogin(
|
||||
payloadData.message || "You have left the organization.",
|
||||
);
|
||||
store.setView("home");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::leave::failure") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Unable to leave the organization.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "org::portal::revoked") {
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
store.failLogin(
|
||||
payloadData.message ||
|
||||
"Organization access is no longer available.",
|
||||
);
|
||||
store.setView("home");
|
||||
}
|
||||
}
|
||||
|
||||
RegistryApp.bridge = {
|
||||
requestLogin,
|
||||
requestCreateOrg,
|
||||
requestDisbandOrg,
|
||||
requestLeaveOrg,
|
||||
requestCreditLine,
|
||||
receive,
|
||||
sendEvent,
|
||||
};
|
||||
|
||||
window.OrgUIBridge = {
|
||||
requestLogin,
|
||||
requestCreateOrg,
|
||||
requestDisbandOrg,
|
||||
requestLeaveOrg,
|
||||
requestCreditLine,
|
||||
receive,
|
||||
receiveLoginSuccess: (data) => receive("org::login::success", data),
|
||||
receiveLoginFailure: (data) => receive("org::login::failure", data),
|
||||
receiveCreateSuccess: (data) => receive("org::create::success", data),
|
||||
receiveCreateFailure: (data) => receive("org::create::failure", data),
|
||||
};
|
||||
})();
|
||||
@ -1,33 +0,0 @@
|
||||
.org-secondary-btn {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.org-danger-btn {
|
||||
background: #7f1d1d;
|
||||
color: #fef2f2;
|
||||
|
||||
&:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.org-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
.org-page-header {
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.org-page-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.org-page-kicker {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.org-page-heading {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- Generated by tools/build-webui.mjs for org UI index. Do not edit directly. -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -5,88 +6,55 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ORBIS - Global Organization Network</title>
|
||||
<script>
|
||||
const addonRoot = "forge\\forge_client\\addons\\org\\ui\\_site\\";
|
||||
const styleFiles = ["base.css", "controls.css", "hero.css"];
|
||||
const scriptFiles = [
|
||||
"runtime.js",
|
||||
"logic\\registryStore.js",
|
||||
"logic\\portalStore.js",
|
||||
"logic\\portalGetters.js",
|
||||
"logic\\portalActions.js",
|
||||
"useRegistryStore.js",
|
||||
"bridge.js",
|
||||
"portal\\data.js",
|
||||
"portal\\useStore.js",
|
||||
"portal\\getters.js",
|
||||
"portal\\actions.js",
|
||||
"components\\navbar.js",
|
||||
"components\\header.js",
|
||||
"components\\hero.js",
|
||||
"components\\footer.js",
|
||||
"components\\modal.js",
|
||||
"components\\panelCard.js",
|
||||
"components\\portal\\metricCard.js",
|
||||
"components\\portal\\simpleStat.js",
|
||||
"components\\portal\\overviewCard.js",
|
||||
"components\\portal\\fleetCard.js",
|
||||
"components\\portal\\treasuryCard.js",
|
||||
"components\\portal\\assetsCard.js",
|
||||
"components\\portal\\membersCard.js",
|
||||
"components\\portal\\activityCard.js",
|
||||
"components\\portal\\futureCard.js",
|
||||
"components\\portal\\dangerCard.js",
|
||||
"components\\portal\\modalLayer.js",
|
||||
"views\\DisbandedView.js",
|
||||
"views\\PortalView.js",
|
||||
"views\\RegistrationView.js",
|
||||
"views\\HomeView.js",
|
||||
"components\\AppShell.js",
|
||||
"bootstrap.js",
|
||||
];
|
||||
window.ForgeSiteConfig = {
|
||||
addonName: "org",
|
||||
logLabel: "Org UI",
|
||||
styles: ["org-ui.css"],
|
||||
commonScripts: ["forge-webui.js"],
|
||||
scripts: ["org-ui.js"],
|
||||
};
|
||||
|
||||
function requestText(path) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
) {
|
||||
return A3API.RequestFile(addonRoot + path);
|
||||
(function loadForgeSiteLoader() {
|
||||
const armaLoaderPath =
|
||||
"forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js";
|
||||
const browserLoaderPath =
|
||||
"../../../common/ui/_site/forge-site-loader.js";
|
||||
|
||||
function appendScript(js) {
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return fetch(path).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + path);
|
||||
function requestLoader() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
A3API &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
) {
|
||||
return A3API.RequestFile(armaLoaderPath);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
return fetch(browserLoaderPath).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"Failed to load " + browserLoaderPath,
|
||||
);
|
||||
}
|
||||
|
||||
function appendStyle(css) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
function appendScript(js) {
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
Promise.all(styleFiles.map(requestText))
|
||||
.then((styles) => {
|
||||
styles.forEach(appendStyle);
|
||||
return Promise.all(scriptFiles.map(requestText));
|
||||
})
|
||||
.then((scripts) => {
|
||||
scripts.forEach(appendScript);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[Org UI] Failed to load site assets.",
|
||||
error,
|
||||
);
|
||||
});
|
||||
requestLoader()
|
||||
.then(appendScript)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[Org UI] Failed to load Forge site loader.",
|
||||
error,
|
||||
);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
|
||||
@ -1,312 +0,0 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createPortalActions = function createPortalActions({
|
||||
portalData,
|
||||
store,
|
||||
getters,
|
||||
registryStore,
|
||||
}) {
|
||||
class OrgPortalActions {
|
||||
constructor() {
|
||||
this.treasuryNoticeTimer = null;
|
||||
}
|
||||
|
||||
showTreasuryNotice(type, text) {
|
||||
store.setTreasuryNotice({ type, text });
|
||||
|
||||
if (this.treasuryNoticeTimer) {
|
||||
clearTimeout(this.treasuryNoticeTimer);
|
||||
}
|
||||
|
||||
this.treasuryNoticeTimer = setTimeout(() => {
|
||||
store.setTreasuryNotice({ type: "", text: "" });
|
||||
this.treasuryNoticeTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
parseAmount(value) {
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? Math.round(amount) : 0;
|
||||
}
|
||||
|
||||
getInputValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
closePortal() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event: "org::close",
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registryStore) {
|
||||
registryStore.setView("home");
|
||||
}
|
||||
}
|
||||
|
||||
openModal(type) {
|
||||
if (
|
||||
(type === "payroll" ||
|
||||
type === "transfer" ||
|
||||
type === "credit") &&
|
||||
!getters.canManageTreasury()
|
||||
) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "disband" && !getters.canDisbandOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "leave" && !getters.canLeaveOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.setModal({ type });
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
store.setModal(null);
|
||||
}
|
||||
|
||||
removeMember(member) {
|
||||
if (!getters.canManageMembers()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getters.isProtectedMember(member)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const memberUid = getters.getMemberUid(member);
|
||||
const memberName = getters.getMemberName(member);
|
||||
|
||||
store.setMembers((currentMembers) =>
|
||||
currentMembers.filter((entry) =>
|
||||
memberUid
|
||||
? entry.uid !== memberUid
|
||||
: entry.name !== memberName,
|
||||
),
|
||||
);
|
||||
store.setCreditLines((currentLines) =>
|
||||
currentLines.filter((line) =>
|
||||
memberUid
|
||||
? line.uid !== memberUid
|
||||
: line.member !== memberName,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
disbandOrganization() {
|
||||
if (!getters.canDisbandOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestDisbandOrg !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Disband bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
bridge.requestDisbandOrg();
|
||||
return true;
|
||||
}
|
||||
|
||||
leaveOrganization() {
|
||||
if (!getters.canLeaveOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestLeaveOrg !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Leave bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
bridge.requestLeaveOrg();
|
||||
return true;
|
||||
}
|
||||
|
||||
runPayroll(amountPerMember) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const members = store.getMembers();
|
||||
const funds = store.getFunds();
|
||||
|
||||
if (members.length === 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"No members available for payroll.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amountPerMember <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid payroll amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const total = amountPerMember * members.length;
|
||||
if (total > funds) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Insufficient org funds for payroll.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setFunds(funds - total);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
sendFundsToMember(memberName, amount) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const funds = store.getFunds();
|
||||
|
||||
if (!memberName) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Select a member to receive funds.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid transfer amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount > funds) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Insufficient org funds for this transfer.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setFunds(funds - amount);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`${getters.formatCurrency(amount)} sent to ${memberName}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
grantCreditLine(memberUid, amount) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!memberUid) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Select a member for the credit line.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid credit line amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const member = store
|
||||
.getMembers()
|
||||
.find(
|
||||
(entry) =>
|
||||
getters.getMemberUid(entry) === memberUid,
|
||||
);
|
||||
const memberName = member
|
||||
? getters.getMemberName(member)
|
||||
: "";
|
||||
|
||||
if (!memberName) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Selected member was not found in the organization roster.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestCreditLine !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Credit line bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return bridge.requestCreditLine({
|
||||
memberUid,
|
||||
memberName,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new OrgPortalActions();
|
||||
};
|
||||
})();
|
||||
@ -1,183 +0,0 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createPortalGetters = function createPortalGetters({
|
||||
portalData,
|
||||
session,
|
||||
}) {
|
||||
class OrgPortalGetters {
|
||||
formatCurrency(value) {
|
||||
return "$" + Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
formatVehicleType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatAssetType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatDisplayName(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (!part) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
part.charAt(0).toUpperCase() +
|
||||
part.slice(1).toLowerCase()
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
getAssetReadiness() {
|
||||
if (portalData.fleet.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = portalData.fleet.reduce(
|
||||
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
|
||||
0,
|
||||
);
|
||||
return Math.round(total / portalData.fleet.length);
|
||||
}
|
||||
|
||||
getNormalizedRole() {
|
||||
return String(session.role || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
isDefaultOrg() {
|
||||
return (
|
||||
portalData.org.isDefault === true ||
|
||||
String(portalData.org.tag || "")
|
||||
.trim()
|
||||
.toUpperCase() === "DEFAULT"
|
||||
);
|
||||
}
|
||||
|
||||
isOrgOwner() {
|
||||
const ownerUid = String(
|
||||
portalData.org.ownerUid || portalData.org.owner || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ownerUid && actorUid) {
|
||||
return actorUid === ownerUid;
|
||||
}
|
||||
|
||||
return (
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isSessionCeo() {
|
||||
return session.ceo === true;
|
||||
}
|
||||
|
||||
isOrgLeaderOrCeo() {
|
||||
return (
|
||||
this.isOrgOwner() ||
|
||||
this.getNormalizedRole() === "LEADER" ||
|
||||
(this.isDefaultOrg() && this.isSessionCeo())
|
||||
);
|
||||
}
|
||||
|
||||
canManageMembers() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canManageTreasury() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canDisbandOrg() {
|
||||
return this.isOrgOwner() && !this.isDefaultOrg();
|
||||
}
|
||||
|
||||
canLeaveOrg() {
|
||||
return !this.isDefaultOrg() && !this.isOrgOwner();
|
||||
}
|
||||
|
||||
getMemberName(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.name || "");
|
||||
}
|
||||
|
||||
return String(member || "");
|
||||
}
|
||||
|
||||
getMemberUid(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.uid || "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
isOwnerMember(member) {
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentMember(member) {
|
||||
const memberUid = this.getMemberUid(member)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (memberUid && actorUid) {
|
||||
return memberUid === actorUid;
|
||||
}
|
||||
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isProtectedMember(member) {
|
||||
return (
|
||||
this.isOwnerMember(member) || this.isCurrentMember(member)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new OrgPortalGetters();
|
||||
};
|
||||
})();
|
||||
@ -1,37 +0,0 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createPortalStore = function createPortalStore({
|
||||
createSignal,
|
||||
portalData,
|
||||
}) {
|
||||
class OrgPortalStore {
|
||||
constructor() {
|
||||
[this.getFunds, this.setFunds] = createSignal(portalData.funds);
|
||||
[this.getMembers, this.setMembers] = createSignal([
|
||||
...portalData.members,
|
||||
]);
|
||||
[this.getCreditLines, this.setCreditLines] = createSignal([
|
||||
...portalData.creditLines,
|
||||
]);
|
||||
[this.getTreasuryNotice, this.setTreasuryNotice] = createSignal(
|
||||
{
|
||||
type: "",
|
||||
text: "",
|
||||
},
|
||||
);
|
||||
[this.getModal, this.setModal] = createSignal(null);
|
||||
[this.getOrgDisbanded, this.setOrgDisbanded] =
|
||||
createSignal(false);
|
||||
}
|
||||
|
||||
hydrateFromPayload(payload) {
|
||||
this.setFunds(payload.portalData.funds || 0);
|
||||
this.setMembers([...(payload.portalData.members || [])]);
|
||||
this.setCreditLines([...(payload.portalData.creditLines || [])]);
|
||||
}
|
||||
}
|
||||
|
||||
return new OrgPortalStore();
|
||||
};
|
||||
})();
|
||||
@ -1,69 +0,0 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createRegistryStore = function createRegistryStore({
|
||||
createSignal,
|
||||
onHydratePortal,
|
||||
}) {
|
||||
class RegistryStore {
|
||||
constructor() {
|
||||
[this.getView, this.setView] = createSignal("home");
|
||||
[this.getIsAuthenticating, this.setIsAuthenticating] =
|
||||
createSignal(false);
|
||||
[this.getLoginError, this.setLoginError] = createSignal("");
|
||||
[this.getIsCreating, this.setIsCreating] = createSignal(false);
|
||||
[this.getCreateError, this.setCreateError] = createSignal("");
|
||||
}
|
||||
|
||||
startLogin() {
|
||||
this.setLoginError("");
|
||||
this.setIsAuthenticating(true);
|
||||
}
|
||||
|
||||
startCreate() {
|
||||
this.setCreateError("");
|
||||
this.setIsCreating(true);
|
||||
}
|
||||
|
||||
failLogin(message) {
|
||||
this.setIsAuthenticating(false);
|
||||
this.setLoginError(message || "Authentication failed.");
|
||||
}
|
||||
|
||||
failCreate(message) {
|
||||
this.setIsCreating(false);
|
||||
this.setCreateError(
|
||||
message || "Organization registration failed.",
|
||||
);
|
||||
}
|
||||
|
||||
hydratePortal(payload) {
|
||||
return Boolean(onHydratePortal && onHydratePortal(payload));
|
||||
}
|
||||
|
||||
completeLogin(payload) {
|
||||
if (!this.hydratePortal(payload)) {
|
||||
this.failLogin("Login response was missing portal data.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoginError("");
|
||||
this.setIsAuthenticating(false);
|
||||
}
|
||||
|
||||
completeCreate(payload) {
|
||||
if (!this.hydratePortal(payload)) {
|
||||
this.failCreate(
|
||||
"Organization registration response was missing portal data.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCreateError("");
|
||||
this.setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new RegistryStore();
|
||||
};
|
||||
})();
|
||||
@ -1,12 +1,10 @@
|
||||
/* Generated by tools/build-webui.mjs for Org UI styles. Do not edit directly. */
|
||||
:root {
|
||||
--bg-app: #fdfcf8;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-hover: #f1f5f9;
|
||||
--primary: #475569;
|
||||
--primary-hover: #1e293b;
|
||||
--window-blue: #12325b;
|
||||
--window-blue-border: #214978;
|
||||
--window-blue-highlight: #d7e5f8;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #64748b;
|
||||
--text-inverse: #f8fafc;
|
||||
@ -21,6 +19,12 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
@ -47,6 +51,14 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#org-portal-frame-root {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -56,85 +68,6 @@ main {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1.25rem;
|
||||
background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%);
|
||||
border-bottom: 1px solid var(--window-blue-border);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 10px 24px rgb(18 50 91 / 0.24);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.window-titlebar-kicker {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(215 229 248 / 0.78);
|
||||
}
|
||||
|
||||
.window-titlebar-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.window-titlebar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.window-control-btn {
|
||||
min-width: 2.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgb(215 229 248 / 0.22);
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--window-blue-highlight);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.window-control-btn:hover {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.window-control-btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.window-control-btn.is-close {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
border-color: rgb(255 255 255 / 0.24);
|
||||
}
|
||||
|
||||
.window-control-btn.is-close:hover {
|
||||
background: rgb(255 255 255 / 0.18);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
@ -254,17 +187,77 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.org-secondary-btn {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.org-danger-btn {
|
||||
background: #7f1d1d;
|
||||
color: #fef2f2;
|
||||
|
||||
&:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.org-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.org-page-header {
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.org-page-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.org-page-kicker {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.window-titlebar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.window-titlebar-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@ -281,4 +274,8 @@ button {
|
||||
.footer .wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.org-page-heading {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
4082
arma/client/addons/org/ui/_site/org-ui.js
Normal file
4082
arma/client/addons/org/ui/_site/org-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { portalData } = OrgPortal.data;
|
||||
const store = OrgPortal.store;
|
||||
const getters = OrgPortal.getters;
|
||||
const registryStore = window.RegistryApp.store;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
OrgPortal.actions = SharedLogic.createPortalActions({
|
||||
portalData,
|
||||
store,
|
||||
getters,
|
||||
registryStore,
|
||||
});
|
||||
})();
|
||||
@ -1,10 +0,0 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { portalData, session } = OrgPortal.data;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
OrgPortal.getters = SharedLogic.createPortalGetters({
|
||||
portalData,
|
||||
session,
|
||||
});
|
||||
})();
|
||||
@ -1,11 +0,0 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { createSignal } = window.RegistryApp.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
OrgPortal.store = SharedLogic.createPortalStore({
|
||||
createSignal,
|
||||
portalData,
|
||||
});
|
||||
})();
|
||||
@ -1,248 +0,0 @@
|
||||
(function () {
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SVG_TAGS = new Set([
|
||||
"svg",
|
||||
"path",
|
||||
"circle",
|
||||
"rect",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"g",
|
||||
"defs",
|
||||
"use",
|
||||
"text",
|
||||
"tspan",
|
||||
"clipPath",
|
||||
"mask",
|
||||
]);
|
||||
|
||||
function appendChild(el, child) {
|
||||
if (child === null || child === undefined || child === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry) => appendChild(el, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof child === "string" || typeof child === "number") {
|
||||
el.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof Node) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const isSvg = SVG_TAGS.has(tag);
|
||||
const el = isSvg
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
if (props) {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "className") {
|
||||
if (isSvg) {
|
||||
el.setAttribute("class", value);
|
||||
} else {
|
||||
el.className = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "style" && typeof value === "object") {
|
||||
Object.assign(el.style, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "value" && "value" in el) {
|
||||
el.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "checked" && "checked" in el) {
|
||||
el.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value) {
|
||||
el.setAttribute(key, "");
|
||||
} else {
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
el.removeAttribute(key);
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach((child) => appendChild(el, child));
|
||||
return el;
|
||||
}
|
||||
|
||||
let rootContainer = null;
|
||||
let rootComponent = null;
|
||||
const injectedStyles = new Set();
|
||||
let rerenderQueued = false;
|
||||
|
||||
function captureScrollState(container) {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
container.querySelectorAll("[data-preserve-scroll-id]"),
|
||||
).map((node) => ({
|
||||
id: node.getAttribute("data-preserve-scroll-id"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollLeft: node.scrollLeft,
|
||||
}));
|
||||
}
|
||||
|
||||
function captureViewportScroll(container) {
|
||||
if (!container) {
|
||||
return { scrollTop: 0, scrollLeft: 0 };
|
||||
}
|
||||
|
||||
const viewport = container.querySelector("main");
|
||||
if (!viewport) {
|
||||
return {
|
||||
scrollTop: window.scrollY || window.pageYOffset || 0,
|
||||
scrollLeft: window.scrollX || window.pageXOffset || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scrollTop: viewport.scrollTop,
|
||||
scrollLeft: viewport.scrollLeft,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreScrollState(container, entries) {
|
||||
if (!container || !Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = container.querySelector(
|
||||
`[data-preserve-scroll-id="${entry.id}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollTop = Number(entry.scrollTop || 0);
|
||||
target.scrollLeft = Number(entry.scrollLeft || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreViewportScroll(container, viewportScroll) {
|
||||
if (!container || !viewportScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = container.querySelector("main");
|
||||
if (!viewport) {
|
||||
window.scrollTo(
|
||||
Number(viewportScroll.scrollLeft || 0),
|
||||
Number(viewportScroll.scrollTop || 0),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.scrollTop = Number(viewportScroll.scrollTop || 0);
|
||||
viewport.scrollLeft = Number(viewportScroll.scrollLeft || 0);
|
||||
}
|
||||
|
||||
function render(component, container) {
|
||||
rootContainer = container;
|
||||
rootComponent = component;
|
||||
rerender();
|
||||
}
|
||||
|
||||
function flushRerender() {
|
||||
if (!rootContainer || !rootComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportScroll = captureViewportScroll(rootContainer);
|
||||
const scrollState = captureScrollState(rootContainer);
|
||||
rootContainer.innerHTML = "";
|
||||
rootContainer.appendChild(rootComponent());
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollState(rootContainer, scrollState);
|
||||
restoreViewportScroll(rootContainer, viewportScroll);
|
||||
});
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
if (rerenderQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
rerenderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
rerenderQueued = false;
|
||||
flushRerender();
|
||||
});
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
if (!id || !cssText || injectedStyles.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-ui-style", id);
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
injectedStyles.add(id);
|
||||
}
|
||||
|
||||
function createSignal(initialValue) {
|
||||
let value = initialValue;
|
||||
|
||||
const getValue = () => value;
|
||||
const setValue = (newValue) => {
|
||||
value = typeof newValue === "function" ? newValue(value) : newValue;
|
||||
rerender();
|
||||
};
|
||||
|
||||
return [getValue, setValue];
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
h,
|
||||
render,
|
||||
rerender,
|
||||
createSignal,
|
||||
ensureScopedStyle,
|
||||
};
|
||||
|
||||
RegistryApp.runtime = runtime;
|
||||
OrgPortal.runtime = runtime;
|
||||
window.AppRuntime = runtime;
|
||||
})();
|
||||
@ -1,25 +0,0 @@
|
||||
(function () {
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const { createSignal } = RegistryApp.runtime;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
RegistryApp.store = SharedLogic.createRegistryStore({
|
||||
createSignal,
|
||||
onHydratePortal(payload) {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
const portalData = payload?.portalData;
|
||||
const session = payload?.session;
|
||||
|
||||
if (!OrgPortal || !portalData || !session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OrgPortal.data.applyLoginPayload(payload);
|
||||
OrgPortal.store.hydrateFromPayload(payload);
|
||||
if (RegistryApp.store.getView() !== "portal") {
|
||||
RegistryApp.store.setView("portal");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
})();
|
||||
114
arma/client/addons/org/ui/src/bootstrap.js
vendored
Normal file
114
arma/client/addons/org/ui/src/bootstrap.js
vendored
Normal file
@ -0,0 +1,114 @@
|
||||
(function () {
|
||||
const ForgeWebUI = window.ForgeWebUI;
|
||||
const RegistryApp = window.RegistryApp;
|
||||
const OrgPortal = window.OrgPortal;
|
||||
const islandDefinitions = [
|
||||
{
|
||||
id: "org-portal-frame-root",
|
||||
preserveScroll: true,
|
||||
render: () => OrgPortal.components.App(),
|
||||
},
|
||||
{
|
||||
id: "org-portal-toast-root",
|
||||
preserveScroll: false,
|
||||
render: () => OrgPortal.componentFns.TreasuryNoticeLayer(),
|
||||
},
|
||||
{
|
||||
id: "org-overview-card-root",
|
||||
preserveScroll: false,
|
||||
render: () => OrgPortal.componentFns.OverviewCard(),
|
||||
},
|
||||
{
|
||||
id: "org-fleet-card-root",
|
||||
preserveScroll: true,
|
||||
render: () => OrgPortal.componentFns.FleetCard(),
|
||||
},
|
||||
{
|
||||
id: "org-treasury-card-root",
|
||||
preserveScroll: false,
|
||||
render: () => OrgPortal.componentFns.TreasuryCard(),
|
||||
},
|
||||
{
|
||||
id: "org-members-card-root",
|
||||
preserveScroll: true,
|
||||
render: () => OrgPortal.componentFns.MembersCard(),
|
||||
},
|
||||
{
|
||||
id: "org-assets-card-root",
|
||||
preserveScroll: true,
|
||||
render: () => OrgPortal.componentFns.AssetsCard(),
|
||||
},
|
||||
{
|
||||
id: "org-activity-card-root",
|
||||
preserveScroll: true,
|
||||
render: () => OrgPortal.componentFns.ActivityCard(),
|
||||
},
|
||||
{
|
||||
id: "org-portal-modal-root",
|
||||
preserveScroll: false,
|
||||
render: () => OrgPortal.componentFns.ModalLayer(),
|
||||
},
|
||||
];
|
||||
|
||||
function createIslandManager() {
|
||||
const mounts = new Map();
|
||||
|
||||
function sync() {
|
||||
islandDefinitions.forEach((definition) => {
|
||||
const container = document.getElementById(definition.id);
|
||||
const current = mounts.get(definition.id);
|
||||
|
||||
if (!container) {
|
||||
if (current) {
|
||||
current.handle.dispose();
|
||||
mounts.delete(definition.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current && current.container === container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
current.handle.dispose();
|
||||
}
|
||||
|
||||
const handle = ForgeWebUI.mount(container, definition.render, {
|
||||
preserveScroll: definition.preserveScroll,
|
||||
});
|
||||
mounts.set(definition.id, {
|
||||
container,
|
||||
handle,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
const app = ForgeWebUI.createApp({
|
||||
name: "org",
|
||||
root: "#app",
|
||||
setup({ root }) {
|
||||
const islandManager = createIslandManager();
|
||||
|
||||
ForgeWebUI.mount(root, () => RegistryApp.components.App(), {
|
||||
preserveScroll: false,
|
||||
});
|
||||
RegistryApp.bridge.ready({ loaded: true });
|
||||
|
||||
ForgeWebUI.effect(() => {
|
||||
RegistryApp.store.getView();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
islandManager.sync();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.start();
|
||||
})();
|
||||
229
arma/client/addons/org/ui/src/bridge.js
Normal file
229
arma/client/addons/org/ui/src/bridge.js
Normal file
@ -0,0 +1,229 @@
|
||||
(function () {
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const store = RegistryApp.store;
|
||||
const bridge = window.ForgeWebUI.createBridge({
|
||||
closeEvent: "org::close",
|
||||
globalName: "ForgeBridge",
|
||||
readyEvent: "org::ready",
|
||||
});
|
||||
|
||||
function sendEvent(event, data) {
|
||||
return bridge.send(event, data);
|
||||
}
|
||||
|
||||
function requestLogin(credentials) {
|
||||
store.startLogin();
|
||||
|
||||
const sent = sendEvent("org::login::request", credentials);
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.failLogin("Arma login bridge is unavailable.");
|
||||
}
|
||||
|
||||
function requestCreateOrg(registration) {
|
||||
store.startCreate();
|
||||
|
||||
const sent = sendEvent("org::create::request", registration);
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.failCreate("Arma registration bridge is unavailable.");
|
||||
}
|
||||
|
||||
function requestDisbandOrg() {
|
||||
const sent = sendEvent("org::disband::request", {});
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma disband bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestLeaveOrg() {
|
||||
const sent = sendEvent("org::leave::request", {});
|
||||
if (sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma leave bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestCreditLine(payload) {
|
||||
const sent = sendEvent("org::credit::request", payload);
|
||||
if (sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
"Arma credit line bridge is unavailable.",
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bridge.on("org::login::success", (payloadData) => {
|
||||
store.completeLogin(payloadData);
|
||||
});
|
||||
|
||||
bridge.on("org::login::failure", (payloadData) => {
|
||||
store.failLogin(payloadData.message || "Authentication failed.");
|
||||
});
|
||||
|
||||
bridge.on("org::create::success", (payloadData) => {
|
||||
store.completeCreate(payloadData);
|
||||
});
|
||||
|
||||
bridge.on("org::create::failure", (payloadData) => {
|
||||
store.failCreate(
|
||||
payloadData.message || "Organization registration failed.",
|
||||
);
|
||||
});
|
||||
|
||||
bridge.on("org::sync", (payloadData) => {
|
||||
if (store && typeof store.hydratePortal === "function") {
|
||||
store.hydratePortal(payloadData);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::credit::success", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"success",
|
||||
payloadData.message || "Credit line assigned.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::credit::failure", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Unable to assign credit line.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::member::creditUpdated", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (!OrgPortal || !OrgPortal.store) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrgPortal.store.setCreditLines((currentLines) => {
|
||||
const nextLine = {
|
||||
amount: payloadData.amount || 0,
|
||||
member: payloadData.memberName || "",
|
||||
uid: payloadData.memberUid || "",
|
||||
};
|
||||
const matchIndex = currentLines.findIndex(
|
||||
(line) => line.uid === nextLine.uid,
|
||||
);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return [...currentLines, nextLine];
|
||||
}
|
||||
|
||||
return currentLines.map((line, index) =>
|
||||
index === matchIndex ? nextLine : line,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
bridge.on("org::disband::success", () => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
OrgPortal.store.setOrgDisbanded(true);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::disband::failure", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Organization disbanding failed.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::leave::success", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
store.failLogin(
|
||||
payloadData.message || "You have left the organization.",
|
||||
);
|
||||
store.setView("home");
|
||||
});
|
||||
|
||||
bridge.on("org::leave::failure", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
if (OrgPortal && OrgPortal.actions) {
|
||||
OrgPortal.actions.showTreasuryNotice(
|
||||
"error",
|
||||
payloadData.message || "Unable to leave the organization.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("org::portal::revoked", (payloadData) => {
|
||||
const OrgPortal = window.OrgPortal;
|
||||
if (OrgPortal && OrgPortal.store) {
|
||||
OrgPortal.store.setModal(null);
|
||||
}
|
||||
|
||||
store.failLogin(
|
||||
payloadData.message ||
|
||||
"Organization access is no longer available.",
|
||||
);
|
||||
store.setView("home");
|
||||
});
|
||||
|
||||
RegistryApp.bridge = {
|
||||
close: bridge.close,
|
||||
ready: bridge.ready,
|
||||
receive: bridge.receive,
|
||||
requestLogin,
|
||||
requestCreateOrg,
|
||||
requestDisbandOrg,
|
||||
requestLeaveOrg,
|
||||
requestCreditLine,
|
||||
sendEvent,
|
||||
};
|
||||
})();
|
||||
@ -5,64 +5,11 @@
|
||||
|
||||
RegistryApp.components = RegistryApp.components || {};
|
||||
|
||||
function WindowTitleBar({ title, onClose }) {
|
||||
return h(
|
||||
"div",
|
||||
{ className: "window-titlebar" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-brand" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "window-titlebar-kicker" },
|
||||
"ORBIS Workspace",
|
||||
),
|
||||
h("span", { className: "window-titlebar-title" }, title),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Minimize unavailable",
|
||||
"aria-label": "Minimize unavailable",
|
||||
},
|
||||
"-",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Maximize unavailable",
|
||||
"aria-label": "Maximize unavailable",
|
||||
},
|
||||
"[ ]",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn is-close",
|
||||
onClick: onClose,
|
||||
title: "Close",
|
||||
"aria-label": "Close organization interface",
|
||||
},
|
||||
"X",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
RegistryApp.components.App = function App() {
|
||||
const Navbar = window.SharedUI.componentFns.Navbar;
|
||||
const Header = window.SharedUI.componentFns.Header;
|
||||
const Footer = window.SharedUI.componentFns.Footer;
|
||||
const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar;
|
||||
const HomeView = RegistryApp.componentFns.HomeView;
|
||||
const RegistrationView = RegistryApp.componentFns.RegistrationView;
|
||||
const PortalApp =
|
||||
@ -108,15 +55,10 @@
|
||||
|
||||
function closeRegistry() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
RegistryApp.bridge &&
|
||||
typeof RegistryApp.bridge.close === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event: "org::close",
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
RegistryApp.bridge.close({});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -133,8 +75,10 @@
|
||||
"div",
|
||||
{ className: "app-shell" },
|
||||
WindowTitleBar({
|
||||
kicker: "ORBIS Workspace",
|
||||
title: "Global Organization Network",
|
||||
onClose: closeRegistry,
|
||||
closeLabel: "Close organization interface",
|
||||
}),
|
||||
Navbar({
|
||||
title: "Global Organization Network",
|
||||
@ -147,7 +91,7 @@
|
||||
? () => portalActions.openModal("leave")
|
||||
: null,
|
||||
}),
|
||||
PortalApp(),
|
||||
h("div", { id: "org-portal-frame-root" }),
|
||||
);
|
||||
}
|
||||
|
||||
@ -162,8 +106,10 @@
|
||||
"div",
|
||||
{ className: "app-shell" },
|
||||
WindowTitleBar({
|
||||
kicker: "ORBIS Workspace",
|
||||
title: "Global Organization Network",
|
||||
onClose: closeRegistry,
|
||||
closeLabel: "Close organization interface",
|
||||
}),
|
||||
h(
|
||||
"main",
|
||||
@ -5,6 +5,13 @@
|
||||
const scopeAttr = "data-ui-panel-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const panelCardCss = `
|
||||
${scopeSelector} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@ -13,6 +20,13 @@ ${scopeSelector} .org-panel-head {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-eyebrow {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
@ -77,7 +91,7 @@ ${scopeSelector} .org-panel-subtitle {
|
||||
),
|
||||
headerExtras,
|
||||
),
|
||||
body,
|
||||
h("div", { className: "org-panel-body" }, body),
|
||||
);
|
||||
};
|
||||
})();
|
||||
@ -51,6 +51,7 @@ ${scopeSelector} .org-activity-time {
|
||||
|
||||
OrgPortal.componentFns.ActivityCard = function ActivityCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const activity = OrgPortal.store.getActivity();
|
||||
ensureScopedStyle("portal-activity-card", activityCardCss);
|
||||
|
||||
return PanelCard({
|
||||
@ -61,7 +62,7 @@ ${scopeSelector} .org-activity-time {
|
||||
body: h(
|
||||
"div",
|
||||
{ className: "org-activity-list" },
|
||||
...portalData.activity.map((item) =>
|
||||
...activity.map((item) =>
|
||||
h(
|
||||
"article",
|
||||
{ className: "org-activity-row" },
|
||||
@ -58,6 +58,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
OrgPortal.componentFns.AssetsCard = function AssetsCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const SimpleStat = OrgPortal.componentFns.SimpleStat;
|
||||
const assets = OrgPortal.store.getAssets();
|
||||
ensureScopedStyle("portal-assets-card", assetsCardCss);
|
||||
|
||||
return PanelCard({
|
||||
@ -68,7 +69,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
body: h(
|
||||
"div",
|
||||
{ className: "org-simple-list" },
|
||||
...portalData.assets.map((asset) =>
|
||||
...assets.map((asset) =>
|
||||
h(
|
||||
"article",
|
||||
{ className: "org-simple-row" },
|
||||
@ -18,11 +18,6 @@ ${scopeSelector} .org-simple-list {
|
||||
scrollbar-color: #94a3b8 #e2e8f0;
|
||||
}
|
||||
|
||||
${scopeSelector} {
|
||||
min-height: 32.5rem;
|
||||
max-height: 32.5rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-simple-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -63,6 +58,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
OrgPortal.componentFns.FleetCard = function FleetCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const SimpleStat = OrgPortal.componentFns.SimpleStat;
|
||||
const fleet = OrgPortal.store.getFleet();
|
||||
ensureScopedStyle("portal-fleet-card", fleetCardCss);
|
||||
|
||||
return PanelCard({
|
||||
@ -74,7 +70,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
body: h(
|
||||
"div",
|
||||
{ className: "org-simple-list" },
|
||||
...portalData.fleet.map((unit) =>
|
||||
...fleet.map((unit) =>
|
||||
h(
|
||||
"article",
|
||||
{ className: "org-simple-row" },
|
||||
@ -77,6 +77,11 @@ ${scopeSelector} .org-metric-grid {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const readiness = getters.getAssetReadiness();
|
||||
const headquarters = portalData.org.headquarters || "ArmA Verse";
|
||||
const assetCount = store.getAssets().length;
|
||||
const fleetCount = store.getFleet().length;
|
||||
const funds = store.getFunds();
|
||||
const memberCount = store.getMembers().length;
|
||||
const reputation = store.getReputation();
|
||||
ensureScopedStyle("portal-overview-card", overviewCardCss);
|
||||
|
||||
return PanelCard({
|
||||
@ -126,7 +131,7 @@ ${scopeSelector} .org-metric-grid {
|
||||
h(
|
||||
"span",
|
||||
{ className: "org-meta-value" },
|
||||
`${store.getMembers().length} total`,
|
||||
`${memberCount} total`,
|
||||
),
|
||||
),
|
||||
h(
|
||||
@ -150,22 +155,22 @@ ${scopeSelector} .org-metric-grid {
|
||||
{ className: "org-metric-grid" },
|
||||
MetricCard(
|
||||
"Org Funds",
|
||||
getters.formatCurrency(store.getFunds()),
|
||||
getters.formatCurrency(funds),
|
||||
"Organization treasury balance",
|
||||
),
|
||||
MetricCard(
|
||||
"Reputation",
|
||||
portalData.reputation,
|
||||
reputation,
|
||||
"Organization standing",
|
||||
),
|
||||
MetricCard(
|
||||
"Asset Lines",
|
||||
portalData.assets.length,
|
||||
assetCount,
|
||||
"Tracked supply and equipment entries",
|
||||
),
|
||||
MetricCard(
|
||||
"Fleet Vehicles",
|
||||
portalData.fleet.length,
|
||||
fleetCount,
|
||||
"Tracked air, ground, and naval vehicles",
|
||||
),
|
||||
),
|
||||
@ -145,6 +145,18 @@ ${scopeSelector} .org-credit-lines-list {
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-treasury-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 0.35rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 #e2e8f0;
|
||||
}
|
||||
|
||||
${scopeSelector} .org-credit-line-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -199,6 +211,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const creditLines = store.getCreditLines();
|
||||
const reputation = store.getReputation();
|
||||
const allowTreasuryActions = getters.canManageTreasury();
|
||||
const activeTab = getTreasuryTab();
|
||||
const isMenuOpen = getTreasuryMenuOpen();
|
||||
@ -209,9 +222,9 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
ensureScopedStyle("portal-treasury-card", treasuryCardCss);
|
||||
|
||||
return PanelCard({
|
||||
className: "org-span-5",
|
||||
className: "org-scroll-panel org-span-5",
|
||||
title: "Treasury",
|
||||
subtitle: "Organization funds, reputation, and member payouts.",
|
||||
subtitle: "Organization funds, reputation and payouts.",
|
||||
headerExtras: h(
|
||||
"div",
|
||||
{ className: "org-treasury-menu" },
|
||||
@ -280,7 +293,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
rootProps: { [scopeAttr]: "" },
|
||||
body: h(
|
||||
"div",
|
||||
null,
|
||||
{ className: "org-treasury-body" },
|
||||
activeTab === "credit"
|
||||
? creditLines.length > 0
|
||||
? h(
|
||||
@ -364,7 +377,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
{ className: "org-meta-label" },
|
||||
"Reputation",
|
||||
),
|
||||
h("strong", null, `${portalData.reputation}`),
|
||||
h("strong", null, `${reputation}`),
|
||||
),
|
||||
),
|
||||
allowTreasuryActions
|
||||
300
arma/client/addons/org/ui/src/portal/actions.js
Normal file
300
arma/client/addons/org/ui/src/portal/actions.js
Normal file
@ -0,0 +1,300 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { portalData } = OrgPortal.data;
|
||||
const store = OrgPortal.store;
|
||||
const getters = OrgPortal.getters;
|
||||
const registryStore = window.RegistryApp.store;
|
||||
|
||||
class OrgPortalActions {
|
||||
constructor() {
|
||||
this.treasuryNoticeTimer = null;
|
||||
}
|
||||
|
||||
showTreasuryNotice(type, text) {
|
||||
store.setTreasuryNotice({ type, text });
|
||||
|
||||
if (this.treasuryNoticeTimer) {
|
||||
clearTimeout(this.treasuryNoticeTimer);
|
||||
}
|
||||
|
||||
this.treasuryNoticeTimer = setTimeout(() => {
|
||||
store.setTreasuryNotice({ type: "", text: "" });
|
||||
this.treasuryNoticeTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
parseAmount(value) {
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? Math.round(amount) : 0;
|
||||
}
|
||||
|
||||
getInputValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
closePortal() {
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (bridge && typeof bridge.close === "function") {
|
||||
bridge.close({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (registryStore) {
|
||||
registryStore.setView("home");
|
||||
}
|
||||
}
|
||||
|
||||
openModal(type) {
|
||||
if (
|
||||
(type === "payroll" ||
|
||||
type === "transfer" ||
|
||||
type === "credit") &&
|
||||
!getters.canManageTreasury()
|
||||
) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "disband" && !getters.canDisbandOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "leave" && !getters.canLeaveOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.setModal({ type });
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
store.setModal(null);
|
||||
}
|
||||
|
||||
removeMember(member) {
|
||||
if (!getters.canManageMembers()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getters.isProtectedMember(member)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const memberUid = getters.getMemberUid(member);
|
||||
const memberName = getters.getMemberName(member);
|
||||
|
||||
store.setMembers((currentMembers) =>
|
||||
currentMembers.filter((entry) =>
|
||||
memberUid
|
||||
? entry.uid !== memberUid
|
||||
: entry.name !== memberName,
|
||||
),
|
||||
);
|
||||
store.setCreditLines((currentLines) =>
|
||||
currentLines.filter((line) =>
|
||||
memberUid
|
||||
? line.uid !== memberUid
|
||||
: line.member !== memberName,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
disbandOrganization() {
|
||||
if (!getters.canDisbandOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestDisbandOrg !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Disband bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
bridge.requestDisbandOrg();
|
||||
return true;
|
||||
}
|
||||
|
||||
leaveOrganization() {
|
||||
if (!getters.canLeaveOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestLeaveOrg !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Leave bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
bridge.requestLeaveOrg();
|
||||
return true;
|
||||
}
|
||||
|
||||
runPayroll(amountPerMember) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const members = store.getMembers();
|
||||
const funds = store.getFunds();
|
||||
|
||||
if (members.length === 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"No members available for payroll.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amountPerMember <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid payroll amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const total = amountPerMember * members.length;
|
||||
if (total > funds) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Insufficient org funds for payroll.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setFunds(funds - total);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
sendFundsToMember(memberName, amount) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const funds = store.getFunds();
|
||||
|
||||
if (!memberName) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Select a member to receive funds.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid transfer amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount > funds) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Insufficient org funds for this transfer.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setFunds(funds - amount);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`${getters.formatCurrency(amount)} sent to ${memberName}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
grantCreditLine(memberUid, amount) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!memberUid) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Select a member for the credit line.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Enter a valid credit line amount.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const member = store
|
||||
.getMembers()
|
||||
.find((entry) => getters.getMemberUid(entry) === memberUid);
|
||||
const memberName = member ? getters.getMemberName(member) : "";
|
||||
|
||||
if (!memberName) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Selected member was not found in the organization roster.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = window.RegistryApp
|
||||
? window.RegistryApp.bridge
|
||||
: null;
|
||||
|
||||
if (!bridge || typeof bridge.requestCreditLine !== "function") {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Credit line bridge is unavailable.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return bridge.requestCreditLine({
|
||||
memberUid,
|
||||
memberName,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
OrgPortal.actions = new OrgPortalActions();
|
||||
})();
|
||||
178
arma/client/addons/org/ui/src/portal/getters.js
Normal file
178
arma/client/addons/org/ui/src/portal/getters.js
Normal file
@ -0,0 +1,178 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { portalData, session } = OrgPortal.data;
|
||||
|
||||
class OrgPortalGetters {
|
||||
formatCurrency(value) {
|
||||
return "$" + Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
formatVehicleType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatAssetType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatDisplayName(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (!part) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
part.charAt(0).toUpperCase() +
|
||||
part.slice(1).toLowerCase()
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
getAssetReadiness() {
|
||||
const fleet = OrgPortal.store
|
||||
? OrgPortal.store.getFleet()
|
||||
: portalData.fleet;
|
||||
if (fleet.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = fleet.reduce(
|
||||
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
|
||||
0,
|
||||
);
|
||||
return Math.round(total / fleet.length);
|
||||
}
|
||||
|
||||
getNormalizedRole() {
|
||||
return String(session.role || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
isDefaultOrg() {
|
||||
return (
|
||||
portalData.org.isDefault === true ||
|
||||
String(portalData.org.tag || "")
|
||||
.trim()
|
||||
.toUpperCase() === "DEFAULT"
|
||||
);
|
||||
}
|
||||
|
||||
isOrgOwner() {
|
||||
const ownerUid = String(
|
||||
portalData.org.ownerUid || portalData.org.owner || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ownerUid && actorUid) {
|
||||
return actorUid === ownerUid;
|
||||
}
|
||||
|
||||
return (
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isSessionCeo() {
|
||||
return session.ceo === true;
|
||||
}
|
||||
|
||||
isOrgLeaderOrCeo() {
|
||||
return (
|
||||
this.isOrgOwner() ||
|
||||
this.getNormalizedRole() === "LEADER" ||
|
||||
(this.isDefaultOrg() && this.isSessionCeo())
|
||||
);
|
||||
}
|
||||
|
||||
canManageMembers() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canManageTreasury() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canDisbandOrg() {
|
||||
return this.isOrgOwner() && !this.isDefaultOrg();
|
||||
}
|
||||
|
||||
canLeaveOrg() {
|
||||
return !this.isDefaultOrg() && !this.isOrgOwner();
|
||||
}
|
||||
|
||||
getMemberName(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.name || "");
|
||||
}
|
||||
|
||||
return String(member || "");
|
||||
}
|
||||
|
||||
getMemberUid(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.uid || "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
isOwnerMember(member) {
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentMember(member) {
|
||||
const memberUid = this.getMemberUid(member).trim().toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (memberUid && actorUid) {
|
||||
return memberUid === actorUid;
|
||||
}
|
||||
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isProtectedMember(member) {
|
||||
return this.isOwnerMember(member) || this.isCurrentMember(member);
|
||||
}
|
||||
}
|
||||
|
||||
OrgPortal.getters = new OrgPortalGetters();
|
||||
})();
|
||||
49
arma/client/addons/org/ui/src/portal/store.js
Normal file
49
arma/client/addons/org/ui/src/portal/store.js
Normal file
@ -0,0 +1,49 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { createSignal } = window.RegistryApp.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
|
||||
class OrgPortalStore {
|
||||
constructor() {
|
||||
[this.getFunds, this.setFunds] = createSignal(portalData.funds);
|
||||
[this.getReputation, this.setReputation] = createSignal(
|
||||
portalData.reputation,
|
||||
);
|
||||
[this.getMembers, this.setMembers] = createSignal([
|
||||
...portalData.members,
|
||||
]);
|
||||
[this.getCreditLines, this.setCreditLines] = createSignal([
|
||||
...portalData.creditLines,
|
||||
]);
|
||||
[this.getFleet, this.setFleet] = createSignal([
|
||||
...portalData.fleet,
|
||||
]);
|
||||
[this.getAssets, this.setAssets] = createSignal([
|
||||
...portalData.assets,
|
||||
]);
|
||||
[this.getActivity, this.setActivity] = createSignal([
|
||||
...portalData.activity,
|
||||
]);
|
||||
[this.getTreasuryNotice, this.setTreasuryNotice] = createSignal({
|
||||
type: "",
|
||||
text: "",
|
||||
});
|
||||
[this.getModal, this.setModal] = createSignal(null);
|
||||
[this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false);
|
||||
}
|
||||
|
||||
hydrateFromPayload(payload) {
|
||||
const nextPortalData = payload.portalData || {};
|
||||
|
||||
this.setFunds(nextPortalData.funds || 0);
|
||||
this.setReputation(nextPortalData.reputation || 0);
|
||||
this.setMembers([...(nextPortalData.members || [])]);
|
||||
this.setCreditLines([...(nextPortalData.creditLines || [])]);
|
||||
this.setFleet([...(nextPortalData.fleet || [])]);
|
||||
this.setAssets([...(nextPortalData.assets || [])]);
|
||||
this.setActivity([...(nextPortalData.activity || [])]);
|
||||
}
|
||||
}
|
||||
|
||||
OrgPortal.store = new OrgPortalStore();
|
||||
})();
|
||||
91
arma/client/addons/org/ui/src/registry/store.js
Normal file
91
arma/client/addons/org/ui/src/registry/store.js
Normal file
@ -0,0 +1,91 @@
|
||||
(function () {
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const { createSignal } = RegistryApp.runtime;
|
||||
|
||||
class RegistryStore {
|
||||
constructor() {
|
||||
[this.getView, this.setView] = createSignal("home");
|
||||
[this.getIsAuthenticating, this.setIsAuthenticating] =
|
||||
createSignal(false);
|
||||
[this.getLoginError, this.setLoginError] = createSignal("");
|
||||
[this.getIsCreating, this.setIsCreating] = createSignal(false);
|
||||
[this.getCreateError, this.setCreateError] = createSignal("");
|
||||
}
|
||||
|
||||
startLogin() {
|
||||
this.setLoginError("");
|
||||
this.setIsAuthenticating(true);
|
||||
}
|
||||
|
||||
startCreate() {
|
||||
this.setCreateError("");
|
||||
this.setIsCreating(true);
|
||||
}
|
||||
|
||||
failLogin(message) {
|
||||
this.setIsAuthenticating(false);
|
||||
this.setLoginError(message || "Authentication failed.");
|
||||
}
|
||||
|
||||
failCreate(message) {
|
||||
this.setIsCreating(false);
|
||||
this.setCreateError(message || "Organization registration failed.");
|
||||
}
|
||||
|
||||
hydratePortal(payload) {
|
||||
const portalApi =
|
||||
window.OrgPortal && window.OrgPortal.data
|
||||
? window.OrgPortal.data
|
||||
: null;
|
||||
const portalStore =
|
||||
window.OrgPortal && window.OrgPortal.store
|
||||
? window.OrgPortal.store
|
||||
: null;
|
||||
const portalData =
|
||||
payload && payload.portalData ? payload.portalData : null;
|
||||
const sessionData =
|
||||
payload && payload.session ? payload.session : null;
|
||||
|
||||
if (
|
||||
!portalApi ||
|
||||
typeof portalApi.applyLoginPayload !== "function" ||
|
||||
!portalStore ||
|
||||
typeof portalStore.hydrateFromPayload !== "function" ||
|
||||
!portalData ||
|
||||
!sessionData
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
portalApi.applyLoginPayload(payload);
|
||||
portalStore.hydrateFromPayload(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
completeLogin(payload) {
|
||||
if (!this.hydratePortal(payload)) {
|
||||
this.failLogin("Login response was missing portal data.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoginError("");
|
||||
this.setIsAuthenticating(false);
|
||||
this.setView("portal");
|
||||
}
|
||||
|
||||
completeCreate(payload) {
|
||||
if (!this.hydratePortal(payload)) {
|
||||
this.failCreate(
|
||||
"Organization registration response was missing portal data.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCreateError("");
|
||||
this.setIsCreating(false);
|
||||
this.setView("portal");
|
||||
}
|
||||
}
|
||||
|
||||
RegistryApp.store = new RegistryStore();
|
||||
})();
|
||||
9
arma/client/addons/org/ui/src/runtime.js
Normal file
9
arma/client/addons/org/ui/src/runtime.js
Normal file
@ -0,0 +1,9 @@
|
||||
(function () {
|
||||
const runtime = window.ForgeWebUI;
|
||||
const RegistryApp = (window.RegistryApp = window.RegistryApp || {});
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
|
||||
RegistryApp.runtime = runtime;
|
||||
OrgPortal.runtime = runtime;
|
||||
window.AppRuntime = runtime;
|
||||
})();
|
||||
280
arma/client/addons/org/ui/src/styles.css
Normal file
280
arma/client/addons/org/ui/src/styles.css
Normal file
@ -0,0 +1,280 @@
|
||||
:root {
|
||||
--bg-app: #fdfcf8;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-hover: #f1f5f9;
|
||||
--primary: #475569;
|
||||
--primary-hover: #1e293b;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #64748b;
|
||||
--text-inverse: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--footer-bg: #1e293b;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#org-portal-frame-root {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.8rem;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
background: var(--footer-bg);
|
||||
color: var(--text-inverse);
|
||||
display: block;
|
||||
|
||||
.wrapper {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #475569;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.org-secondary-btn {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.org-danger-btn {
|
||||
background: #7f1d1d;
|
||||
color: #fef2f2;
|
||||
|
||||
&:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.org-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.org-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.org-page-header {
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.org-page-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.org-page-kicker {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-page-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer .wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.org-page-heading {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,10 @@
|
||||
ensureScopedStyle(
|
||||
"portal-view",
|
||||
`
|
||||
${portalViewScope} {
|
||||
--org-row-card-max-height: 36rem;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-toast-stack {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
@ -46,6 +50,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-panel {
|
||||
@ -56,10 +61,23 @@
|
||||
${portalViewScope} .org-scroll-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 31rem;
|
||||
min-height: 0;
|
||||
max-height: var(--org-row-card-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-island-root {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-island-root > .org-panel {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-span-12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
@ -93,26 +111,47 @@
|
||||
${portalViewScope} .org-span-5 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
${portalViewScope} .org-scroll-panel {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
OrgPortal.components = OrgPortal.components || {};
|
||||
OrgPortal.componentFns = OrgPortal.componentFns || {};
|
||||
|
||||
OrgPortal.componentFns.TreasuryNoticeLayer =
|
||||
function TreasuryNoticeLayer() {
|
||||
const treasuryNotice = store.getTreasuryNotice();
|
||||
if (!treasuryNotice.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ className: "org-toast-stack" },
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
treasuryNotice.type === "error"
|
||||
? "org-toast is-error"
|
||||
: "org-toast is-success",
|
||||
},
|
||||
treasuryNotice.text,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
OrgPortal.components.App = function App() {
|
||||
const Hero = window.SharedUI.componentFns.Hero;
|
||||
const Footer = window.SharedUI.componentFns.Footer;
|
||||
const OverviewCard = OrgPortal.componentFns.OverviewCard;
|
||||
const FleetCard = OrgPortal.componentFns.FleetCard;
|
||||
const TreasuryCard = OrgPortal.componentFns.TreasuryCard;
|
||||
const MembersCard = OrgPortal.componentFns.MembersCard;
|
||||
const AssetsCard = OrgPortal.componentFns.AssetsCard;
|
||||
const ActivityCard = OrgPortal.componentFns.ActivityCard;
|
||||
const FutureCard = OrgPortal.componentFns.FutureCard;
|
||||
const DangerCard = OrgPortal.componentFns.DangerCard;
|
||||
const ModalLayer = OrgPortal.componentFns.ModalLayer;
|
||||
const DisbandedView = OrgPortal.componentFns.DisbandedView;
|
||||
const treasuryNotice = store.getTreasuryNotice();
|
||||
const footerSections = [
|
||||
{
|
||||
title: "Organization Controls",
|
||||
@ -133,7 +172,6 @@
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (store.getOrgDisbanded()) {
|
||||
return h(
|
||||
"main",
|
||||
@ -153,7 +191,7 @@
|
||||
DisbandedView(),
|
||||
),
|
||||
),
|
||||
ModalLayer(),
|
||||
h("div", { id: "org-portal-modal-root" }),
|
||||
Footer({ sections: footerSections }),
|
||||
);
|
||||
}
|
||||
@ -161,22 +199,7 @@
|
||||
return h(
|
||||
"main",
|
||||
{ "data-ui-portal-view": "" },
|
||||
treasuryNotice.text
|
||||
? h(
|
||||
"div",
|
||||
{ className: "org-toast-stack" },
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
treasuryNotice.type === "error"
|
||||
? "org-toast is-error"
|
||||
: "org-toast is-success",
|
||||
},
|
||||
treasuryNotice.text,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
h("div", { id: "org-portal-toast-root" }),
|
||||
h(
|
||||
"div",
|
||||
{ className: "container" },
|
||||
@ -189,17 +212,35 @@
|
||||
subtitle: "Player organization command portal",
|
||||
meta: `${session.actorName} - ${session.role}`,
|
||||
}),
|
||||
OverviewCard(),
|
||||
FleetCard(),
|
||||
TreasuryCard(),
|
||||
MembersCard(),
|
||||
AssetsCard(),
|
||||
ActivityCard(),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-12",
|
||||
id: "org-overview-card-root",
|
||||
}),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-7",
|
||||
id: "org-fleet-card-root",
|
||||
}),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-5",
|
||||
id: "org-treasury-card-root",
|
||||
}),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-5",
|
||||
id: "org-members-card-root",
|
||||
}),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-7",
|
||||
id: "org-assets-card-root",
|
||||
}),
|
||||
h("div", {
|
||||
className: "org-island-root org-span-6",
|
||||
id: "org-activity-card-root",
|
||||
}),
|
||||
FutureCard(),
|
||||
DangerCard(),
|
||||
),
|
||||
),
|
||||
ModalLayer(),
|
||||
h("div", { id: "org-portal-modal-root" }),
|
||||
Footer({ sections: footerSections }),
|
||||
);
|
||||
};
|
||||
56
arma/client/addons/org/ui/ui.config.mjs
Normal file
56
arma/client/addons/org/ui/ui.config.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
export default {
|
||||
addonName: "org",
|
||||
title: "ORBIS - Global Organization Network",
|
||||
logLabel: "Org UI",
|
||||
outputDir: "_site",
|
||||
jsBundles: [
|
||||
{
|
||||
name: "Org UI app",
|
||||
output: "org-ui.js",
|
||||
sources: [
|
||||
"src/runtime.js",
|
||||
"src/registry/store.js",
|
||||
"src/bridge.js",
|
||||
"src/portal/data.js",
|
||||
"src/portal/store.js",
|
||||
"src/portal/getters.js",
|
||||
"src/portal/actions.js",
|
||||
"src/components/navbar.js",
|
||||
"src/components/header.js",
|
||||
"src/components/hero.js",
|
||||
"src/components/footer.js",
|
||||
"src/components/modal.js",
|
||||
"src/components/panelCard.js",
|
||||
"src/components/portal/metricCard.js",
|
||||
"src/components/portal/simpleStat.js",
|
||||
"src/components/portal/overviewCard.js",
|
||||
"src/components/portal/fleetCard.js",
|
||||
"src/components/portal/treasuryCard.js",
|
||||
"src/components/portal/assetsCard.js",
|
||||
"src/components/portal/membersCard.js",
|
||||
"src/components/portal/activityCard.js",
|
||||
"src/components/portal/futureCard.js",
|
||||
"src/components/portal/dangerCard.js",
|
||||
"src/components/portal/modalLayer.js",
|
||||
"src/views/DisbandedView.js",
|
||||
"src/views/PortalView.js",
|
||||
"src/views/RegistrationView.js",
|
||||
"src/views/HomeView.js",
|
||||
"src/components/AppShell.js",
|
||||
"src/bootstrap.js",
|
||||
],
|
||||
},
|
||||
],
|
||||
cssBundles: [
|
||||
{
|
||||
name: "Org UI styles",
|
||||
output: "org-ui.css",
|
||||
sources: ["src/styles.css"],
|
||||
},
|
||||
],
|
||||
site: {
|
||||
styles: ["org-ui.css"],
|
||||
commonScripts: ["forge-webui.js"],
|
||||
scripts: ["org-ui.js"],
|
||||
},
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
PREP(buildStoreUIPayload);
|
||||
PREP(handleUIEvents);
|
||||
PREP(initStoreCatalogService);
|
||||
PREP(initStoreClass);
|
||||
PREP(initStoreUIBridge);
|
||||
PREP(openUI);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "script_component.hpp"
|
||||
|
||||
if (isNil QGVAR(StoreCatalogService)) then { call FUNC(initStoreCatalogService); };
|
||||
if (isNil QGVAR(StoreClass)) then { call FUNC(initStoreClass); };
|
||||
if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initStoreUIBridge); };
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ class CfgPatches {
|
||||
name = COMPONENT_NAME;
|
||||
requiredVersion = REQUIRED_VERSION;
|
||||
requiredAddons[] = {
|
||||
"forge_client_common",
|
||||
"forge_client_main"
|
||||
};
|
||||
units[] = {};
|
||||
|
||||
125
arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf
Normal file
125
arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf
Normal file
@ -0,0 +1,125 @@
|
||||
#include "..\script_component.hpp"
|
||||
|
||||
/*
|
||||
* File: fnc_buildStoreUIPayload.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-13
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* Builds the browser hydrate payload for the store UI from current client state.
|
||||
*
|
||||
* Arguments:
|
||||
* None
|
||||
*
|
||||
* Return Value:
|
||||
* Store UI payload [HASHMAP]
|
||||
*/
|
||||
|
||||
private _storeState = createHashMap;
|
||||
private _budget = 50000;
|
||||
private _creditLine = 0;
|
||||
private _cashBalance = 0;
|
||||
private _bankBalance = 0;
|
||||
private _orgFunds = 0;
|
||||
private _orgId = "";
|
||||
private _orgName = "";
|
||||
private _orgOwnerUid = "";
|
||||
private _orgCreditLines = createHashMap;
|
||||
private _playerUid = getPlayerUID player;
|
||||
private _playerVar = toLowerANSI (vehicleVarName player);
|
||||
private _isOrgLeader = false;
|
||||
private _isDefaultOrg = false;
|
||||
private _isDefaultOrgCeo = false;
|
||||
|
||||
if !(isNil QGVAR(StoreClass)) then {
|
||||
_storeState = GVAR(StoreClass) call ["getStoreState", []];
|
||||
_budget = _storeState getOrDefault ["budget", _budget];
|
||||
};
|
||||
|
||||
if !(isNil QEGVAR(bank,BankClass)) then {
|
||||
_cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]];
|
||||
_bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]];
|
||||
};
|
||||
|
||||
if !(isNil QEGVAR(org,OrgClass)) then {
|
||||
_orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]];
|
||||
_orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]];
|
||||
_orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]];
|
||||
_orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]];
|
||||
_orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]];
|
||||
_isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" };
|
||||
_isOrgLeader = _orgOwnerUid isEqualTo _playerUid;
|
||||
_isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" };
|
||||
};
|
||||
|
||||
if (_orgCreditLines isEqualType createHashMap) then {
|
||||
private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap];
|
||||
if (_playerCreditLine isEqualType createHashMap) then {
|
||||
_creditLine = _playerCreditLine getOrDefault ["amount", 0];
|
||||
};
|
||||
};
|
||||
|
||||
private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo;
|
||||
private _orgFundsEnabled = _canUseOrgFunds && { _orgFunds > 0 };
|
||||
private _paymentSources = [
|
||||
createHashMapFromArray [
|
||||
["id", "cash"],
|
||||
["label", "Cash"],
|
||||
["balance", _cashBalance],
|
||||
["enabled", _cashBalance > 0],
|
||||
["detail", "Use on-hand cash carried by the player."]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "bank"],
|
||||
["label", "Bank"],
|
||||
["balance", _bankBalance],
|
||||
["enabled", _bankBalance > 0],
|
||||
["detail", "Charge the player bank account."]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "org_funds"],
|
||||
["label", "Org Funds"],
|
||||
["balance", _orgFunds],
|
||||
["enabled", _orgFundsEnabled],
|
||||
["detail", [
|
||||
"Only organization leaders or the default-org CEO can use treasury funds.",
|
||||
[
|
||||
"Charge organization treasury funds.",
|
||||
"No organization funds are currently available."
|
||||
] select _orgFundsEnabled
|
||||
] select _canUseOrgFunds]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "credit_line"],
|
||||
["label", "Credit Line"],
|
||||
["balance", _creditLine],
|
||||
["enabled", _creditLine > 0],
|
||||
["detail", [
|
||||
"No approved credit line is assigned to this member.",
|
||||
"Use the approved procurement credit line."
|
||||
] select (_creditLine > 0)]
|
||||
]
|
||||
];
|
||||
|
||||
createHashMapFromArray [
|
||||
["session", createHashMapFromArray [
|
||||
["actorName", name player],
|
||||
["actorUid", _playerUid],
|
||||
["approval", "Field Access"],
|
||||
["orgId", _orgId],
|
||||
["orgName", _orgName],
|
||||
["orgLeader", _isOrgLeader],
|
||||
["defaultOrgCeo", _isDefaultOrgCeo],
|
||||
["canUseOrgFunds", _canUseOrgFunds]
|
||||
]],
|
||||
["storeConfig", createHashMapFromArray [
|
||||
["budget", _budget],
|
||||
["creditLine", _creditLine],
|
||||
["availability", _storeState getOrDefault ["availability", "In-Stock"]],
|
||||
["moduleState", _storeState getOrDefault ["moduleState", "Preview"]],
|
||||
["paymentSources", _paymentSources],
|
||||
["defaultPaymentSource", "cash"]
|
||||
]],
|
||||
["cartItems", []]
|
||||
]
|
||||
@ -0,0 +1,294 @@
|
||||
#include "..\script_component.hpp"
|
||||
|
||||
/*
|
||||
* File: fnc_initStoreCatalogService.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-13
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* Initializes the store catalog service for category discovery, pricing, and payload shaping.
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreCatalogServiceBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["catalogCache", createHashMap];
|
||||
}],
|
||||
["isVisibleConfig", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]]];
|
||||
|
||||
isClass _cfg
|
||||
&& { getNumber (_cfg >> "scope") >= 2 }
|
||||
&& { (getText (_cfg >> "displayName")) isNotEqualTo "" }
|
||||
}],
|
||||
["buildDescription", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_fallback", "", [""]]];
|
||||
|
||||
private _description = getText (_cfg >> "descriptionShort");
|
||||
if (_description isEqualTo "") then { _description = _fallback; };
|
||||
|
||||
_description
|
||||
}],
|
||||
["formatPriceValue", compileFinal {
|
||||
params [["_priceValue", 0, [0]]];
|
||||
|
||||
format ["$%1", [_priceValue max 0] call BIS_fnc_numberText]
|
||||
}],
|
||||
["calculateItemPrice", compileFinal {
|
||||
params [
|
||||
["_cfg", configNull, [configNull]],
|
||||
["_isVehicle", false, [false]]
|
||||
];
|
||||
|
||||
if (isNull _cfg) exitWith { "$50" };
|
||||
|
||||
private _mass = 0;
|
||||
private _priceValue = 0;
|
||||
|
||||
if (_isVehicle) then {
|
||||
_priceValue = getNumber (_cfg >> "cost");
|
||||
} else {
|
||||
_mass = getNumber (_cfg >> "ItemInfo" >> "mass");
|
||||
if (_mass <= 0) then {
|
||||
_mass = getNumber (_cfg >> "mass");
|
||||
};
|
||||
|
||||
_priceValue = ceil ((_mass max 0) * 0.1);
|
||||
};
|
||||
|
||||
_priceValue = _priceValue max 50;
|
||||
_self call ["formatPriceValue", [_priceValue]]
|
||||
}],
|
||||
["buildItem", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]], ["_imageField", "picture", [""]], ["_isVehicle", false, [false]]];
|
||||
|
||||
if (isNull _cfg) exitWith { createHashMap };
|
||||
|
||||
private _className = configName _cfg;
|
||||
private _displayName = getText (_cfg >> "displayName");
|
||||
private _picture = getText (_cfg >> _imageField);
|
||||
if (_picture isEqualTo "" && { _imageField isNotEqualTo "picture" }) then { _picture = getText (_cfg >> "picture"); };
|
||||
|
||||
createHashMapFromArray [
|
||||
["className", _className],
|
||||
["code", _className],
|
||||
["name", _displayName],
|
||||
["description", _self call ["buildDescription", [_cfg, _fallbackDescription]]],
|
||||
["price", _self call ["calculateItemPrice", [_cfg, _isVehicle]]],
|
||||
["image", _picture],
|
||||
["type", _typeLabel]
|
||||
]
|
||||
}],
|
||||
["appendCfgWeaponsByItemInfoType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "ItemInfo" >> "type") isEqualTo _itemInfoType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgWeaponsByType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_weaponType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "type") isEqualTo _weaponType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgVehiclesByKind", compileFinal {
|
||||
params [["_items", [], [[]]], ["_baseClass", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
&& { _className isKindOf [_baseClass, configFile >> "CfgVehicles"] }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["scanCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
|
||||
private _items = [];
|
||||
|
||||
switch (_categoryKey) do {
|
||||
case "uniforms": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 801, "Uniform", "Live uniform entry generated from the game inventory."]];
|
||||
};
|
||||
case "headgear": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 605, "Headgear", "Live headgear entry generated from the game inventory."]];
|
||||
};
|
||||
case "vests": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 701, "Vest", "Live vest entry generated from the game inventory."]];
|
||||
};
|
||||
case "facewear": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Facewear", "Live facewear entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgGlasses"));
|
||||
};
|
||||
case "ammo": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Magazine", "Live ammunition entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgMagazines"));
|
||||
};
|
||||
case "items": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _itemType = [_className] call BIS_fnc_itemType;
|
||||
private _group = _itemType param [0, ""];
|
||||
private _kind = _itemType param [1, ""];
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _group in ["Item", "Equipment"] }
|
||||
&& { !(_kind in ["Uniform", "Vest", "Headgear"]) }
|
||||
) then {
|
||||
private _typeLabel = [_kind, "Item"] select (_kind isEqualTo "");
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, "Live utility entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
};
|
||||
case "primary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 1, "Primary Weapon", "Live primary weapon entry generated from the game inventory."]];
|
||||
};
|
||||
case "handgun": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 2, "Handgun", "Live sidearm entry generated from the game inventory."]];
|
||||
};
|
||||
case "secondary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 4, "Launcher", "Live launcher entry generated from the game inventory."]];
|
||||
};
|
||||
case "cars": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Car", "Vehicle", "Live wheeled vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "armor": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Tank", "Vehicle", "Live armored vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "helis": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]];
|
||||
};
|
||||
case "planes": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]];
|
||||
};
|
||||
case "naval": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "other": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _isSupportedVehicle = _className isKindOf ["AllVehicles", configFile >> "CfgVehicles"];
|
||||
private _isKnownCategory =
|
||||
_className isKindOf ["Car", configFile >> "CfgVehicles"]
|
||||
|| { _className isKindOf ["Tank", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Helicopter", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Plane", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Ship", configFile >> "CfgVehicles"] };
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _isSupportedVehicle }
|
||||
&& { !_isKnownCategory }
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [
|
||||
_cfg,
|
||||
"Special Vehicle",
|
||||
"Live specialty vehicle entry generated from the game inventory.",
|
||||
"editorPreview",
|
||||
true
|
||||
]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
};
|
||||
};
|
||||
|
||||
private _sortedItems = _items apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] };
|
||||
|
||||
_sortedItems sort true;
|
||||
_sortedItems apply { _x select 1 }
|
||||
}],
|
||||
["isVehicleCategory", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
(toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"]
|
||||
}],
|
||||
["buildPayloadCategory", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
switch (toLowerANSI _category) do {
|
||||
case "ammo": { "magazine" };
|
||||
case "primary";
|
||||
case "secondary";
|
||||
case "handgun": { "weapon" };
|
||||
case "cars";
|
||||
case "armor";
|
||||
case "helis";
|
||||
case "planes";
|
||||
case "naval";
|
||||
case "other": { toLowerANSI _category };
|
||||
default { "item" };
|
||||
}
|
||||
}],
|
||||
["buildCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
|
||||
private _catalogCache = _self getOrDefault ["catalogCache", createHashMap];
|
||||
if (_categoryKey in (keys _catalogCache)) exitWith { _catalogCache get _categoryKey };
|
||||
|
||||
private _items = _self call ["scanCategoryItems", [_categoryKey]];
|
||||
private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]];
|
||||
private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]);
|
||||
|
||||
{
|
||||
_x set ["category", _payloadCategory];
|
||||
_x set ["entryKind", _entryKind];
|
||||
} forEach _items;
|
||||
|
||||
_catalogCache set [_categoryKey, _items];
|
||||
_self set ["catalogCache", _catalogCache];
|
||||
|
||||
_items
|
||||
}]
|
||||
];
|
||||
|
||||
GVAR(StoreCatalogService) = createHashMapObject [GVAR(StoreCatalogServiceBaseClass)];
|
||||
GVAR(StoreCatalogService)
|
||||
@ -25,156 +25,16 @@ GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["uid", getPlayerUID player];
|
||||
_self set ["store", createHashMap];
|
||||
_self set ["workspace", createHashMapFromArray [
|
||||
["budget", 48000],
|
||||
["creditLine", 0],
|
||||
["availability", "Open"],
|
||||
["approval", "Field Access"],
|
||||
["moduleState", "Preview"],
|
||||
["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]
|
||||
_self set ["store", createHashMapFromArray [
|
||||
["budget", 50000],
|
||||
["availability", "In-Stock"],
|
||||
["moduleState", "Preview"]
|
||||
]];
|
||||
_self set ["isLoaded", false];
|
||||
_self set ["lastSave", time];
|
||||
|
||||
systemChat format ["Store class initialized for %1", name player];
|
||||
diag_log "[FORGE:Client:Store] Store Class Initialized!";
|
||||
}],
|
||||
["buildUIPayload", compileFinal {
|
||||
private _workspace = _self getOrDefault ["workspace", createHashMap];
|
||||
private _budget = _workspace getOrDefault ["budget", 48000];
|
||||
private _creditLine = _workspace getOrDefault ["creditLine", 0];
|
||||
private _cashBalance = 0;
|
||||
private _bankBalance = 0;
|
||||
private _orgFunds = 0;
|
||||
private _orgId = "";
|
||||
private _orgName = "";
|
||||
private _orgOwnerUid = "";
|
||||
private _orgCreditLines = createHashMap;
|
||||
private _playerUid = getPlayerUID player;
|
||||
private _playerVar = toLowerANSI (vehicleVarName player);
|
||||
private _isOrgLeader = false;
|
||||
private _isDefaultOrg = false;
|
||||
private _isDefaultOrgCeo = false;
|
||||
|
||||
if !(isNil QEGVAR(bank,BankClass)) then {
|
||||
_cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]];
|
||||
_bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]];
|
||||
};
|
||||
|
||||
if !(isNil QEGVAR(org,OrgClass)) then {
|
||||
_orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]];
|
||||
_orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]];
|
||||
_orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]];
|
||||
_orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]];
|
||||
_orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]];
|
||||
_isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" };
|
||||
_isOrgLeader = _orgOwnerUid isEqualTo _playerUid;
|
||||
_isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" };
|
||||
};
|
||||
|
||||
if (_orgCreditLines isEqualType createHashMap) then {
|
||||
private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap];
|
||||
if (_playerCreditLine isEqualType createHashMap) then {
|
||||
_creditLine = _playerCreditLine getOrDefault ["amount", _creditLine];
|
||||
};
|
||||
};
|
||||
|
||||
private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo;
|
||||
private _orgFundsEnabled = _canUseOrgFunds && {_orgFunds > 0};
|
||||
private _paymentSources = [
|
||||
createHashMapFromArray [
|
||||
["id", "cash"],
|
||||
["label", "Cash"],
|
||||
["balance", _cashBalance],
|
||||
["enabled", _cashBalance > 0],
|
||||
["detail", "Use on-hand cash carried by the player."]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "bank"],
|
||||
["label", "Bank"],
|
||||
["balance", _bankBalance],
|
||||
["enabled", _bankBalance > 0],
|
||||
["detail", "Charge the player bank account."]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "org_funds"],
|
||||
["label", "Org Funds"],
|
||||
["balance", _orgFunds],
|
||||
["enabled", _orgFundsEnabled],
|
||||
["detail", [
|
||||
"Only organization leaders or the default-org CEO can use treasury funds.",
|
||||
[
|
||||
"Charge organization treasury funds.",
|
||||
"No organization funds are currently available."
|
||||
] select _orgFundsEnabled
|
||||
] select _canUseOrgFunds]
|
||||
],
|
||||
createHashMapFromArray [
|
||||
["id", "credit_line"],
|
||||
["label", "Credit Line"],
|
||||
["balance", _creditLine],
|
||||
["enabled", _creditLine > 0],
|
||||
["detail", [
|
||||
"No approved credit line is assigned to this member.",
|
||||
"Use the approved procurement credit line."
|
||||
] select (_creditLine > 0)]
|
||||
]
|
||||
];
|
||||
|
||||
createHashMapFromArray [
|
||||
["session", createHashMapFromArray [
|
||||
["actorName", name player],
|
||||
["actorUid", getPlayerUID player],
|
||||
["approvalRole", _workspace getOrDefault ["approval", "Field Access"]],
|
||||
["orgId", _orgId],
|
||||
["orgName", _orgName],
|
||||
["orgLeader", _isOrgLeader],
|
||||
["defaultOrgCeo", _isDefaultOrgCeo],
|
||||
["canUseOrgFunds", _canUseOrgFunds]
|
||||
]],
|
||||
["workspace", createHashMapFromArray [
|
||||
["budget", _budget],
|
||||
["creditLine", _creditLine],
|
||||
["availability", _workspace getOrDefault ["availability", "Open"]],
|
||||
["approval", _workspace getOrDefault ["approval", "Field Access"]],
|
||||
["moduleState", _workspace getOrDefault ["moduleState", "Preview"]],
|
||||
["searchTags", _workspace getOrDefault ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]],
|
||||
["paymentSources", _paymentSources],
|
||||
["defaultPaymentSource", "cash"]
|
||||
]],
|
||||
["cartItems", []]
|
||||
]
|
||||
}],
|
||||
["formatPriceValue", compileFinal {
|
||||
params [["_priceValue", 0, [0]]];
|
||||
|
||||
format ["$%1", [_priceValue max 0] call BIS_fnc_numberText]
|
||||
}],
|
||||
["calculateItemPrice", compileFinal {
|
||||
params [
|
||||
["_cfg", configNull, [configNull]],
|
||||
["_isVehicle", false, [false]]
|
||||
];
|
||||
|
||||
if (isNull _cfg) exitWith { "$50" };
|
||||
|
||||
private _mass = 0;
|
||||
private _priceValue = 0;
|
||||
|
||||
if (_isVehicle) then {
|
||||
_priceValue = getNumber (_cfg >> "cost");
|
||||
} else {
|
||||
_mass = getNumber (_cfg >> "ItemInfo" >> "mass");
|
||||
if (_mass <= 0) then {
|
||||
_mass = getNumber (_cfg >> "mass");
|
||||
};
|
||||
|
||||
_priceValue = ceil ((_mass max 0) * 0.1);
|
||||
};
|
||||
|
||||
_priceValue = _priceValue max 50;
|
||||
_self call ["formatPriceValue", [_priceValue]]
|
||||
["getStoreState", compileFinal {
|
||||
_self getOrDefault ["store", createHashMap]
|
||||
}]
|
||||
];
|
||||
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreUIBridgeBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["catalogCache", createHashMap];
|
||||
}],
|
||||
["getActiveBrowserControl", compileFinal {
|
||||
private _display = uiNamespace getVariable ["RscStore", displayNull];
|
||||
if (isNull _display) exitWith { controlNull };
|
||||
@ -48,258 +45,10 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["data", _data]
|
||||
]]]
|
||||
}],
|
||||
["isVisibleConfig", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]]];
|
||||
|
||||
isClass _cfg
|
||||
&& { getNumber (_cfg >> "scope") >= 2 }
|
||||
&& { (getText (_cfg >> "displayName")) isNotEqualTo "" }
|
||||
}],
|
||||
["buildDescription", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_fallback", "", [""]]];
|
||||
|
||||
private _description = getText (_cfg >> "descriptionShort");
|
||||
if (_description isEqualTo "") then { _description = _fallback; };
|
||||
|
||||
_description
|
||||
}],
|
||||
["buildItem", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]], ["_imageField", "picture", [""]], ["_isVehicle", false, [false]]];
|
||||
|
||||
if (isNull _cfg) exitWith { createHashMap };
|
||||
|
||||
private _className = configName _cfg;
|
||||
private _displayName = getText (_cfg >> "displayName");
|
||||
private _picture = getText (_cfg >> _imageField);
|
||||
if (_picture isEqualTo "" && { _imageField isNotEqualTo "picture" }) then { _picture = getText (_cfg >> "picture"); };
|
||||
|
||||
createHashMapFromArray [
|
||||
["className", _className],
|
||||
["code", _className],
|
||||
["name", _displayName],
|
||||
["description", _self call ["buildDescription", [_cfg, _fallbackDescription]]],
|
||||
["price", GVAR(StoreClass) call ["calculateItemPrice", [_cfg, _isVehicle]]],
|
||||
["image", _picture],
|
||||
["type", _typeLabel]
|
||||
]
|
||||
}],
|
||||
["appendCfgWeaponsByItemInfoType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "ItemInfo" >> "type") isEqualTo _itemInfoType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgWeaponsByType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_weaponType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "type") isEqualTo _weaponType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgVehiclesByKind", compileFinal {
|
||||
params [["_items", [], [[]]], ["_baseClass", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
&& { _className isKindOf [_baseClass, configFile >> "CfgVehicles"] }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["scanCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
if (isNil QGVAR(StoreClass)) exitWith { [] };
|
||||
|
||||
private _items = [];
|
||||
|
||||
switch (_categoryKey) do {
|
||||
case "uniforms": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 801, "Uniform", "Live uniform entry generated from the game inventory."]];
|
||||
};
|
||||
case "headgear": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 605, "Headgear", "Live headgear entry generated from the game inventory."]];
|
||||
};
|
||||
case "vests": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 701, "Vest", "Live vest entry generated from the game inventory."]];
|
||||
};
|
||||
case "facewear": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Facewear", "Live facewear entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgGlasses"));
|
||||
};
|
||||
case "ammo": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Magazine", "Live ammunition entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgMagazines"));
|
||||
};
|
||||
case "items": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _itemType = [_className] call BIS_fnc_itemType;
|
||||
private _group = _itemType param [0, ""];
|
||||
private _kind = _itemType param [1, ""];
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _group in ["Item", "Equipment"] }
|
||||
&& { !(_kind in ["Uniform", "Vest", "Headgear"]) }
|
||||
) then {
|
||||
private _typeLabel = [_kind, "Item"] select (_kind isEqualTo "");
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, "Live utility entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
};
|
||||
case "primary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 1, "Primary Weapon", "Live primary weapon entry generated from the game inventory."]];
|
||||
};
|
||||
case "handgun": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 2, "Handgun", "Live sidearm entry generated from the game inventory."]];
|
||||
};
|
||||
case "secondary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 4, "Launcher", "Live launcher entry generated from the game inventory."]];
|
||||
};
|
||||
case "cars": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Car", "Vehicle", "Live wheeled vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "armor": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Tank", "Vehicle", "Live armored vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "helis": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]];
|
||||
};
|
||||
case "planes": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]];
|
||||
};
|
||||
case "naval": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "other": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _isSupportedVehicle = _className isKindOf ["AllVehicles", configFile >> "CfgVehicles"];
|
||||
private _isKnownCategory =
|
||||
_className isKindOf ["Car", configFile >> "CfgVehicles"]
|
||||
|| { _className isKindOf ["Tank", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Helicopter", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Plane", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Ship", configFile >> "CfgVehicles"] };
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _isSupportedVehicle }
|
||||
&& { !_isKnownCategory }
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [
|
||||
_cfg,
|
||||
"Special Vehicle",
|
||||
"Live specialty vehicle entry generated from the game inventory.",
|
||||
"editorPreview",
|
||||
true
|
||||
]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
};
|
||||
};
|
||||
|
||||
private _sortedItems = _items apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] };
|
||||
|
||||
_sortedItems sort true;
|
||||
_sortedItems apply { _x select 1 }
|
||||
}],
|
||||
["isVehicleCategory", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
(toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"]
|
||||
}],
|
||||
["buildPayloadCategory", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
switch (toLowerANSI _category) do {
|
||||
case "ammo": { "magazine" };
|
||||
case "primary";
|
||||
case "secondary";
|
||||
case "handgun": { "weapon" };
|
||||
case "cars";
|
||||
case "armor";
|
||||
case "helis";
|
||||
case "planes";
|
||||
case "naval";
|
||||
case "other": { toLowerANSI _category };
|
||||
default { "item" };
|
||||
}
|
||||
}],
|
||||
["buildCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
|
||||
private _catalogCache = _self getOrDefault ["catalogCache", createHashMap];
|
||||
if (_categoryKey in (keys _catalogCache)) exitWith { _catalogCache get _categoryKey };
|
||||
|
||||
private _items = _self call ["scanCategoryItems", [_categoryKey]];
|
||||
private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]];
|
||||
private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]);
|
||||
|
||||
{
|
||||
_x set ["category", _payloadCategory];
|
||||
_x set ["entryKind", _entryKind];
|
||||
} forEach _items;
|
||||
|
||||
_catalogCache set [_categoryKey, _items];
|
||||
_self set ["catalogCache", _catalogCache];
|
||||
|
||||
_items
|
||||
}],
|
||||
["handleReady", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
private _payload = if (isNil QGVAR(StoreClass)) then {
|
||||
createHashMap
|
||||
} else {
|
||||
GVAR(StoreClass) call ["buildUIPayload", []]
|
||||
};
|
||||
|
||||
private _payload = call FUNC(buildStoreUIPayload);
|
||||
_self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]];
|
||||
}],
|
||||
["handleCategoryRequest", compileFinal {
|
||||
@ -312,14 +61,14 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
]]];
|
||||
};
|
||||
|
||||
if (isNil QGVAR(StoreClass)) exitWith {
|
||||
if (isNil QGVAR(StoreCatalogService)) exitWith {
|
||||
_self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [
|
||||
["category", _category],
|
||||
["message", "Store data is unavailable."]
|
||||
["message", "Store catalog is unavailable."]
|
||||
]]];
|
||||
};
|
||||
|
||||
private _items = _self call ["buildCategoryItems", [_category]];
|
||||
private _items = GVAR(StoreCatalogService) call ["buildCategoryItems", [_category]];
|
||||
|
||||
diag_log format ["[FORGE:Client:Store] Category request handled for %1 with %2 item(s).", _category, count _items];
|
||||
|
||||
@ -328,9 +77,9 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["items", _items]
|
||||
]]];
|
||||
}],
|
||||
["refreshWorkspace", compileFinal {
|
||||
private _payload = GVAR(StoreClass) call ["buildUIPayload", []];
|
||||
_self call ["sendBridgeEvent", ["store::workspace::hydrate", _payload]];
|
||||
["refreshStoreConfig", compileFinal {
|
||||
private _payload = call FUNC(buildStoreUIPayload);
|
||||
_self call ["sendBridgeEvent", ["store::config::hydrate", _payload]];
|
||||
}],
|
||||
["handleCheckoutRequest", compileFinal {
|
||||
params [["_data", createHashMap, [createHashMap]]];
|
||||
@ -358,7 +107,7 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
[] spawn {
|
||||
sleep 0.05;
|
||||
if !(isNil QGVAR(StoreUIBridge)) then {
|
||||
GVAR(StoreUIBridge) call ["refreshWorkspace", []];
|
||||
GVAR(StoreUIBridge) call ["refreshStoreConfig", []];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const store = StorefrontApp.store;
|
||||
|
||||
function sendEvent(event, data) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
return sendEvent("store::close", {});
|
||||
}
|
||||
|
||||
function requestCheckout(payload) {
|
||||
return sendEvent("store::checkout::request", payload);
|
||||
}
|
||||
|
||||
function requestCategory(payload) {
|
||||
return sendEvent("store::category::request", payload);
|
||||
}
|
||||
|
||||
function notifyReady() {
|
||||
return sendEvent("store::ready", { loaded: true });
|
||||
}
|
||||
|
||||
function receive(eventOrPayload, data = {}) {
|
||||
const event =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.event
|
||||
: eventOrPayload;
|
||||
const payloadData =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.data || {}
|
||||
: data;
|
||||
|
||||
if (event === "store::hydrate") {
|
||||
StorefrontApp.data.applyHydratePayload(payloadData);
|
||||
store.hydrateFromPayload(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::workspace::hydrate") {
|
||||
StorefrontApp.data.applyHydratePayload(payloadData);
|
||||
store.hydrateWorkspace(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::checkout::success") {
|
||||
store.setIsCheckingOut(false);
|
||||
store.setCartItems([]);
|
||||
store.setCartOpen(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"success",
|
||||
payloadData.message || "Checkout completed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::category::hydrate") {
|
||||
store.hydrateCategoryItems(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::category::failure") {
|
||||
store.finishCategoryRequest(payloadData.category || "");
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Category request failed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::checkout::failure") {
|
||||
store.setIsCheckingOut(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Checkout failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StorefrontApp.bridge = {
|
||||
sendEvent,
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
requestCategory,
|
||||
notifyReady,
|
||||
receive,
|
||||
};
|
||||
|
||||
window.StoreUIBridge = {
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
requestCategory,
|
||||
notifyReady,
|
||||
receive,
|
||||
receiveHydrate: (data) => receive("store::hydrate", data),
|
||||
};
|
||||
})();
|
||||
@ -1,456 +0,0 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
const defaultSession = {
|
||||
actorName: "",
|
||||
actorUid: "",
|
||||
approvalRole: "Field Access",
|
||||
orgId: "",
|
||||
orgName: "",
|
||||
orgLeader: false,
|
||||
defaultOrgCeo: false,
|
||||
canUseOrgFunds: false,
|
||||
};
|
||||
|
||||
const defaultStoreConfig = {
|
||||
budget: 48000,
|
||||
creditLine: 0,
|
||||
availability: "Open",
|
||||
approval: "Field Access",
|
||||
moduleState: "Preview",
|
||||
searchTags: ["Field", "Logistics", "Issued", "Restricted"],
|
||||
paymentSources: [
|
||||
{
|
||||
id: "cash",
|
||||
label: "Cash",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Use on-hand cash carried by the player.",
|
||||
},
|
||||
{
|
||||
id: "bank",
|
||||
label: "Bank",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Charge the player bank account.",
|
||||
},
|
||||
{
|
||||
id: "org_funds",
|
||||
label: "Org Funds",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Only organization leaders or the default-org CEO can use treasury funds.",
|
||||
},
|
||||
{
|
||||
id: "credit_line",
|
||||
label: "Credit Line",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "No approved credit line is assigned to this member.",
|
||||
},
|
||||
],
|
||||
defaultPaymentSource: "cash",
|
||||
};
|
||||
|
||||
function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function replaceObject(target, source) {
|
||||
Object.keys(target).forEach((key) => delete target[key]);
|
||||
Object.assign(target, cloneValue(source));
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
categoryCards: [
|
||||
{ id: "uniforms", label: "Uniforms" },
|
||||
{ id: "headgear", label: "Headgear" },
|
||||
{ id: "facewear", label: "Facewear" },
|
||||
{ id: "vests", label: "Vests" },
|
||||
{ id: "weapons", label: "Weapons" },
|
||||
{ id: "ammo", label: "Ammo" },
|
||||
{ id: "items", label: "Items" },
|
||||
{ id: "vehicles", label: "Vehicles" },
|
||||
],
|
||||
vehicleCards: [
|
||||
{ id: "cars", label: "Cars" },
|
||||
{ id: "armor", label: "Armor" },
|
||||
{ id: "helis", label: "Helicopters" },
|
||||
{ id: "planes", label: "Planes" },
|
||||
{ id: "naval", label: "Naval" },
|
||||
{ id: "other", label: "Other" },
|
||||
],
|
||||
weaponCards: [
|
||||
{ id: "primary", label: "Primary" },
|
||||
{ id: "secondary", label: "Secondary" },
|
||||
{ id: "handgun", label: "Handgun" },
|
||||
],
|
||||
previewItems: {
|
||||
uniforms: [
|
||||
{
|
||||
code: "UNF-102",
|
||||
name: "Field Uniform",
|
||||
description:
|
||||
"Standard issue apparel block reserved for mission-ready clothing sets.",
|
||||
price: "$1,250",
|
||||
},
|
||||
{
|
||||
code: "UNF-214",
|
||||
name: "Combat Uniform",
|
||||
description:
|
||||
"Hardened kit placeholder for armored and specialized duty loadouts.",
|
||||
price: "$1,980",
|
||||
},
|
||||
{
|
||||
code: "UNF-330",
|
||||
name: "Duty Uniform",
|
||||
description:
|
||||
"Administrative and garrison wear preview for storefront layout validation.",
|
||||
price: "$890",
|
||||
},
|
||||
],
|
||||
headgear: [
|
||||
{
|
||||
code: "HDG-044",
|
||||
name: "Patrol Helmet",
|
||||
description:
|
||||
"Protective headgear module with placeholder image frame and pricing slot.",
|
||||
price: "$640",
|
||||
},
|
||||
{
|
||||
code: "HDG-107",
|
||||
name: "Operator Cap",
|
||||
description:
|
||||
"Soft headwear entry for non-armored and low-profile equipment sets.",
|
||||
price: "$120",
|
||||
},
|
||||
{
|
||||
code: "HDG-221",
|
||||
name: "Boonie Hat",
|
||||
description:
|
||||
"Terrain-adapted headwear card for storefront presentation.",
|
||||
price: "$95",
|
||||
},
|
||||
],
|
||||
facewear: [
|
||||
{
|
||||
code: "FAC-015",
|
||||
name: "Protective Goggles",
|
||||
description:
|
||||
"Facewear module placeholder aligned to the shared supply exchange layout.",
|
||||
price: "$220",
|
||||
},
|
||||
{
|
||||
code: "FAC-028",
|
||||
name: "Balaclava",
|
||||
description:
|
||||
"Low-profile face covering preview card for catalog expansion.",
|
||||
price: "$74",
|
||||
},
|
||||
{
|
||||
code: "FAC-091",
|
||||
name: "Respirator Mask",
|
||||
description:
|
||||
"Filtered facewear placeholder for hazard and industrial kit sets.",
|
||||
price: "$410",
|
||||
},
|
||||
],
|
||||
vests: [
|
||||
{
|
||||
code: "VST-311",
|
||||
name: "Carrier Rig",
|
||||
description:
|
||||
"Plate carrier preview item with a reserved image zone and pricing footer.",
|
||||
price: "$2,430",
|
||||
},
|
||||
{
|
||||
code: "VST-414",
|
||||
name: "Patrol Vest",
|
||||
description:
|
||||
"Mid-weight vest card intended for security and checkpoint loadouts.",
|
||||
price: "$1,320",
|
||||
},
|
||||
{
|
||||
code: "VST-558",
|
||||
name: "Utility Harness",
|
||||
description:
|
||||
"Storage-focused chest rig placeholder for non-ballistic kit sets.",
|
||||
price: "$760",
|
||||
},
|
||||
],
|
||||
ammo: [
|
||||
{
|
||||
code: "AMM-556",
|
||||
name: "5.56 Cartridge Pack",
|
||||
description:
|
||||
"Grouped ammunition supply card with placeholder product art.",
|
||||
price: "$180",
|
||||
},
|
||||
{
|
||||
code: "AMM-762",
|
||||
name: "7.62 Cartridge Pack",
|
||||
description:
|
||||
"Extended-caliber ammunition block for rifle and marksman loadouts.",
|
||||
price: "$220",
|
||||
},
|
||||
{
|
||||
code: "AMM-9MM",
|
||||
name: "9mm Cartridge Pack",
|
||||
description:
|
||||
"Compact sidearm ammunition placeholder entry for layout review.",
|
||||
price: "$70",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
code: "ITM-004",
|
||||
name: "First Aid Kit",
|
||||
description:
|
||||
"Support item placeholder designed to preview general utility inventory cards.",
|
||||
price: "$65",
|
||||
},
|
||||
{
|
||||
code: "ITM-089",
|
||||
name: "Radio Module",
|
||||
description:
|
||||
"Communications item block with the same product card treatment as all categories.",
|
||||
price: "$330",
|
||||
},
|
||||
{
|
||||
code: "ITM-217",
|
||||
name: "Tool Kit",
|
||||
description:
|
||||
"Repair and engineering support module placeholder for store browsing.",
|
||||
price: "$145",
|
||||
},
|
||||
],
|
||||
primary: [
|
||||
{
|
||||
code: "WPN-PRI-01",
|
||||
name: "Primary Platform A",
|
||||
description:
|
||||
"Primary weapon slot placeholder card for mock store review.",
|
||||
price: "$3,250",
|
||||
},
|
||||
{
|
||||
code: "WPN-PRI-02",
|
||||
name: "Primary Platform B",
|
||||
description:
|
||||
"Alternate long-arm placeholder with image frame and metadata treatment.",
|
||||
price: "$3,980",
|
||||
},
|
||||
{
|
||||
code: "WPN-PRI-03",
|
||||
name: "Primary Platform C",
|
||||
description:
|
||||
"General-purpose primary slot preview for future catalog wiring.",
|
||||
price: "$2,890",
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
code: "WPN-SEC-01",
|
||||
name: "Secondary Launcher A",
|
||||
description:
|
||||
"Secondary slot placeholder card for support and utility weapon systems.",
|
||||
price: "$5,600",
|
||||
},
|
||||
{
|
||||
code: "WPN-SEC-02",
|
||||
name: "Secondary Launcher B",
|
||||
description:
|
||||
"Compact shoulder-fired placeholder entry in the shared product style.",
|
||||
price: "$4,950",
|
||||
},
|
||||
{
|
||||
code: "WPN-SEC-03",
|
||||
name: "Secondary Launcher C",
|
||||
description:
|
||||
"Reserved card for extended secondary inventory logic tomorrow.",
|
||||
price: "$6,120",
|
||||
},
|
||||
],
|
||||
handgun: [
|
||||
{
|
||||
code: "WPN-HND-01",
|
||||
name: "Sidearm A",
|
||||
description:
|
||||
"Handgun slot placeholder card with shared visual language.",
|
||||
price: "$780",
|
||||
},
|
||||
{
|
||||
code: "WPN-HND-02",
|
||||
name: "Sidearm B",
|
||||
description:
|
||||
"Secondary sidearm preview block for storefront evaluation.",
|
||||
price: "$920",
|
||||
},
|
||||
{
|
||||
code: "WPN-HND-03",
|
||||
name: "Sidearm C",
|
||||
description:
|
||||
"Compact sidearm placeholder with the same product framing.",
|
||||
price: "$860",
|
||||
},
|
||||
],
|
||||
cars: [
|
||||
{
|
||||
code: "VEH-CAR-01",
|
||||
name: "Patrol Utility Car",
|
||||
description:
|
||||
"Light wheeled vehicle placeholder for quick response and urban transport.",
|
||||
price: "$12,500",
|
||||
},
|
||||
{
|
||||
code: "VEH-CAR-02",
|
||||
name: "Transport Van",
|
||||
description:
|
||||
"Personnel transport preview card using the shared product layout.",
|
||||
price: "$18,200",
|
||||
},
|
||||
{
|
||||
code: "VEH-CAR-03",
|
||||
name: "Recon SUV",
|
||||
description:
|
||||
"Recon-focused platform placeholder with pricing and metadata treatment.",
|
||||
price: "$21,900",
|
||||
},
|
||||
],
|
||||
armor: [
|
||||
{
|
||||
code: "VEH-ARM-01",
|
||||
name: "APC Variant A",
|
||||
description:
|
||||
"Armored personnel carrier placeholder reserved for heavy vehicle inventory.",
|
||||
price: "$145,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-ARM-02",
|
||||
name: "IFV Variant B",
|
||||
description:
|
||||
"Tracked armored platform preview aligned to category card behavior.",
|
||||
price: "$228,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-ARM-03",
|
||||
name: "Support Armor C",
|
||||
description:
|
||||
"Heavy support vehicle placeholder for future role-based filtering.",
|
||||
price: "$174,500",
|
||||
},
|
||||
],
|
||||
helis: [
|
||||
{
|
||||
code: "VEH-HEL-01",
|
||||
name: "Light Heli A",
|
||||
description:
|
||||
"Rotorcraft placeholder for scouting and rapid insertion use cases.",
|
||||
price: "$325,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-HEL-02",
|
||||
name: "Transport Heli B",
|
||||
description:
|
||||
"Medium-lift helicopter preview item with staged catalog metadata.",
|
||||
price: "$482,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-HEL-03",
|
||||
name: "Attack Heli C",
|
||||
description:
|
||||
"Combat helicopter placeholder for future weapon package wiring.",
|
||||
price: "$690,000",
|
||||
},
|
||||
],
|
||||
planes: [
|
||||
{
|
||||
code: "VEH-PLN-01",
|
||||
name: "Fixed-Wing Trainer",
|
||||
description:
|
||||
"Basic aircraft placeholder for pilot training and logistics transfer.",
|
||||
price: "$760,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-PLN-02",
|
||||
name: "Utility Plane",
|
||||
description:
|
||||
"General-purpose plane preview card in the shared storefront style.",
|
||||
price: "$1,120,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-PLN-03",
|
||||
name: "Strike Plane",
|
||||
description:
|
||||
"Fixed-wing strike platform placeholder for high-tier procurement.",
|
||||
price: "$1,860,000",
|
||||
},
|
||||
],
|
||||
naval: [
|
||||
{
|
||||
code: "VEH-NAV-01",
|
||||
name: "Patrol Boat",
|
||||
description:
|
||||
"Shallow-water patrol vessel placeholder for littoral operations.",
|
||||
price: "$92,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-NAV-02",
|
||||
name: "Assault Boat",
|
||||
description:
|
||||
"Assault transport craft preview entry with staged catalog attributes.",
|
||||
price: "$128,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-NAV-03",
|
||||
name: "Support Craft",
|
||||
description:
|
||||
"Utility naval craft placeholder for resupply and extraction workflows.",
|
||||
price: "$104,000",
|
||||
},
|
||||
],
|
||||
other: [
|
||||
{
|
||||
code: "VEH-OTH-01",
|
||||
name: "UAV Support Unit",
|
||||
description:
|
||||
"Unmanned vehicle placeholder grouped under miscellaneous platforms.",
|
||||
price: "$48,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-OTH-02",
|
||||
name: "Static Transport Module",
|
||||
description:
|
||||
"Special transport asset preview for non-standard deployment types.",
|
||||
price: "$67,000",
|
||||
},
|
||||
{
|
||||
code: "VEH-OTH-03",
|
||||
name: "Service Platform",
|
||||
description:
|
||||
"General support platform placeholder for future specialty categories.",
|
||||
price: "$58,500",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
StorefrontApp.data = {
|
||||
catalog,
|
||||
session: Object.assign({}, defaultSession),
|
||||
storeConfig: Object.assign({}, defaultStoreConfig),
|
||||
applyHydratePayload(payload) {
|
||||
replaceObject(
|
||||
this.session,
|
||||
Object.assign({}, defaultSession, payload?.session || {}),
|
||||
);
|
||||
replaceObject(
|
||||
this.storeConfig,
|
||||
Object.assign(
|
||||
{},
|
||||
defaultStoreConfig,
|
||||
payload?.workspace || payload?.storeConfig || {},
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- Generated by tools/build-webui.mjs for store UI index. Do not edit directly. -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -5,69 +6,55 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FORGE Supply Exchange</title>
|
||||
<script>
|
||||
const addonRoot = "forge\\forge_client\\addons\\store\\ui\\_site\\";
|
||||
const styleFiles = ["style.css"];
|
||||
const scriptFiles = [
|
||||
"runtime.js",
|
||||
"media.js",
|
||||
"data.js",
|
||||
"logic/store.js",
|
||||
"pages/StoreView.js",
|
||||
"useStore.js",
|
||||
"bridge.js",
|
||||
"logic/events.js",
|
||||
"components/AppShell.js",
|
||||
"components/cards.js",
|
||||
"components/cart.js",
|
||||
"components/navbar.js",
|
||||
"script.js",
|
||||
];
|
||||
window.ForgeSiteConfig = {
|
||||
addonName: "store",
|
||||
logLabel: "Store UI",
|
||||
styles: ["store-ui.css"],
|
||||
commonScripts: ["forge-webui.js"],
|
||||
scripts: ["store-ui.js"],
|
||||
};
|
||||
|
||||
function requestText(path) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
) {
|
||||
return A3API.RequestFile(
|
||||
addonRoot + path.replace(/\//g, "\\"),
|
||||
);
|
||||
(function loadForgeSiteLoader() {
|
||||
const armaLoaderPath =
|
||||
"forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js";
|
||||
const browserLoaderPath =
|
||||
"../../../common/ui/_site/forge-site-loader.js";
|
||||
|
||||
function appendScript(js) {
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return fetch(path).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load " + path);
|
||||
function requestLoader() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
A3API &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
) {
|
||||
return A3API.RequestFile(armaLoaderPath);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
return fetch(browserLoaderPath).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"Failed to load " + browserLoaderPath,
|
||||
);
|
||||
}
|
||||
|
||||
function appendStyle(css) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
function appendScript(js) {
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
Promise.all(styleFiles.map(requestText))
|
||||
.then((styles) => {
|
||||
styles.forEach(appendStyle);
|
||||
return Promise.all(scriptFiles.map(requestText));
|
||||
})
|
||||
.then((scripts) => {
|
||||
scripts.forEach(appendScript);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[Store UI] Failed to load site assets.",
|
||||
error,
|
||||
);
|
||||
});
|
||||
requestLoader()
|
||||
.then(appendScript)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[Store UI] Failed to load Forge site loader.",
|
||||
error,
|
||||
);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SVG_TAGS = new Set([
|
||||
"svg",
|
||||
"path",
|
||||
"circle",
|
||||
"rect",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"g",
|
||||
"defs",
|
||||
"use",
|
||||
"text",
|
||||
"tspan",
|
||||
"clipPath",
|
||||
"mask",
|
||||
]);
|
||||
|
||||
function appendChild(el, child) {
|
||||
if (child === null || child === undefined || child === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry) => appendChild(el, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof child === "string" || typeof child === "number") {
|
||||
el.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof Node) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const isSvg = SVG_TAGS.has(tag);
|
||||
const el = isSvg
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
if (props) {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "className") {
|
||||
if (isSvg) {
|
||||
el.setAttribute("class", value);
|
||||
} else {
|
||||
el.className = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "style" && typeof value === "object") {
|
||||
Object.assign(el.style, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "value" && "value" in el) {
|
||||
el.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "checked" && "checked" in el) {
|
||||
el.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value) {
|
||||
el.setAttribute(key, "");
|
||||
} else {
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
el.removeAttribute(key);
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach((child) => appendChild(el, child));
|
||||
return el;
|
||||
}
|
||||
|
||||
let rootContainer = null;
|
||||
let rootComponent = null;
|
||||
const injectedStyles = new Set();
|
||||
|
||||
function captureScrollState(container) {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
container.querySelectorAll("[data-preserve-scroll-id]"),
|
||||
).map((node) => ({
|
||||
id: node.getAttribute("data-preserve-scroll-id"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollLeft: node.scrollLeft,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreScrollState(container, entries) {
|
||||
if (!container || !Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = container.querySelector(
|
||||
`[data-preserve-scroll-id="${entry.id}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollTop = Number(entry.scrollTop || 0);
|
||||
target.scrollLeft = Number(entry.scrollLeft || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function render(component, container) {
|
||||
rootContainer = container;
|
||||
rootComponent = component;
|
||||
rerender();
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
if (!rootContainer || !rootComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollState = captureScrollState(rootContainer);
|
||||
rootContainer.innerHTML = "";
|
||||
rootContainer.appendChild(rootComponent());
|
||||
restoreScrollState(rootContainer, scrollState);
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
if (!id || !cssText || injectedStyles.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-ui-style", id);
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
injectedStyles.add(id);
|
||||
}
|
||||
|
||||
function createSignal(initialValue) {
|
||||
let value = initialValue;
|
||||
|
||||
const getValue = () => value;
|
||||
const setValue = (nextValue) => {
|
||||
value =
|
||||
typeof nextValue === "function" ? nextValue(value) : nextValue;
|
||||
rerender();
|
||||
};
|
||||
|
||||
return [getValue, setValue];
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
h,
|
||||
render,
|
||||
rerender,
|
||||
createSignal,
|
||||
ensureScopedStyle,
|
||||
};
|
||||
|
||||
StorefrontApp.runtime = runtime;
|
||||
window.AppRuntime = runtime;
|
||||
})();
|
||||
@ -1,25 +0,0 @@
|
||||
(function () {
|
||||
function mountStorefront() {
|
||||
const root = document.getElementById("app");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.StorefrontApp.runtime.render(
|
||||
window.StorefrontApp.components.App,
|
||||
root,
|
||||
);
|
||||
|
||||
if (window.StorefrontApp.bridge) {
|
||||
window.StorefrontApp.bridge.notifyReady();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountStorefront, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
mountStorefront();
|
||||
}
|
||||
})();
|
||||
@ -1,6 +1,5 @@
|
||||
/* Generated by tools/build-webui.mjs for Store UI styles. Do not edit directly. */
|
||||
:root {
|
||||
--store-titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%);
|
||||
--store-titlebar-border: rgba(161, 190, 224, 0.18);
|
||||
--store-shell-bg: #e4e3df;
|
||||
--store-surface: #f5f3ef;
|
||||
--store-surface-alt: #ece8e2;
|
||||
3487
arma/client/addons/store/ui/_site/store-ui.js
Normal file
3487
arma/client/addons/store/ui/_site/store-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +0,0 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { createSignal } = StorefrontApp.runtime;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
StorefrontApp.store = SharedLogic.createStorefrontStore({
|
||||
createSignal,
|
||||
});
|
||||
})();
|
||||
19
arma/client/addons/store/ui/src/bootstrap.js
vendored
Normal file
19
arma/client/addons/store/ui/src/bootstrap.js
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
(function () {
|
||||
const ForgeWebUI = window.ForgeWebUI;
|
||||
const StorefrontApp = window.StorefrontApp;
|
||||
const app = ForgeWebUI.createApp({
|
||||
name: "store",
|
||||
root: "#app",
|
||||
setup({ root }) {
|
||||
ForgeWebUI.mount(root, () => StorefrontApp.components.App(), {
|
||||
preserveScroll: false,
|
||||
});
|
||||
|
||||
if (StorefrontApp.bridge) {
|
||||
StorefrontApp.bridge.notifyReady();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.start();
|
||||
})();
|
||||
81
arma/client/addons/store/ui/src/bridge.js
Normal file
81
arma/client/addons/store/ui/src/bridge.js
Normal file
@ -0,0 +1,81 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const store = StorefrontApp.store;
|
||||
const bridge = window.ForgeWebUI.createBridge({
|
||||
closeEvent: "store::close",
|
||||
globalName: "StoreUIBridge",
|
||||
readyEvent: "store::ready",
|
||||
});
|
||||
|
||||
function requestClose() {
|
||||
return bridge.close({});
|
||||
}
|
||||
|
||||
function requestCheckout(payload) {
|
||||
return bridge.send("store::checkout::request", payload);
|
||||
}
|
||||
|
||||
function requestCategory(payload) {
|
||||
return bridge.send("store::category::request", payload);
|
||||
}
|
||||
|
||||
function notifyReady() {
|
||||
return bridge.ready({ loaded: true });
|
||||
}
|
||||
|
||||
bridge.on("store::hydrate", (payloadData) => {
|
||||
StorefrontApp.data.applyHydratePayload(payloadData);
|
||||
store.hydrateFromPayload(payloadData);
|
||||
});
|
||||
|
||||
bridge.on("store::config::hydrate", (payloadData) => {
|
||||
StorefrontApp.data.applyHydratePayload(payloadData);
|
||||
store.hydrateStoreConfig(payloadData);
|
||||
});
|
||||
|
||||
bridge.on("store::checkout::success", (payloadData) => {
|
||||
store.setIsCheckingOut(false);
|
||||
store.setCartItems([]);
|
||||
store.setCartOpen(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"success",
|
||||
payloadData.message || "Checkout completed.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("store::category::hydrate", (payloadData) => {
|
||||
store.hydrateCategoryItems(payloadData);
|
||||
});
|
||||
|
||||
bridge.on("store::category::failure", (payloadData) => {
|
||||
store.finishCategoryRequest(payloadData.category || "");
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Category request failed.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bridge.on("store::checkout::failure", (payloadData) => {
|
||||
store.setIsCheckingOut(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Checkout failed.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
StorefrontApp.bridge = {
|
||||
close: bridge.close,
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
requestCategory,
|
||||
notifyReady,
|
||||
receive: bridge.receive,
|
||||
sendEvent: bridge.send,
|
||||
};
|
||||
})();
|
||||
@ -1,6 +1,7 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar;
|
||||
const store = StorefrontApp.store;
|
||||
const getters = StorefrontApp.getters;
|
||||
const actions = StorefrontApp.actions;
|
||||
@ -17,26 +18,6 @@ ${scopeSelector} {
|
||||
background: var(--store-shell-bg);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem 0.95rem 1.2rem;
|
||||
background: var(--store-titlebar-bg);
|
||||
color: #f4f8fd;
|
||||
border-bottom: 1px solid var(--store-titlebar-border);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar-kicker,
|
||||
${scopeSelector} .footer-title,
|
||||
${scopeSelector} .eyebrow {
|
||||
font-size: 0.68rem;
|
||||
@ -46,17 +27,6 @@ ${scopeSelector} .eyebrow {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar-kicker {
|
||||
color: rgb(214 227 241 / 0.72);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar-title {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-titlebar-controls,
|
||||
${scopeSelector} .module-header,
|
||||
${scopeSelector} .store-panel-header {
|
||||
display: flex;
|
||||
@ -65,30 +35,6 @@ ${scopeSelector} .store-panel-header {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 0.45rem;
|
||||
border: 1px solid rgb(197 220 243 / 0.16);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(237 244 251 / 0.88);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn.is-close {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn.is-close:hover {
|
||||
background: rgb(185 67 67 / 0.9);
|
||||
border-color: rgb(255 222 222 / 0.45);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@ -206,12 +152,6 @@ ${scopeSelector} .quick-tag {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .quick-tag.is-active {
|
||||
background: var(--store-accent-soft);
|
||||
color: var(--store-accent);
|
||||
border-color: rgb(18 54 93 / 0.2);
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-stack {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
@ -336,64 +276,6 @@ ${scopeSelector} .store-toast.is-error {
|
||||
StorefrontApp.components = StorefrontApp.components || {};
|
||||
StorefrontApp.componentFns = StorefrontApp.componentFns || {};
|
||||
|
||||
function WindowTitleBar() {
|
||||
return h(
|
||||
"div",
|
||||
{ className: "window-titlebar" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-brand" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "window-titlebar-kicker" },
|
||||
"FORGE Logistics",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "window-titlebar-title" },
|
||||
"Supply Exchange",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Minimize unavailable",
|
||||
"aria-label": "Minimize unavailable",
|
||||
},
|
||||
"-",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Maximize unavailable",
|
||||
"aria-label": "Maximize unavailable",
|
||||
},
|
||||
"[ ]",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn is-close",
|
||||
title: "Close",
|
||||
"aria-label": "Close store interface",
|
||||
onClick: () => actions.closeStore(),
|
||||
},
|
||||
"X",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderStoreBody(state) {
|
||||
const {
|
||||
CategoryCard,
|
||||
@ -485,6 +367,10 @@ ${scopeSelector} .store-toast.is-error {
|
||||
const header = getters.getStoreHeader(state);
|
||||
const notice = store.getNotice();
|
||||
const activeQuery = state.searchQuery;
|
||||
const paymentSources = getters.getPaymentSources(storeConfig);
|
||||
const availablePaymentSourceCount = paymentSources.filter(
|
||||
(source) => source.enabled !== false,
|
||||
).length;
|
||||
const filterDepartment =
|
||||
state.view === "items"
|
||||
? actions.formatTitle(
|
||||
@ -502,7 +388,12 @@ ${scopeSelector} .store-toast.is-error {
|
||||
return h(
|
||||
"div",
|
||||
{ [scopeAttr]: "" },
|
||||
WindowTitleBar(),
|
||||
WindowTitleBar({
|
||||
kicker: "FORGE Logistics",
|
||||
title: "Supply Exchange",
|
||||
onClose: () => actions.closeStore(),
|
||||
closeLabel: "Close store interface",
|
||||
}),
|
||||
notice.text
|
||||
? h(
|
||||
"div",
|
||||
@ -593,19 +484,7 @@ ${scopeSelector} .store-toast.is-error {
|
||||
"div",
|
||||
{ className: "quick-tags" },
|
||||
(storeConfig.searchTags || []).map((tag) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
activeQuery === tag
|
||||
? "quick-tag is-active"
|
||||
: "quick-tag",
|
||||
onClick: () =>
|
||||
actions.applySearchQuery(tag),
|
||||
},
|
||||
tag,
|
||||
),
|
||||
h("span", { className: "quick-tag" }, tag),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -645,7 +524,6 @@ ${scopeSelector} .store-toast.is-error {
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Operational Tier"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
@ -664,7 +542,6 @@ ${scopeSelector} .store-toast.is-error {
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Stock Window"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
@ -672,26 +549,6 @@ ${scopeSelector} .store-toast.is-error {
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-group" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-label" },
|
||||
"Approval",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Purchase Level"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
session.approvalRole ||
|
||||
storeConfig.approval,
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-group" },
|
||||
@ -703,7 +560,6 @@ ${scopeSelector} .store-toast.is-error {
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Checkout Source"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
@ -782,13 +638,11 @@ ${scopeSelector} .store-toast.is-error {
|
||||
h(
|
||||
"div",
|
||||
{ className: "footer-block" },
|
||||
h("span", { className: "footer-title" }, "Bridge State"),
|
||||
h("span", { className: "footer-title" }, "Purchase Access"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "footer-copy" },
|
||||
session.actorName
|
||||
? `Hydrated for ${session.actorName}. Checkout remains a stub until the procurement backend is wired.`
|
||||
: "The browser bridge is active. Hydration and checkout events now flow through the same contract shape as the org UI.",
|
||||
`${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -62,32 +62,23 @@ ${scopeSelector} .cart-close {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid rgb(173 48 48 / 0.9);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(214 92 92) 0%,
|
||||
rgb(175 52 52) 100%
|
||||
);
|
||||
color: #fff;
|
||||
border: 1px solid var(--store-border-strong);
|
||||
background: rgb(255 255 255 / 0.78);
|
||||
color: var(--store-accent);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.26),
|
||||
0 8px 18px rgb(138 61 61 / 0.28);
|
||||
box-shadow: 0 6px 16px rgb(18 54 93 / 0.08);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-close:hover {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(226 107 107) 0%,
|
||||
rgb(187 61 61) 100%
|
||||
);
|
||||
border-color: rgb(173 48 48);
|
||||
background: var(--store-accent-soft);
|
||||
border-color: rgb(18 54 93 / 0.24);
|
||||
color: var(--store-accent);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-close:focus-visible {
|
||||
outline: 2px solid rgb(191 80 80 / 0.35);
|
||||
outline: 2px solid rgb(18 54 93 / 0.25);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-status,
|
||||
@ -307,7 +298,9 @@ ${scopeSelector} .cart-empty {
|
||||
getters.getPaymentSourceById(
|
||||
storeConfig,
|
||||
state.selectedPaymentSource,
|
||||
) || paymentSources[0] || null;
|
||||
) ||
|
||||
paymentSources[0] ||
|
||||
null;
|
||||
const availablePaymentSourceCount = paymentSources.filter(
|
||||
(source) => source.enabled !== false,
|
||||
).length;
|
||||
@ -378,11 +371,7 @@ ${scopeSelector} .cart-empty {
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-kpi-card" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "kpi-label" },
|
||||
"Payment",
|
||||
),
|
||||
h("span", { className: "kpi-label" }, "Payment"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "kpi-value" },
|
||||
@ -393,11 +382,7 @@ ${scopeSelector} .cart-empty {
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-status" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "eyebrow" },
|
||||
"Payment Source",
|
||||
),
|
||||
h("span", { className: "eyebrow" }, "Payment Source"),
|
||||
h(
|
||||
"div",
|
||||
{ className: "payment-source-field" },
|
||||
@ -416,8 +401,7 @@ ${scopeSelector} .cart-empty {
|
||||
"option",
|
||||
{
|
||||
value: source.id,
|
||||
disabled:
|
||||
source.enabled === false,
|
||||
disabled: source.enabled === false,
|
||||
},
|
||||
source.enabled === false
|
||||
? `${source.label} (Locked)`
|
||||
@ -427,10 +411,9 @@ ${scopeSelector} .cart-empty {
|
||||
),
|
||||
selectedPaymentSource
|
||||
? h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
"payment-source-meta",
|
||||
"div",
|
||||
{
|
||||
className: "payment-source-meta",
|
||||
},
|
||||
h(
|
||||
"div",
|
||||
@ -472,8 +455,7 @@ ${scopeSelector} .cart-empty {
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
className:
|
||||
"payment-source-state",
|
||||
className: "payment-source-state",
|
||||
},
|
||||
availablePaymentSourceCount > 0
|
||||
? selectedPaymentSource.enabled ===
|
||||
@ -628,9 +610,7 @@ ${scopeSelector} .cart-empty {
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-value" },
|
||||
getters.formatCurrency(
|
||||
remainingSourceBalance,
|
||||
),
|
||||
getters.formatCurrency(remainingSourceBalance),
|
||||
),
|
||||
),
|
||||
h(
|
||||
@ -647,15 +627,6 @@ ${scopeSelector} .cart-empty {
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-actions" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-secondary",
|
||||
onClick: () => actions.closeCart(),
|
||||
},
|
||||
"Review Later",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
134
arma/client/addons/store/ui/src/data.js
Normal file
134
arma/client/addons/store/ui/src/data.js
Normal file
@ -0,0 +1,134 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
const defaultSession = {
|
||||
actorName: "",
|
||||
actorUid: "",
|
||||
approval: "Field Access",
|
||||
orgId: "",
|
||||
orgName: "",
|
||||
orgLeader: false,
|
||||
defaultOrgCeo: false,
|
||||
canUseOrgFunds: false,
|
||||
};
|
||||
|
||||
const defaultStoreConfig = {
|
||||
budget: 50000,
|
||||
creditLine: 0,
|
||||
availability: "In-Stock",
|
||||
moduleState: "Preview",
|
||||
searchTags: [
|
||||
"Attachment",
|
||||
"Grenade",
|
||||
"Medical",
|
||||
"Consumable",
|
||||
"Static",
|
||||
"Scope",
|
||||
"Item",
|
||||
"Misc",
|
||||
],
|
||||
paymentSources: [
|
||||
{
|
||||
id: "cash",
|
||||
label: "Cash",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Use on-hand cash carried by the player.",
|
||||
},
|
||||
{
|
||||
id: "bank",
|
||||
label: "Bank",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Charge the player bank account.",
|
||||
},
|
||||
{
|
||||
id: "org_funds",
|
||||
label: "Org Funds",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "Only organization leaders or the default-org CEO can use treasury funds.",
|
||||
},
|
||||
{
|
||||
id: "credit_line",
|
||||
label: "Credit Line",
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
detail: "No approved credit line is assigned to this member.",
|
||||
},
|
||||
],
|
||||
defaultPaymentSource: "cash",
|
||||
};
|
||||
|
||||
function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function replaceObject(target, source) {
|
||||
Object.keys(target).forEach((key) => delete target[key]);
|
||||
Object.assign(target, cloneValue(source));
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
categoryCards: [
|
||||
{ id: "uniforms", label: "Uniforms" },
|
||||
{ id: "headgear", label: "Headgear" },
|
||||
{ id: "facewear", label: "Facewear" },
|
||||
{ id: "vests", label: "Vests" },
|
||||
{ id: "weapons", label: "Weapons" },
|
||||
{ id: "ammo", label: "Ammo" },
|
||||
{ id: "items", label: "Items" },
|
||||
{ id: "vehicles", label: "Vehicles" },
|
||||
],
|
||||
vehicleCards: [
|
||||
{ id: "cars", label: "Cars" },
|
||||
{ id: "armor", label: "Armor" },
|
||||
{ id: "helis", label: "Helicopters" },
|
||||
{ id: "planes", label: "Planes" },
|
||||
{ id: "naval", label: "Naval" },
|
||||
{ id: "other", label: "Other" },
|
||||
],
|
||||
weaponCards: [
|
||||
{ id: "primary", label: "Primary" },
|
||||
{ id: "secondary", label: "Secondary" },
|
||||
{ id: "handgun", label: "Handgun" },
|
||||
],
|
||||
previewItems: {
|
||||
uniforms: [],
|
||||
headgear: [],
|
||||
facewear: [],
|
||||
vests: [],
|
||||
ammo: [],
|
||||
items: [],
|
||||
primary: [],
|
||||
secondary: [],
|
||||
handgun: [],
|
||||
cars: [],
|
||||
armor: [],
|
||||
helis: [],
|
||||
planes: [],
|
||||
naval: [],
|
||||
other: [],
|
||||
},
|
||||
};
|
||||
|
||||
StorefrontApp.data = {
|
||||
catalog,
|
||||
session: Object.assign({}, defaultSession),
|
||||
storeConfig: Object.assign({}, defaultStoreConfig),
|
||||
applyHydratePayload(payload) {
|
||||
replaceObject(
|
||||
this.session,
|
||||
Object.assign({}, defaultSession, payload?.session || {}),
|
||||
);
|
||||
replaceObject(
|
||||
this.storeConfig,
|
||||
Object.assign(
|
||||
{},
|
||||
defaultStoreConfig,
|
||||
payload?.storeConfig || {},
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@ -1,6 +1,7 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const runtime = StorefrontApp.runtime;
|
||||
const [getTextureVersion, setTextureVersion] = runtime.createSignal(0);
|
||||
const MAX_CONCURRENT_TEXTURES = 6;
|
||||
const RERENDER_DELAY_MS = 48;
|
||||
const textureCache = Object.create(null);
|
||||
@ -59,9 +60,7 @@
|
||||
|
||||
rerenderTimer = window.setTimeout(() => {
|
||||
rerenderTimer = 0;
|
||||
if (runtime && typeof runtime.rerender === "function") {
|
||||
runtime.rerender();
|
||||
}
|
||||
setTextureVersion((currentVersion) => currentVersion + 1);
|
||||
}, RERENDER_DELAY_MS);
|
||||
}
|
||||
|
||||
@ -221,6 +220,7 @@
|
||||
}
|
||||
|
||||
function getTextureState(path) {
|
||||
getTextureVersion();
|
||||
const normalizedPath = normalizeTexturePath(path);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
@ -236,6 +236,7 @@
|
||||
}
|
||||
|
||||
function getTextureSource(path) {
|
||||
getTextureVersion();
|
||||
const normalizedPath = normalizeTexturePath(path);
|
||||
if (!normalizedPath) {
|
||||
return "";
|
||||
@ -1,4 +1,6 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { createSignal } = StorefrontApp.runtime;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createStorefrontStore = function createStorefrontStore({
|
||||
@ -180,15 +182,17 @@
|
||||
this.finishCategoryRequest(categoryKey);
|
||||
}
|
||||
|
||||
ensureSelectedPaymentSource(workspace) {
|
||||
const paymentSources = Array.isArray(workspace?.paymentSources)
|
||||
? workspace.paymentSources
|
||||
ensureSelectedPaymentSource(storeConfig) {
|
||||
const paymentSources = Array.isArray(
|
||||
storeConfig?.paymentSources,
|
||||
)
|
||||
? storeConfig.paymentSources
|
||||
: [];
|
||||
const currentSource = String(
|
||||
this.getSelectedPaymentSource() || "",
|
||||
).trim();
|
||||
const defaultSource = String(
|
||||
workspace?.defaultPaymentSource || "",
|
||||
storeConfig?.defaultPaymentSource || "",
|
||||
).trim();
|
||||
const sourceIds = paymentSources.map((source) =>
|
||||
String(source?.id || "").trim(),
|
||||
@ -253,21 +257,17 @@
|
||||
? payload.cartItems
|
||||
: [];
|
||||
|
||||
this.setCartItems(
|
||||
cartItems.map(normalizeCartItem),
|
||||
);
|
||||
this.setCartItems(cartItems.map(normalizeCartItem));
|
||||
this.setCartOpen(false);
|
||||
this.setIsCheckingOut(false);
|
||||
this.setCatalogItemsByKey({});
|
||||
this.setCatalogRequestKey("");
|
||||
this.setIsCatalogLoading(false);
|
||||
this.setCatalogPage(1);
|
||||
this.ensureSelectedPaymentSource(
|
||||
payload?.workspace || payload?.storeConfig || {},
|
||||
);
|
||||
this.ensureSelectedPaymentSource(payload?.storeConfig || {});
|
||||
}
|
||||
|
||||
hydrateWorkspace(payload) {
|
||||
hydrateStoreConfig(payload) {
|
||||
const cartItems = Array.isArray(payload?.cartItems)
|
||||
? payload.cartItems
|
||||
: [];
|
||||
@ -275,12 +275,14 @@
|
||||
this.setCartItems(cartItems.map(normalizeCartItem));
|
||||
this.setCartOpen(false);
|
||||
this.setIsCheckingOut(false);
|
||||
this.ensureSelectedPaymentSource(
|
||||
payload?.workspace || payload?.storeConfig || {},
|
||||
);
|
||||
this.ensureSelectedPaymentSource(payload?.storeConfig || {});
|
||||
}
|
||||
}
|
||||
|
||||
return new StorefrontStore();
|
||||
};
|
||||
|
||||
StorefrontApp.store = SharedLogic.createStorefrontStore({
|
||||
createSignal,
|
||||
});
|
||||
})();
|
||||
6
arma/client/addons/store/ui/src/runtime.js
Normal file
6
arma/client/addons/store/ui/src/runtime.js
Normal file
@ -0,0 +1,6 @@
|
||||
(function () {
|
||||
const runtime = window.ForgeWebUI;
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
StorefrontApp.runtime = runtime;
|
||||
window.AppRuntime = runtime;
|
||||
})();
|
||||
89
arma/client/addons/store/ui/src/styles.css
Normal file
89
arma/client/addons/store/ui/src/styles.css
Normal file
@ -0,0 +1,89 @@
|
||||
:root {
|
||||
--store-shell-bg: #e4e3df;
|
||||
--store-surface: #f5f3ef;
|
||||
--store-surface-alt: #ece8e2;
|
||||
--store-surface-strong: #ffffff;
|
||||
--store-border: rgba(74, 91, 110, 0.2);
|
||||
--store-border-strong: rgba(20, 46, 79, 0.2);
|
||||
--store-text-main: #1f2d3d;
|
||||
--store-text-muted: #6a7787;
|
||||
--store-text-subtle: #8792a0;
|
||||
--store-accent: #12365d;
|
||||
--store-accent-soft: #dbe7f3;
|
||||
--store-accent-line: rgba(18, 54, 93, 0.12);
|
||||
--store-success: #2f7d5b;
|
||||
--store-danger: #8a3d3d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||
color: var(--store-text-main);
|
||||
background: var(--store-shell-bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(18 54 93 / 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.store-btn {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid var(--store-border-strong);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.store-btn.store-btn-primary {
|
||||
background: rgb(255 255 255 / 0.68);
|
||||
color: var(--store-accent);
|
||||
}
|
||||
|
||||
.store-btn.store-btn-primary:hover {
|
||||
background: rgb(219 231 243 / 0.88);
|
||||
}
|
||||
|
||||
.store-btn.store-btn-secondary {
|
||||
background: rgb(255 255 255 / 0.42);
|
||||
color: var(--store-text-muted);
|
||||
}
|
||||
|
||||
.store-btn.store-btn-secondary:hover {
|
||||
background: rgb(255 255 255 / 0.6);
|
||||
color: var(--store-text-main);
|
||||
}
|
||||
38
arma/client/addons/store/ui/ui.config.mjs
Normal file
38
arma/client/addons/store/ui/ui.config.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
export default {
|
||||
addonName: "store",
|
||||
title: "FORGE Supply Exchange",
|
||||
logLabel: "Store UI",
|
||||
outputDir: "_site",
|
||||
jsBundles: [
|
||||
{
|
||||
name: "Store UI app",
|
||||
output: "store-ui.js",
|
||||
sources: [
|
||||
"src/runtime.js",
|
||||
"src/media.js",
|
||||
"src/data.js",
|
||||
"src/registry/store.js",
|
||||
"src/pages/StoreView.js",
|
||||
"src/bridge.js",
|
||||
"src/registry/events.js",
|
||||
"src/components/AppShell.js",
|
||||
"src/components/cards.js",
|
||||
"src/components/cart.js",
|
||||
"src/components/navbar.js",
|
||||
"src/bootstrap.js",
|
||||
],
|
||||
},
|
||||
],
|
||||
cssBundles: [
|
||||
{
|
||||
name: "Store UI styles",
|
||||
output: "store-ui.css",
|
||||
sources: ["src/styles.css"],
|
||||
},
|
||||
],
|
||||
site: {
|
||||
styles: ["store-ui.css"],
|
||||
commonScripts: ["forge-webui.js"],
|
||||
scripts: ["store-ui.js"],
|
||||
},
|
||||
};
|
||||
@ -28,6 +28,22 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = $PSScriptRoot
|
||||
|
||||
function Build-WebUIAssets {
|
||||
Write-Host "`n=== Building Web UI Bundles ===" -ForegroundColor Cyan
|
||||
|
||||
Push-Location $scriptDir
|
||||
try {
|
||||
& npm run build:webui
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Web UI bundle build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
Write-Host "✓ Web UI bundles built successfully" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
function Build-HemttProject {
|
||||
param(
|
||||
[string]$ProjectPath,
|
||||
@ -54,6 +70,7 @@ $serverPath = Join-Path $scriptDir "arma\server"
|
||||
|
||||
try {
|
||||
if ($Target -eq 'client' -or $Target -eq 'both') {
|
||||
Build-WebUIAssets
|
||||
Build-HemttProject -ProjectPath $clientPath -ProjectName "Client"
|
||||
}
|
||||
|
||||
|
||||
7
package.json
Normal file
7
package.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "forge-webui",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:webui": "node tools/build-webui.mjs"
|
||||
}
|
||||
}
|
||||
335
tools/build-webui.mjs
Normal file
335
tools/build-webui.mjs
Normal file
@ -0,0 +1,335 @@
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const commonUiSrcDir = "arma/client/addons/common/ui/src";
|
||||
const commonUiSiteDir = "arma/client/addons/common/ui/_site";
|
||||
const clientAddonsDir = path.join(rootDir, "arma/client/addons");
|
||||
|
||||
function toRepoRelative(absolutePath) {
|
||||
return path.relative(rootDir, absolutePath).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function resolveFromRoot(...segments) {
|
||||
return toRepoRelative(path.join(rootDir, ...segments));
|
||||
}
|
||||
|
||||
function resolveFromConfigDir(configDir, relativePath) {
|
||||
return toRepoRelative(path.resolve(configDir, relativePath));
|
||||
}
|
||||
|
||||
const commonJsBundles = [
|
||||
{
|
||||
name: "Forge Web UI runtime",
|
||||
output: resolveFromRoot(commonUiSiteDir, "forge-webui.js"),
|
||||
sources: [
|
||||
"runtime.js",
|
||||
"host.js",
|
||||
"bridge.js",
|
||||
"app.js",
|
||||
"windowTitleBar.js",
|
||||
"index.js",
|
||||
].map((relativePath) => resolveFromRoot(commonUiSrcDir, relativePath)),
|
||||
},
|
||||
{
|
||||
name: "Forge Web UI site loader",
|
||||
output: resolveFromRoot(commonUiSiteDir, "forge-site-loader.js"),
|
||||
sources: [resolveFromRoot(commonUiSrcDir, "siteLoader.js")],
|
||||
},
|
||||
];
|
||||
const commonFormatSourceTargets = [resolveFromRoot(commonUiSrcDir)];
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
async function readSource(relativePath) {
|
||||
const absolutePath = path.join(rootDir, relativePath);
|
||||
return readFile(absolutePath, "utf8");
|
||||
}
|
||||
|
||||
async function writeBundle(outputRelativePath, content) {
|
||||
const outputPath = path.join(rootDir, outputRelativePath);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, content, "utf8");
|
||||
}
|
||||
|
||||
async function cleanOutputDirs(outputDirs) {
|
||||
const uniqueDirs = unique(outputDirs).filter(Boolean);
|
||||
|
||||
await Promise.all(
|
||||
uniqueDirs.map(async (relativeDir) => {
|
||||
const absoluteDir = path.join(rootDir, relativeDir);
|
||||
await rm(absoluteDir, { force: true, recursive: true });
|
||||
await mkdir(absoluteDir, { recursive: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildJsBundle({ name, output, sources }) {
|
||||
const banner = `/* Generated by tools/build-webui.mjs for ${name}. Do not edit directly. */\n`;
|
||||
const chunks = await Promise.all(sources.map(readSource));
|
||||
await writeBundle(output, banner + chunks.join("\n\n"));
|
||||
console.log(`Built ${output}`);
|
||||
}
|
||||
|
||||
async function buildCssBundle({ name, output, sources }) {
|
||||
const banner = `/* Generated by tools/build-webui.mjs for ${name}. Do not edit directly. */\n`;
|
||||
const chunks = await Promise.all(sources.map(readSource));
|
||||
await writeBundle(output, banner + chunks.join("\n\n"));
|
||||
console.log(`Built ${output}`);
|
||||
}
|
||||
|
||||
function renderSiteIndex({ title, siteConfig }) {
|
||||
const configJson = JSON.stringify(siteConfig, null, 16)
|
||||
.replace(/^/gm, " ".repeat(12))
|
||||
.trimStart();
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${title}</title>
|
||||
<script>
|
||||
window.ForgeSiteConfig = ${configJson};
|
||||
|
||||
(function loadForgeSiteLoader() {
|
||||
const armaLoaderPath =
|
||||
"forge\\\\forge_client\\\\addons\\\\common\\\\ui\\\\_site\\\\forge-site-loader.js";
|
||||
const browserLoaderPath =
|
||||
"../../../common/ui/_site/forge-site-loader.js";
|
||||
|
||||
function appendScript(js) {
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function requestLoader() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
A3API &&
|
||||
typeof A3API.RequestFile === "function"
|
||||
) {
|
||||
return A3API.RequestFile(armaLoaderPath);
|
||||
}
|
||||
|
||||
return fetch(browserLoaderPath).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"Failed to load " + browserLoaderPath,
|
||||
);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
requestLoader()
|
||||
.then(appendScript)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[${siteConfig.logLabel}] Failed to load Forge site loader.",
|
||||
error,
|
||||
);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async function buildHtmlPage({ name, output, title, siteConfig }) {
|
||||
const banner = `<!-- Generated by tools/build-webui.mjs for ${name}. Do not edit directly. -->\n`;
|
||||
await writeBundle(output, banner + renderSiteIndex({ title, siteConfig }));
|
||||
console.log(`Built ${output}`);
|
||||
}
|
||||
|
||||
async function pathExists(absolutePath) {
|
||||
try {
|
||||
await stat(absolutePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPrettier(targets) {
|
||||
const uniqueTargets = unique(targets).filter(Boolean);
|
||||
if (uniqueTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Formatting ${uniqueTargets.length} Web UI target(s) with Prettier`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const quotedTargets = uniqueTargets.map((target) =>
|
||||
`"${String(target).replace(/"/g, '\\"')}"`,
|
||||
);
|
||||
const command = `npx prettier --write --ignore-unknown ${quotedTargets.join(" ")}`;
|
||||
const child = spawn(command, [], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(`Prettier failed with exit code ${code ?? "unknown"}.`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverUiConfigs() {
|
||||
const addons = await readdir(clientAddonsDir, { withFileTypes: true });
|
||||
const configPaths = [];
|
||||
|
||||
for (const entry of addons) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const configPath = path.join(
|
||||
clientAddonsDir,
|
||||
entry.name,
|
||||
"ui",
|
||||
"ui.config.mjs",
|
||||
);
|
||||
|
||||
try {
|
||||
const configStat = await stat(configPath);
|
||||
if (configStat.isFile()) {
|
||||
configPaths.push(configPath);
|
||||
}
|
||||
} catch {
|
||||
// UI config is optional per addon.
|
||||
}
|
||||
}
|
||||
|
||||
configPaths.sort((left, right) => left.localeCompare(right));
|
||||
return configPaths;
|
||||
}
|
||||
|
||||
async function loadUiConfig(absoluteConfigPath) {
|
||||
const configModule = await import(pathToFileURL(absoluteConfigPath).href);
|
||||
const config = configModule.default;
|
||||
|
||||
if (!config || !config.addonName || !config.outputDir || !config.site) {
|
||||
throw new Error(
|
||||
`Invalid UI config at ${toRepoRelative(absoluteConfigPath)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const configDir = path.dirname(absoluteConfigPath);
|
||||
const configRelativePath = toRepoRelative(absoluteConfigPath);
|
||||
const outputDir = resolveFromConfigDir(configDir, config.outputDir);
|
||||
const srcDirPath = path.join(configDir, "src");
|
||||
const formatSourceTargets = [configRelativePath];
|
||||
|
||||
if (await pathExists(srcDirPath)) {
|
||||
formatSourceTargets.push(toRepoRelative(srcDirPath));
|
||||
}
|
||||
|
||||
const jsBundles = (config.jsBundles || []).map((bundle) => ({
|
||||
name: bundle.name,
|
||||
output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)),
|
||||
sources: (bundle.sources || []).map((source) =>
|
||||
resolveFromConfigDir(configDir, source),
|
||||
),
|
||||
}));
|
||||
const cssBundles = (config.cssBundles || []).map((bundle) => ({
|
||||
name: bundle.name,
|
||||
output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)),
|
||||
sources: (bundle.sources || []).map((source) =>
|
||||
resolveFromConfigDir(configDir, source),
|
||||
),
|
||||
}));
|
||||
const htmlPage = {
|
||||
name: `${config.addonName} UI index`,
|
||||
output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")),
|
||||
title: config.title,
|
||||
siteConfig: {
|
||||
addonName: config.addonName,
|
||||
logLabel: config.logLabel || `${config.addonName} UI`,
|
||||
...config.site,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
jsBundles,
|
||||
cssBundles,
|
||||
htmlPage,
|
||||
formatSourceTargets,
|
||||
formatGeneratedTargets: [
|
||||
...jsBundles.map((bundle) => bundle.output),
|
||||
...cssBundles.map((bundle) => bundle.output),
|
||||
htmlPage.output,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function collectUiBuildArtifacts() {
|
||||
const configPaths = await discoverUiConfigs();
|
||||
const uiConfigs = await Promise.all(configPaths.map(loadUiConfig));
|
||||
|
||||
return {
|
||||
outputDirs: uiConfigs.map((config) => config.outputDir),
|
||||
jsBundles: uiConfigs.flatMap((config) => config.jsBundles),
|
||||
cssBundles: uiConfigs.flatMap((config) => config.cssBundles),
|
||||
htmlPages: uiConfigs.map((config) => config.htmlPage),
|
||||
formatSourceTargets: uiConfigs.flatMap(
|
||||
(config) => config.formatSourceTargets,
|
||||
),
|
||||
formatGeneratedTargets: uiConfigs.flatMap(
|
||||
(config) => config.formatGeneratedTargets,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const uiArtifacts = await collectUiBuildArtifacts();
|
||||
const commonGeneratedTargets = commonJsBundles.map((bundle) => bundle.output);
|
||||
const commonOutputDirs = [resolveFromRoot(commonUiSiteDir)];
|
||||
|
||||
await runPrettier([
|
||||
...commonFormatSourceTargets,
|
||||
...uiArtifacts.formatSourceTargets,
|
||||
]);
|
||||
|
||||
await cleanOutputDirs([...commonOutputDirs, ...uiArtifacts.outputDirs]);
|
||||
|
||||
await Promise.all([
|
||||
...commonJsBundles.map(buildJsBundle),
|
||||
...uiArtifacts.jsBundles.map(buildJsBundle),
|
||||
]);
|
||||
await Promise.all(uiArtifacts.cssBundles.map(buildCssBundle));
|
||||
await Promise.all(uiArtifacts.htmlPages.map(buildHtmlPage));
|
||||
|
||||
await runPrettier([
|
||||
...commonGeneratedTargets,
|
||||
...uiArtifacts.formatGeneratedTargets,
|
||||
]);
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
console.error("Failed to build Forge Web UI bundles.");
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user