Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Exports

Server-side exports for other resources, such as housing or MLO armouries. Run exports that write to the database from an event handler or thread.

## GetRacks()

Returns all racks as detached copies, including stored guns and metadata. The result is sorted by rack ID.

## GetRack(id)

Returns a detached copy of the rack with the given ID, or `nil` if it does not exist.

## CreateRack(model, coords, heading, opts)

Creates a rack without an item and returns its integer ID on success, or `nil, error` on failure.

- `model`: rack model name (for example, a supported `sd_gunrack_*` model).
- `coords`: world position with numeric `x`, `y`, and `z` fields.
- `heading`: heading in degrees.
- `opts` (optional): `bucket`, `owner`, `ownerName`, `locked`, `access`, and `ignoreSpacing`.

Possible errors: `bad_model`, `bad_coords`, `too_close`, `overlap`, `save_failed`.

## DeleteRack(id, giveTo)

Deletes a rack and returns `true, guns` on success, or `false, error` on failure.

- Without `giveTo`, the rack's guns are returned to the caller as records containing `name`, `item`, `metadata`, `by`, and `at`; the caller chooses where to put them.
- With `giveTo` (a player server ID), the guns are given to that player's inventory before the rack is deleted. If they cannot carry all guns, the rack remains and the export returns `false, 'cannot_carry'`.

Possible errors include `no_rack`, `busy`, `cannot_carry`, and `save_failed`.
133 changes: 133 additions & 0 deletions locales/da.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
{
"title": "Våbenstativ",

"err": {
"too_fast": "Tag det roligt.",
"no_rack": "Det stativ er her ikke længere.",
"too_far": "Du er for langt fra stativet.",
"bad_slot": "Den plads findes ikke.",
"slot_taken": "Pladsen er allerede optaget.",
"slot_empty": "Der er ikke noget våben på pladsen.",
"no_weapon": "Du har ikke det våben på dig.",
"not_weapon": "Det er ikke et våben.",
"blacklisted": "Det våben må ikke hænge på et stativ.",
"no_fit": "Våbnet passer ikke på pladsen.",
"remove_failed": "Våbnet kunne ikke fjernes fra dit inventar.",
"save_failed": "Stativet kunne ikke gemmes. Intet blev ændret.",
"cannot_carry": "Du kan ikke bære det.",
"no_access": "Stativet er låst.",
"not_owner": "Stativet er ikke dit.",
"not_empty": "Tag våbnene ned fra stativet først.",
"bad_model": "Stativet er ikke sat op på denne server.",
"no_identity": "Din karakter er ikke indlæst endnu.",
"place_jobs": "Du må ikke stille våbenstativer op.",
"place_limit": "Du ejer allerede %d våbenstativer.",
"no_placement": "Brug stativgenstanden igen for at stille det op.",
"bad_spot": "Du kan ikke stille et stativ her.",
"too_far_place": "Du er for langt fra stedet.",
"spot_too_close": "Det er for tæt på et andet stativ.",
"spot_overlap": "Det ville overlappe et andet stativ.",
"spot_no_model": "Stativet er ikke sat op på denne server.",
"no_item": "Du har ikke længere stativgenstanden.",
"not_police": "Kun politiet kan beslaglægge våben.",
"force_disabled": "Denne lås kan ikke dirkes op.",
"force_not_locked": "Stativet er ikke låst.",
"force_not_needed": "Du kan allerede åbne stativet.",
"force_no_item": "Du skal bruge en %s.",
"force_busy": "Du er allerede i gang med en lås.",
"force_failed": "Låsen holdt.",
"admin_only": "Kun for personale.",
"rack_busy": "Nogen bruger stativet lige nu. Prøv igen.",
"no_player": "Der er ingen spiller med det id.",
"bad_request": "Serveren forstod ikke anmodningen.",
"bad_access": "Hver linje skal have et job- eller bandenavn (bogstaver, tal, _ - .) og en grad fra 0. Højst %d af hver.",
"no_character": "Der er ingen karakter med det borger-id.",
"no_identity_target": "Spillerens karakter er ikke indlæst.",
"already_owner": "Stativet tilhører allerede spilleren.",
"delete_disabled": "Sletning af stativer fra konsollen er slået fra på denne server.",
"console_failed": "Det gik galt: serveren stødte på en fejl (se konsollen).",
"logs_unreadable": "Logtabellen kunne ikke læses.",
"no_answer": "Serveren svarede ikke.",
"console_closed": "Konsollen er ikke åben."
},

"target": {
"store_generic": "Hæng våbnet op",
"store": "Hæng %s op",
"choose": "Vælg våben... (%s)",
"take_generic": "Tag våbnet",
"take": "Tag %s",
"seize_generic": "Beslaglæg våbnet",
"seize": "Beslaglæg %s",
"inspect": "Undersøg",
"lock": "Lås stativet",
"unlock": "Lås op og del (alle i nærheden kan bruge det)",
"pickup": "Tag stativet ned",
"force": "Dirk låsen op",
"slot": "Plads"
},

"progress": {
"store": "Hænger %s op",
"take": "Tager %s",
"seize": "Beslaglægger %s",
"pickup": "Tager stativet ned"
},

"notify": {
"stored": "%s hængt op.",
"no_weapons": "Du har ikke noget våben, der passer her.",
"taken": "Tog %s.",
"seized": "Beslaglagde %s.",
"locked": "Stativet er låst.",
"unlocked": "Stativet er låst op. Alle i nærheden kan bruge det.",
"picked_up": "Stativet er taget ned.",
"forced": "Låsen gav efter.",
"force_broke": "Låsen holdt, og dit værktøj gik i stykker.",
"force_failed": "Låsen holdt."
},

"menu": {
"choose_title": "Vælg et våben",
"weapon_desc": "Ammo %d - serienummer %s",
"no_serial": "intet"
},

"inspect": {
"serial": "Serienummer",
"ammo": "Ammo",
"condition": "Stand",
"attachments": "Tilbehør",
"none": "Intet",
"racked_by": "Hængt op af",
"racked_at": "%s den %s",
"unknown": "Ukendt"
},

"placement": {
"no_surface": "Der er ikke noget under stativet.",
"need_wall": "Det står ikke fladt på væggen. Stil det op ad en; %s lægger det fladt mod væggen bagved.",
"need_floor": "Her er for stejlt til stativet.",
"need_surface": "Her er for stejlt. Stativet skal stå på gulvet eller et bord.",
"floating": "Svæver %s m over gulvet. %s sætter det ned.",
"sunk": "Det er sunket ned i underlaget. %s sætter det ovenpå.",
"no_wall_behind": "Der er ingen væg lige bagved.",
"too_far": "Du er for langt væk.",
"overlap": "Overlapper et andet stativ.",
"cancelled": "Placering annulleret.",
"placed": "%s stillet op.",
"already": "Du er allerede ved at stille et stativ op.",
"key_select": "Stativplacering: tag fat i gizmoen",
"key_move": "Stativplacering: flyttetilstand",
"key_rotate": "Stativplacering: rotationstilstand",
"key_turn_left": "Stativplacering: drej til venstre",
"key_turn_right": "Stativplacering: drej til højre",
"key_look": "Stativplacering: hold for at kigge rundt",
"key_axes": "Stativplacering: lokale akser / verdensakser"
},

"admin": {
"command_help": "Personalekonsol til våbenstativer",
"delete_partial": "%d våben returneret. %d kunne ikke være i dit inventar. Stativet blev beholdt med dem."
}
}
8 changes: 6 additions & 2 deletions web/src/admin/AccessEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { AccessEntry, RackAccess } from '@/types';

Expand Down Expand Up @@ -37,6 +38,7 @@ export const AccessEditor = ({
onChange: (next: RackAccess) => void;
disabled?: boolean;
}) => {
const id = useId();
const set = (side: Side, list: AccessEntry[]) => onChange({ ...value, [side]: list });

const update = (side: Side, i: number, patch: Partial<AccessEntry>) =>
Expand All @@ -47,7 +49,7 @@ export const AccessEditor = ({
{SIDES.map(({ side, title, add, placeholder }) => (
<div key={side} className="gr-access-side">
<div className="gr-access-head">
<span className="ga-field-label">{title}</span>
<span className="ga-field-label" id={`${id}-${side}-heading`}>{title}</span>
<button
type="button"
className="ga-btn sm"
Expand All @@ -67,11 +69,13 @@ export const AccessEditor = ({
<input
className="ga-input"
value={entry.name}
id={`${id}-${side}-name-${i}`}
maxLength={NAME_MAX}
spellCheck={false}
placeholder={placeholder}
disabled={disabled}
aria-label={`${title} name`}
aria-label={i === 0 ? undefined : `${title} name`}
aria-labelledby={i === 0 ? `${id}-${side}-heading` : undefined}
onChange={e => update(side, i, { name: e.target.value })}
/>
<span className="gr-access-from">from grade</span>
Expand Down
27 changes: 17 additions & 10 deletions web/src/admin/GiveForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,19 @@ export const GiveForm = ({
if (!Number.isInteger(src) || src <= 0 || !item) return;

setBusy(true);
const resp = await fetchNui('admin:giveItem', { target: src, item, count });
setBusy(false);
try {
const resp = await fetchNui('admin:giveItem', { target: src, item, count });
const failure = failureOf(resp);

const failure = failureOf(resp);
if (failure) return showToast(failure, 'error');

if (failure) return showToast(failure, 'error');

const who = resp?.targetName ?? `player ${src}`;
showToast(`Gave ${resp?.count ?? count}x ${resp?.label ?? item} to ${who}.`, 'success');
const who = resp?.targetName ?? `player ${src}`;
showToast(`Gave ${resp?.count ?? count}x ${resp?.label ?? item} to ${who}.`, 'success');
} catch {
showToast('Could not give the rack item. Try again.', 'error');
} finally {
setBusy(false);
}
};

return (
Expand Down Expand Up @@ -90,7 +94,7 @@ export const GiveForm = ({
type="button"
className="ga-btn"
onClick={() => clamp(count - 1)}
disabled={count <= 1}
disabled={most < 1 || count <= 1}
aria-label="One fewer"
>
<Minus size={13} />
Expand All @@ -101,25 +105,28 @@ export const GiveForm = ({
min={1}
max={most}
value={count}
disabled={most < 1}
onChange={e => clamp(Number(e.target.value))}
/>
<button
type="button"
className="ga-btn"
onClick={() => clamp(count + 1)}
disabled={count >= most}
disabled={most < 1 || count >= most}
aria-label="One more"
>
<Plus size={13} />
</button>
</div>
</div>

{most < 1 && <div className="ga-field-label">Giving rack items is disabled on this server.</div>}

<button
type="button"
className="ga-btn green gr-give-btn"
onClick={() => void give()}
disabled={busy || !target || !item}
disabled={busy || most < 1 || !target || !item}
>
<Gift size={13} />
Give
Expand Down
41 changes: 24 additions & 17 deletions web/src/admin/LogsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,33 @@ export const LogsTab = ({ pollMs, refreshToken, showToast, openRack }: TabProps)
async (manual = false) => {
if (manual) setRefreshing(true);

const resp = await fetchNui('admin:getLogs', {
page,
size: PAGE_SIZE,
kind: kind ?? undefined,
q: sent || undefined,
});

if (!alive.current) return;

if (manual) setRefreshing(false);

if (isRefused(resp)) {
const message = failureOf(resp) ?? 'The server sent nothing back.';
try {
const resp = await fetchNui('admin:getLogs', {
page,
size: PAGE_SIZE,
kind: kind ?? undefined,
q: sent || undefined,
});

if (!alive.current) return;

if (isRefused(resp)) {
const message = failureOf(resp) ?? 'The server sent nothing back.';
setError(message);
if (manual) showToast(message, 'error');
return;
}

setError(null);
setData(pageOf(resp));
} catch {
if (!alive.current) return;
const message = 'Could not read the action log. Try again.';
setError(message);
if (manual) showToast(message, 'error');
return;
} finally {
if (manual && alive.current) setRefreshing(false);
}

setError(null);
setData(pageOf(resp));
},
[page, kind, sent, showToast],
);
Expand Down
46 changes: 27 additions & 19 deletions web/src/admin/WeaponsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,28 +91,35 @@ export const WeaponsTab = ({ refreshToken, racks, showToast, openRack }: TabProp
async (manual = false) => {
if (manual) setRefreshing(true);

const resp = await fetchNui('admin:searchWeapons', { q: sent, field });

if (!alive.current) return;

if (manual) setRefreshing(false);

if (isRefused(resp)) {
const message = failureOf(resp) ?? 'The server sent nothing back.';
try {
const resp = await fetchNui('admin:searchWeapons', { q: sent, field });

if (!alive.current) return;

if (isRefused(resp)) {
const message = failureOf(resp) ?? 'The server sent nothing back.';
setError(message);
if (manual) showToast(message, 'error');
return;
}

setError(null);
setHits(
listOf<unknown>(resp.list).flatMap(h => {
const hit = hitOf(h);
return hit ? [hit] : [];
}),
);
setTotal(numOf(resp.total));
setCapped(resp.capped === true);
} catch {
if (!alive.current) return;
const message = 'Could not search stored guns. Try again.';
setError(message);
if (manual) showToast(message, 'error');
return;
} finally {
if (manual && alive.current) setRefreshing(false);
}

setError(null);
setHits(
listOf<unknown>(resp.list).flatMap(h => {
const hit = hitOf(h);
return hit ? [hit] : [];
}),
);
setTotal(numOf(resp.total));
setCapped(resp.capped === true);
},
[sent, field, showToast],
);
Expand All @@ -134,6 +141,7 @@ export const WeaponsTab = ({ refreshToken, racks, showToast, openRack }: TabProp
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search a weapon, a serial, an owner or who racked it…"
aria-label="Search stored weapons"
spellCheck={false}
/>
{query && (
Expand Down