Add brand set and gear administration
This commit is contained in:
+49
-1
@@ -42,7 +42,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_nocase_idx ON stat_attrib
|
||||
CREATE TABLE IF NOT EXISTS talents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Backpack', 'Vest')),
|
||||
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Backpack', 'Body Armor', 'Vest')),
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -91,3 +91,51 @@ CREATE TABLE IF NOT EXISTS talent_tier_effects (
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(talent_tier_id, stat_attribute_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS brand_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS brand_sets_name_nocase_idx ON brand_sets(name COLLATE NOCASE);
|
||||
CREATE TABLE IF NOT EXISTS brand_set_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
brand_set_id TEXT NOT NULL REFERENCES brand_sets(id) ON DELETE CASCADE,
|
||||
pieces_required INTEGER NOT NULL CHECK(pieces_required IN (2, 3, 4)),
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
value REAL NOT NULL CHECK(value >= 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(brand_set_id, pieces_required)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slot TEXT NOT NULL CHECK(slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
|
||||
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
|
||||
level INTEGER NOT NULL CHECK(level BETWEEN 1 AND 50),
|
||||
base_health_min REAL NOT NULL CHECK(base_health_min >= 0),
|
||||
base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min),
|
||||
base_armor_min REAL,
|
||||
base_armor_max REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK(
|
||||
(slot IN ('Gloves', 'Knee Pads', 'Body Armor') AND base_armor_min IS NOT NULL AND base_armor_max IS NOT NULL AND base_armor_min >= 0 AND base_armor_max >= base_armor_min)
|
||||
OR
|
||||
(slot NOT IN ('Gloves', 'Knee Pads', 'Body Armor') AND base_armor_min IS NULL AND base_armor_max IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS gear_name_nocase_idx ON gear(name COLLATE NOCASE);
|
||||
CREATE TABLE IF NOT EXISTS gear_brand_sets (
|
||||
gear_id TEXT NOT NULL REFERENCES gear(id) ON DELETE CASCADE,
|
||||
brand_set_id TEXT NOT NULL REFERENCES brand_sets(id),
|
||||
PRIMARY KEY(gear_id, brand_set_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_talents (
|
||||
gear_id TEXT NOT NULL REFERENCES gear(id) ON DELETE CASCADE,
|
||||
talent_id TEXT NOT NULL REFERENCES talents(id),
|
||||
PRIMARY KEY(gear_id, talent_id)
|
||||
);
|
||||
|
||||
@@ -87,6 +87,15 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
||||
- Editors use `dashboard-editor` and appear before the affected table.
|
||||
- Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`.
|
||||
- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably.
|
||||
- Threshold-based set bonuses use compact `effect-band` groups. Each threshold is
|
||||
displayed as a fixed piece-count marker followed by its mapped stat and value,
|
||||
keeping progression readable in editors and table summaries.
|
||||
- Multi-value compatibility fields use a dense `selection-grid` of checkbox
|
||||
rows with a visible selected-count summary. Keep the entire row clickable and
|
||||
preserve native checkbox semantics for keyboard and assistive-technology use.
|
||||
- When compatibility is conditional on another field, show only valid choices,
|
||||
clear stale selections when the controlling field changes, and state required
|
||||
minimum selections in both the section label and validation feedback.
|
||||
|
||||
### Forms
|
||||
|
||||
|
||||
@@ -62,3 +62,7 @@
|
||||
|
||||
.discord-connection{display:grid;grid-template-columns:1fr;gap:9px;padding:10px}.connection-copy{display:flex!important;align-items:center;gap:10px}.connection-copy>img{width:28px;height:21px;padding:4px;background:#5865f2;border-radius:3px}.discord-link-button{min-height:42px;padding:8px 14px;display:flex;align-items:center;justify-content:center;gap:12px;background:#5865f2;color:#fff!important;border-radius:4px;font-size:14px!important;font-weight:600!important;letter-spacing:0;text-transform:none}.discord-link-button img{display:block;width:25px;height:19px;object-fit:contain}.discord-link-button:hover{background:#4752c4;filter:none}.discord-link-button:active{background:#3c45a5;filter:none}.discord-link-button:focus-visible{outline:2px solid #e0e3ff;outline-offset:3px}.discord-link-button[aria-busy="true"]::after{content:"";width:11px;height:11px;border:2px solid #fff;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}
|
||||
|
||||
.brand-name{display:block;max-width:420px;margin:10px 0}.effect-editor{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.effect-band{display:grid;grid-template-columns:58px minmax(0,1fr) 100px;align-items:end;gap:8px;padding:9px;background:#101314;border:1px solid #343a3b}.effect-band>strong{align-self:stretch;display:grid;place-content:center;text-align:center;color:#f6a821;font-size:23px;border-right:1px solid #343a3b}.effect-band>strong small{display:block;color:#707779;font-size:6px;letter-spacing:1px}.effect-band .field{margin:0}.split-actions{justify-content:space-between}.split-actions>div{display:flex;gap:8px}.brand-set-row{grid-template-columns:1.1fr 1.2fr 1.2fr 1.2fr .6fr .3fr}.empty-dashboard{min-height:120px;display:grid;place-content:center;text-align:center;gap:6px;color:#626a6c}.empty-dashboard strong{font-size:10px;letter-spacing:1.2px}.empty-dashboard span{font-size:8px}
|
||||
.gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d}
|
||||
@media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
const sections = [
|
||||
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
|
||||
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
|
||||
{ href: '/admin/equipment', label: 'Equipment types', code: 'EQPT', pending: true },
|
||||
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
|
||||
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' },
|
||||
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', pending: true }
|
||||
];
|
||||
</script>
|
||||
@@ -16,7 +17,7 @@
|
||||
</header>
|
||||
<div class="admin-dashboard">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>04 MODULES</b></div>
|
||||
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
|
||||
<nav aria-label="Admin sections">
|
||||
{#each sections as section}
|
||||
<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { requireAdmin } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const thresholds = [2, 3, 4] as const;
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const [brands, effects, stats] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
|
||||
]);
|
||||
return {
|
||||
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
|
||||
stats
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
requireAdmin(await getSessionUser(cookies));
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
|
||||
const effects = thresholds.map((pieces) => ({ pieces, statId: String(form.get(`stat${pieces}`) ?? ''), value: Number(form.get(`value${pieces}`)) }));
|
||||
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) {
|
||||
return fail(400, { brandError: 'Every threshold needs an attribute and a non-negative value.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { brandError: 'That brand set already exists.' });
|
||||
}
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||
const brandId = id || randomUUID(), now = new Date().toISOString();
|
||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||
for (const effect of effects) {
|
||||
await (await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
|
||||
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
|
||||
}
|
||||
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
requireAdmin(await getSessionUser(cookies));
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
return { brandArchived: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||
type Editor = { id: string; name: string; effects: Effect[] };
|
||||
const blank = (): Editor => ({ id: '', name: '', effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
|
||||
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
|
||||
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
|
||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||
$effect(() => {
|
||||
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = form.brandMode === 'add' ? blank() : null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Brand sets — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
|
||||
<div class="module-kpis"><div><span>TOTAL SETS</span><strong>{data.brands.length}</strong><small>All brand definitions</small></div><div><span>ACTIVE</span><strong>{data.brands.filter((brand) => Number(brand.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SET EFFECTS</span><strong>{data.brands.reduce((total, brand) => total + brand.effects.length, 0)}</strong><small>Mapped thresholds</small></div><div><span>ATTRIBUTES</span><strong>{data.stats.length}</strong><small>Available effect targets</small></div></div>
|
||||
{#if editing}
|
||||
<div class="dashboard-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||
<div class="effect-editor">
|
||||
{#each [2, 3, 4] as pieces}
|
||||
{@const effect = effectFor(editing, pieces)}
|
||||
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE SET</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
||||
{#each data.brands as brand}
|
||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
|
||||
{:else}
|
||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { requireAdmin } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all()
|
||||
]);
|
||||
return {
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
brands,
|
||||
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))
|
||||
};
|
||||
};
|
||||
|
||||
const readRange = (form: FormData, prefix: string) => {
|
||||
const min = Number(form.get(`${prefix}Min`));
|
||||
const max = Number(form.get(`${prefix}Max`));
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error();
|
||||
return [min, max] as const;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
requireAdmin(await getSessionUser(cookies));
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const tier = Number(form.get('tier'));
|
||||
const level = Number(form.get('level'));
|
||||
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
|
||||
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
|
||||
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2 || !Number.isInteger(level) || level < 1 || level > 50) {
|
||||
return fail(400, { gearError: 'Choose a valid slot, tier, and level.' });
|
||||
}
|
||||
if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' });
|
||||
const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
|
||||
if (talentCategory && talentIds.length < 1) return fail(400, { gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.` });
|
||||
if (!talentCategory && talentIds.length) return fail(400, { gearError: `${slot} gear cannot have talents.` });
|
||||
let health: readonly [number, number], armor: readonly [number, number] | [null, null];
|
||||
try {
|
||||
health = readRange(form, 'health');
|
||||
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
|
||||
} catch {
|
||||
return fail(400, { gearError: 'Base ranges require non-negative minimums and maximums greater than or equal to them.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearError: 'That gear name already exists.' });
|
||||
}
|
||||
const validBrands = await (await database.prepare(`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`)).get(...brandIds);
|
||||
if (Number(validBrands?.count) !== brandIds.length) return fail(400, { gearError: 'One or more selected brand sets are unavailable.' });
|
||||
if (talentCategory) {
|
||||
const allowedCategories = talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
|
||||
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
|
||||
}
|
||||
const gearId = id || randomUUID(), now = new Date().toISOString();
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
|
||||
.run(name, slot, tier, level, ...health, ...armor, now, id);
|
||||
} else {
|
||||
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,level,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`))
|
||||
.run(gearId, name, slot, tier, level, ...health, ...armor, now, now);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId);
|
||||
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
|
||||
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
|
||||
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
|
||||
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
requireAdmin(await getSessionUser(cookies));
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
return { gearArchived: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data, form } = $props();
|
||||
type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
|
||||
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
|
||||
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
|
||||
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
|
||||
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; };
|
||||
$effect(() => {
|
||||
if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = form.gearMode === 'add' ? blank() : null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Gear — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
|
||||
<div class="module-kpis"><div><span>TOTAL GEAR</span><strong>{data.gear.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.gear.filter((item) => Number(item.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SLOTS</span><strong>{new Set(data.gear.map((item) => item.slot)).size}/6</strong><small>Slots represented</small></div><div><span>BRAND SETS</span><strong>{data.brands.length}</strong><small>Compatibility options</small></div></div>
|
||||
{#if editing}
|
||||
<div class="dashboard-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<div class="gear-identity">
|
||||
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||
<label class="field">SLOT<select name="slot" value={editing.slot} onchange={(event) => changeSlot(event.currentTarget.value)}>{#each slots as slot}<option>{slot}</option>{/each}</select></label>
|
||||
<label class="field">TIER<select name="tier" bind:value={editing.tier}><option value={0}>0</option><option value={1}>1</option><option value={2}>2</option></select></label>
|
||||
<label class="field">LEVEL<input name="level" type="number" min="1" max="50" step="1" bind:value={editing.level} required /></label>
|
||||
</div>
|
||||
<div class="gear-ranges">
|
||||
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
|
||||
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
|
||||
</div>
|
||||
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">
|
||||
{#each data.brands as brand}<label><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
|
||||
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid talent-selection">
|
||||
{#each gearTalents(editing.slot) as talent}<label><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
|
||||
{/if}
|
||||
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
|
||||
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>PROGRESSION</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
||||
{#each data.gear as item}
|
||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span>{item.slot}</span><span>T{item.tier} · LVL {item.level}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><span title={brandNames(item.brandIds)}>{item.brandIds.length} COMPATIBLE</span><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@ export const load: PageServerLoad = async () => {
|
||||
(await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
|
||||
(await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
|
||||
]);
|
||||
return { talents: talents.map((talent) => ({...talent, tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({...tier, triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id)}))})), triggers, stats };
|
||||
return { talents: talents.map((talent) => ({...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category, tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({...tier, triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id)}))})), triggers, stats };
|
||||
};
|
||||
const parseEffects = (raw: FormDataEntryValue | null) => {
|
||||
const value = JSON.parse(String(raw ?? '[]')) as Array<{statId:string;value:string|number}>;
|
||||
@@ -42,11 +42,12 @@ export const actions: Actions = {
|
||||
const triggerIds={I:form.getAll('tierITriggerIds').map(String),II:form.getAll('tierIITriggerIds').map(String)};
|
||||
let tierEffects:{I:Array<{statId:string;value:number}>;II:Array<{statId:string;value:number}>};
|
||||
try{tierEffects={I:parseEffects(form.get('tierIEffects')),II:parseEffects(form.get('tierIIEffects'))};}catch{return fail(400,{talentError:'Effects could not be read.'});}
|
||||
if(name.length<2||!['Weapon','Backpack','Vest'].includes(category))return fail(400,{talentError:'Provide a valid talent name and category.'});
|
||||
if(name.length<2||!['Weapon','Backpack','Body Armor'].includes(category))return fail(400,{talentError:'Provide a valid talent name and category.'});
|
||||
for(const tier of ['I','II'] as const)if(!descriptions[tier]||!triggerIds[tier].length||!tierEffects[tier].length||tierEffects[tier].some((effect)=>!effect.statId||!Number.isFinite(effect.value)))return fail(400,{talentError:`Tier ${tier} requires a description, trigger, and valid attribute effects.`});
|
||||
const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name,category,id||'new'))return fail(409,{talentError:'That talent already exists in this category.'});
|
||||
const storedCategory=category==='Body Armor'?'Vest':category;
|
||||
const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name,storedCategory,id||'new'))return fail(409,{talentError:'That talent already exists in this category.'});
|
||||
const talentId=id||randomUUID(),now=new Date().toISOString();
|
||||
if(id)await(await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name,category,now,id);else await(await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId,name,category,now,now);
|
||||
if(id)await(await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name,storedCategory,now,id);else await(await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId,name,storedCategory,now,now);
|
||||
for(const tier of ['I','II'] as const){
|
||||
await(await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(),talentId,tier,descriptions[tier],'','',now,now);
|
||||
const tierRow=await(await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId,tier); const tierId=String(tierRow?.id);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</script>
|
||||
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Tier behavior, reusable activation conditions, uptime, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={()=>talent=blankTalent()}>ADD TALENT +</button></div></div>
|
||||
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item)=>item.category==='Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item)=>item.category!=='Weapon').length}</strong><small>Vest and backpack</small></div></div>
|
||||
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item)=>item.category==='Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item)=>item.category!=='Weapon').length}</strong><small>Body armor and backpack</small></div></div>
|
||||
{#if trigger}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id?'MODIFY TRIGGER':'NEW TRIGGER'}</p><h3>{trigger.id?trigger.name:'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={()=>trigger=null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>trigger=null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>{/if}
|
||||
{#if talent}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{talent.id?'MODIFY TALENT':'NEW TALENT'}</p><h3>{talent.id?talent.name:'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={()=>talent=null}>×</button></div><PendingForm action="?/saveTalent"><input type="hidden" name="id" value={talent.id}/><input type="hidden" name="tierIEffects" value={JSON.stringify(talent.I.effects)}/><input type="hidden" name="tierIIEffects" value={JSON.stringify(talent.II.effects)}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Vest</option></select></label></div><div class="tier-grid">{#each ['I','II'] as tier}<fieldset class="tier-card"><legend>TIER {tier}</legend><label class="field">DISPLAY DESCRIPTION<textarea name={tier==='I'?'tierIDescription':'tierIIDescription'} bind:value={talent[tier].description} required></textarea></label><p class="label">ACTIVATION TRIGGERS</p><div class="trigger-list">{#each data.triggers.filter((item)=>Number(item.active)===1) as item}<div><label><input name={tier==='I'?'tierITriggerIds':'tierIITriggerIds'} type="checkbox" value={item.id} checked={talent[tier].triggerIds.includes(String(item.id))} onchange={(event)=>toggle(tier as 'I'|'II',String(item.id),event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={()=>editTrigger(item)}>EDIT</button></div>{/each}</div><div class="effects-head"><p class="label">ATTRIBUTE EFFECTS</p><button type="button" onclick={()=>addEffect(tier as 'I'|'II')}>ADD EFFECT +</button></div>{#each talent[tier].effects as effect,index}<div class="talent-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name}</option>{/each}</select><input type="number" step="any" bind:value={effect.value} placeholder="VALUE" required/><button type="button" onclick={()=>removeEffect(tier as 'I'|'II',index)}>×</button></div>{/each}</fieldset>{/each}</div>{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>talent=null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div></PendingForm></div>{/if}
|
||||
{#if talent}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{talent.id?'MODIFY TALENT':'NEW TALENT'}</p><h3>{talent.id?talent.name:'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={()=>talent=null}>×</button></div><PendingForm action="?/saveTalent"><input type="hidden" name="id" value={talent.id}/><input type="hidden" name="tierIEffects" value={JSON.stringify(talent.I.effects)}/><input type="hidden" name="tierIIEffects" value={JSON.stringify(talent.II.effects)}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Body Armor</option></select></label></div><div class="tier-grid">{#each ['I','II'] as tier}<fieldset class="tier-card"><legend>TIER {tier}</legend><label class="field">DISPLAY DESCRIPTION<textarea name={tier==='I'?'tierIDescription':'tierIIDescription'} bind:value={talent[tier].description} required></textarea></label><p class="label">ACTIVATION TRIGGERS</p><div class="trigger-list">{#each data.triggers.filter((item)=>Number(item.active)===1) as item}<div><label><input name={tier==='I'?'tierITriggerIds':'tierIITriggerIds'} type="checkbox" value={item.id} checked={talent[tier].triggerIds.includes(String(item.id))} onchange={(event)=>toggle(tier as 'I'|'II',String(item.id),event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={()=>editTrigger(item)}>EDIT</button></div>{/each}</div><div class="effects-head"><p class="label">ATTRIBUTE EFFECTS</p><button type="button" onclick={()=>addEffect(tier as 'I'|'II')}>ADD EFFECT +</button></div>{#each talent[tier].effects as effect,index}<div class="talent-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name}</option>{/each}</select><input type="number" step="any" bind:value={effect.value} placeholder="VALUE" required/><button type="button" onclick={()=>removeEffect(tier as 'I'|'II',index)}>×</button></div>{/each}</fieldset>{/each}</div>{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>talent=null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div></PendingForm></div>{/if}
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers,'I')}>{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}</span><span class:configured={configured(item.tiers,'II')}>{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={()=>editTalent(item)}>EDIT</button></div>{/each}</div>
|
||||
|
||||
Reference in New Issue
Block a user