diff --git a/app.py b/app.py index 832a0ba..e3197b0 100644 --- a/app.py +++ b/app.py @@ -357,7 +357,7 @@ def loadout(): tab = request.args.get("tab", "guns") sort = request.args.get("sort", "weight_asc") - guns = armor = helmets = headwear = backpacks = rigs = [] + guns = armor = helmets = headwear = backpacks = rigs = plates = [] builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = [] requires = request.args.getlist("requires") # list of slot_nameids that must exist min_class = request.args.get("min_class", 0, type=int) @@ -443,6 +443,14 @@ def loadout(): ORDER BY {sort_frag} """, (min_capacity, min_capacity, min_class, min_class)).fetchall() + elif tab == "plates": + plates = conn.execute(f""" + SELECT * FROM gear_items + WHERE category = 'plate' + AND (? = 0 OR armor_class >= ?) + ORDER BY {sort_frag} + """, (min_class, min_class)).fetchall() + elif tab == "builder": builder_guns = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='gun' ORDER BY name").fetchall() builder_armor = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='armor' ORDER BY name").fetchall() @@ -450,12 +458,16 @@ def loadout(): builder_rigs = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='rig' ORDER BY name").fetchall() builder_backpacks = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='backpack' ORDER BY name").fetchall() + # IDs of carriers that have at least one open plate slot (shell weight only) + open_slot_rows = conn.execute("SELECT DISTINCT carrier_id FROM armor_open_slots").fetchall() + carrier_ids_with_open_slots = {row["carrier_id"] for row in open_slot_rows} + conn.close() return render_template( "loadout.html", tab=tab, sort=sort, guns=guns, armor=armor, helmets=helmets, headwear=headwear, - backpacks=backpacks, rigs=rigs, + backpacks=backpacks, rigs=rigs, plates=plates, slot_filters=LOADOUT_SLOT_FILTERS, requires=requires, min_class=min_class, min_capacity=min_capacity, @@ -464,6 +476,7 @@ def loadout(): builder_helmets=builder_helmets, builder_rigs=builder_rigs, builder_backpacks=builder_backpacks, + carrier_ids_with_open_slots=carrier_ids_with_open_slots, ) @@ -506,17 +519,19 @@ def gun_detail(gun_id): if row["mod_id"]: slots[sid]["mods"].append(dict(row)) - # Key slots to show at top (highlighted) + # Split into required vs optional slots KEY_SLOTS = {"mod_muzzle", "mod_magazine"} ordered_slots = [slots[s] for s in slot_order] - key_slots = [s for s in ordered_slots if s["slot_nameid"] in KEY_SLOTS] - other_slots = [s for s in ordered_slots if s["slot_nameid"] not in KEY_SLOTS] + # Required slots (always needed) shown at top — key slots (magazine/muzzle) highlighted + key_slots = [s for s in ordered_slots if s["required"] and s["slot_nameid"] in KEY_SLOTS] + req_slots = [s for s in ordered_slots if s["required"] and s["slot_nameid"] not in KEY_SLOTS] + optional_slots = [s for s in ordered_slots if not s["required"]] - # Lightest total (base + lightest per slot) + # Lightest total (base + lightest per REQUIRED slot only) lightest_total = (gun["weight_kg"] or 0) + sum( s["mods"][0]["weight_kg"] for s in ordered_slots - if s["mods"] and s["mods"][0]["weight_kg"] is not None + if s["required"] and s["mods"] and s["mods"][0]["weight_kg"] is not None ) conn.close() @@ -524,7 +539,8 @@ def gun_detail(gun_id): "gun_detail.html", gun=gun, key_slots=key_slots, - other_slots=other_slots, + req_slots=req_slots, + optional_slots=optional_slots, lightest_total=lightest_total, ) @@ -571,6 +587,45 @@ def gun_slots_json(gun_id): return jsonify(result) +@app.route("/loadout/carrier//slots.json") +def carrier_slots_json(carrier_id): + """Returns open plate slots and allowed plates for a carrier (armor or rig).""" + conn = get_db() + rows = conn.execute(""" + SELECT aos.slot_nameid, aos.zones, + p.id AS plate_id, p.name AS plate_name, p.short_name AS plate_short, + p.weight_kg, p.armor_class, p.durability, p.material + FROM armor_open_slots aos + LEFT JOIN armor_slot_plates asp ON asp.carrier_id = aos.carrier_id + AND asp.slot_nameid = aos.slot_nameid + LEFT JOIN gear_items p ON p.id = asp.plate_id + WHERE aos.carrier_id = ? + ORDER BY aos.slot_nameid, p.armor_class DESC, p.weight_kg ASC + """, (carrier_id,)).fetchall() + conn.close() + + # Group by slot + slots = {} + slot_order = [] + for row in rows: + sn = row["slot_nameid"] + if sn not in slots: + slots[sn] = {"slot_nameid": sn, "zones": row["zones"], "plates": []} + slot_order.append(sn) + if row["plate_id"]: + slots[sn]["plates"].append({ + "id": row["plate_id"], + "name": row["plate_name"], + "short_name": row["plate_short"], + "weight_kg": row["weight_kg"], + "armor_class": row["armor_class"], + "durability": row["durability"], + "material": row["material"], + }) + + return jsonify([slots[s] for s in slot_order]) + + @app.route("/loadout/save-build", methods=["POST"]) def save_build(): data = request.get_json() or {} diff --git a/import_gear.py b/import_gear.py index 405658a..bafa52e 100644 --- a/import_gear.py +++ b/import_gear.py @@ -64,6 +64,14 @@ GRAPHQL_QUERY_GEAR = """ durability material { name } zones + armorSlots { + __typename + ... on ItemArmorSlotOpen { + nameId + zones + allowedPlates { id } + } + } } } } @@ -112,6 +120,26 @@ GRAPHQL_QUERY_GEAR = """ class durability zones + armorSlots { + __typename + ... on ItemArmorSlotOpen { + nameId + zones + allowedPlates { id } + } + } + } + } + } + + plates: items(types: [armorPlate]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesArmorAttachment { + class + durability + material { name } + zones } } } @@ -211,6 +239,7 @@ def import_weapons(conn, weapons): def import_armor(conn, items): cursor = conn.cursor() counts = {"inserted": 0, "updated": 0, "skipped": 0} + slot_data = {} # carrier_id -> armorSlots list for item in items: item_id = item.get("id") name = item.get("name") @@ -229,8 +258,11 @@ def import_armor(conn, items): zones=zones, ) counts[result] += 1 + armor_slots = props.get("armorSlots") or [] + if armor_slots: + slot_data[item_id] = armor_slots conn.commit() - return counts + return counts, slot_data def import_helmets(conn, items): @@ -291,6 +323,7 @@ def import_backpacks(conn, items): def import_rigs(conn, items): cursor = conn.cursor() counts = {"inserted": 0, "updated": 0, "skipped": 0} + slot_data = {} # carrier_id -> armorSlots list for item in items: item_id = item.get("id") name = item.get("name") @@ -308,10 +341,75 @@ def import_rigs(conn, items): zones=zones, ) counts[result] += 1 + armor_slots = props.get("armorSlots") or [] + if armor_slots: + slot_data[item_id] = armor_slots + conn.commit() + return counts, slot_data + + +def import_plates(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + props = item.get("properties") or {} + material = (props.get("material") or {}).get("name") + zones = ",".join(props.get("zones") or []) or None + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "plate", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + armor_class=props.get("class"), + durability=props.get("durability"), + material=material, + zones=zones, + ) + counts[result] += 1 conn.commit() return counts +def import_armor_open_slots(conn, carrier_id, armor_slots): + """ + Insert open plate slots and their compatible plates for a carrier item. + armor_slots: list of armorSlots from the API (only ItemArmorSlotOpen are relevant). + """ + cursor = conn.cursor() + slot_count = 0 + plate_count = 0 + # Build set of known item IDs for fast lookup + known_ids = {row[0] for row in cursor.execute("SELECT id FROM gear_items").fetchall()} + + for slot in armor_slots: + if slot.get("__typename") != "ItemArmorSlotOpen": + continue + slot_nameid = slot.get("nameId") + if not slot_nameid: + continue + zones = ",".join(slot.get("zones") or []) or None + cursor.execute(""" + INSERT OR REPLACE INTO armor_open_slots (carrier_id, slot_nameid, zones) + VALUES (?, ?, ?) + """, (carrier_id, slot_nameid, zones)) + slot_count += 1 + + for plate in (slot.get("allowedPlates") or []): + plate_id = plate.get("id") + if not plate_id or plate_id not in known_ids: + continue + cursor.execute(""" + INSERT OR IGNORE INTO armor_slot_plates (carrier_id, slot_nameid, plate_id) + VALUES (?, ?, ?) + """, (carrier_id, slot_nameid, plate_id)) + plate_count += 1 + + return slot_count, plate_count + + def import_suppressors(conn, items): cursor = conn.cursor() counts = {"inserted": 0, "updated": 0, "skipped": 0} @@ -438,6 +536,7 @@ def main(): rigs = gear_data.get("rigs", []) suppressors = gear_data.get("suppressors", []) mods = gear_data.get("mods", []) + plates = gear_data.get("plates", []) # Wearables: only keep those whose properties resolved to ItemPropertiesHelmet # (i.e. they have 'class' or 'headZones' set — not decorative items) @@ -451,7 +550,8 @@ def main(): and (w["properties"].get("class") or w["properties"].get("headZones")) ] print(f" armor={len(armor)}, helmets={len(helmets)}, wearable_helmets={len(wearable_helmets)}, " - f"backpacks={len(backpacks)}, rigs={len(rigs)}, suppressors={len(suppressors)}, mods={len(mods)}") + f"backpacks={len(backpacks)}, rigs={len(rigs)}, suppressors={len(suppressors)}, " + f"mods={len(mods)}, plates={len(plates)})") # Phase 2: insert into DB conn = sqlite3.connect(DB_PATH) @@ -462,8 +562,12 @@ def main(): wcounts, slot_data = import_weapons(conn, weapons) print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped") + print("Importing plates (armorPlate items)...") + c = import_plates(conn, plates) + print(f" plates: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + print("Importing armor...") - c = import_armor(conn, armor) + c, armor_slot_data = import_armor(conn, armor) print(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") print("Importing helmets...") @@ -479,7 +583,7 @@ def main(): print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") print("Importing rigs...") - c = import_rigs(conn, rigs) + c, rig_slot_data = import_rigs(conn, rigs) print(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") print("Importing suppressors...") @@ -496,6 +600,19 @@ def main(): print(f" gun_slots: {slots_ins} rows") print(f" gun_slot_items: {slot_items_ins} rows inserted, {slot_items_skip} skipped (item not in DB)") + # Phase 3b: armor open slots (needs plates in DB first) + print("Importing armor open plate slots...") + total_open_slots = 0 + total_plate_links = 0 + all_carrier_slots = {**armor_slot_data, **rig_slot_data} + for carrier_id, armor_slots in all_carrier_slots.items(): + sc, pc = import_armor_open_slots(conn, carrier_id, armor_slots) + total_open_slots += sc + total_plate_links += pc + conn.commit() + print(f" armor_open_slots: {total_open_slots} rows") + print(f" armor_slot_plates: {total_plate_links} rows") + # Phase 4: classify mod_type for mods based on which slots they appear in print("Classifying mod types from slot data...") cursor = conn.cursor() diff --git a/migration_add_plates.sql b/migration_add_plates.sql new file mode 100644 index 0000000..43876d5 --- /dev/null +++ b/migration_add_plates.sql @@ -0,0 +1,21 @@ +-- Migration: add armor plates support +-- Run: sqlite3 tarkov.db < migration_add_plates.sql + +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS armor_open_slots ( + carrier_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + slot_nameid TEXT NOT NULL, + zones TEXT, + PRIMARY KEY (carrier_id, slot_nameid) +); + +CREATE TABLE IF NOT EXISTS armor_slot_plates ( + carrier_id TEXT NOT NULL, + slot_nameid TEXT NOT NULL, + plate_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + PRIMARY KEY (carrier_id, slot_nameid, plate_id), + FOREIGN KEY (carrier_id, slot_nameid) REFERENCES armor_open_slots(carrier_id, slot_nameid) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_armor_slot_plates_plate ON armor_slot_plates(plate_id); diff --git a/migrations_v2.sql b/migrations_v2.sql index d1c9bf4..ebb9688 100644 --- a/migrations_v2.sql +++ b/migrations_v2.sql @@ -60,6 +60,26 @@ CREATE TABLE IF NOT EXISTS gun_slot_items ( CREATE INDEX IF NOT EXISTS idx_gun_slot_items_item ON gun_slot_items(item_id); +-- Open plate slots on armor/rig carriers (ItemArmorSlotOpen from tarkov API) +-- Weight of a carrier does NOT include plates in these slots +CREATE TABLE IF NOT EXISTS armor_open_slots ( + carrier_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + slot_nameid TEXT NOT NULL, -- e.g. "front_plate", "back_plate", "left_side_plate" + zones TEXT, -- comma-separated zone names e.g. "FR. PLATE,BCK. PLATE" + PRIMARY KEY (carrier_id, slot_nameid) +); + +-- Which plates are compatible with each open slot on a carrier +CREATE TABLE IF NOT EXISTS armor_slot_plates ( + carrier_id TEXT NOT NULL, + slot_nameid TEXT NOT NULL, + plate_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + PRIMARY KEY (carrier_id, slot_nameid, plate_id), + FOREIGN KEY (carrier_id, slot_nameid) REFERENCES armor_open_slots(carrier_id, slot_nameid) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_armor_slot_plates_plate ON armor_slot_plates(plate_id); + -- Saved loadout builds CREATE TABLE IF NOT EXISTS saved_builds ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/tarkov.db b/tarkov.db index b211171..3e8aef5 100644 Binary files a/tarkov.db and b/tarkov.db differ diff --git a/templates/gun_detail.html b/templates/gun_detail.html index f2e6e04..bcd78b0 100644 --- a/templates/gun_detail.html +++ b/templates/gun_detail.html @@ -197,106 +197,75 @@
Lightest possible build: {{ "%.3f"|format(lightest_total) }} kg - base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per slot + base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per required slot
- {% if key_slots %} -
-
Key slots
- {% for slot in key_slots %} - {% set lightest = slot.mods[0] if slot.mods else none %} -
-
- {{ slot.slot_name }} - key slot - {% if slot.required %}required{% endif %} - {{ slot.mods | length }} mods - - {% if lightest and lightest.weight_kg is not none %} - lightest {{ "%.3f"|format(lightest.weight_kg) }} kg - {% else %}—{% endif %} - - -
-
- {% if slot.mods %} - {% for mod in slot.mods %} -
- {% if mod.grid_image_url %} - - {% else %} -
- {% endif %} -
- {{ mod.mod_name }} - {% if mod.mod_short and mod.mod_short != mod.mod_name %} - {{ mod.mod_short }} - {% endif %} -
- {% if mod.wiki_url %} - wiki - {% endif %} - - {% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %} - -
- {% endfor %} - {% else %} -
No compatible mods found in database.
- {% endif %} -
+ {# ---- Macro to render a slot card ---- #} + {% macro slot_card(slot, extra_class='', open=False) %} + {% set lightest = slot.mods[0] if slot.mods else none %} +
+
+ {{ slot.slot_name }} + {% if extra_class == 'key-slot' %}key slot{% endif %} + {{ slot.mods | length }} mods + + {% if lightest and lightest.weight_kg is not none %} + lightest {{ "%.3f"|format(lightest.weight_kg) }} kg + {% else %}—{% endif %} + +
+
+ {% if slot.mods %} + {% for mod in slot.mods %} +
+ {% if mod.grid_image_url %} + + {% else %} +
+ {% endif %} +
+ {{ mod.mod_name }} + {% if mod.mod_short and mod.mod_short != mod.mod_name %} + {{ mod.mod_short }} + {% endif %} +
+ {% if mod.wiki_url %} + wiki + {% endif %} + + {% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %} + +
+ {% endfor %} + {% else %} +
No compatible mods found in database.
+ {% endif %} +
+
+ {% endmacro %} + + {# ---- Required slots (key slots highlighted, open by default) ---- #} + {% if key_slots or req_slots %} +
+
Required slots (counted in lightest build weight)
+ {% for slot in key_slots %} + {{ slot_card(slot, 'key-slot', open=True) }} + {% endfor %} + {% for slot in req_slots %} + {{ slot_card(slot, '', open=False) }} {% endfor %}
{% endif %} - {% if other_slots %} - + {# ---- Optional slots (collapsed behind toggle) ---- #} + {% if optional_slots %} +
-
All other slots
- {% for slot in other_slots %} - {% set lightest = slot.mods[0] if slot.mods else none %} -
-
- {{ slot.slot_name }} - {% if slot.required %}required{% endif %} - {{ slot.mods | length }} mods - - {% if lightest and lightest.weight_kg is not none %} - lightest {{ "%.3f"|format(lightest.weight_kg) }} kg - {% else %}—{% endif %} - - -
-
- {% if slot.mods %} - {% for mod in slot.mods %} -
- {% if mod.grid_image_url %} - - {% else %} -
- {% endif %} -
- {{ mod.mod_name }} - {% if mod.mod_short and mod.mod_short != mod.mod_name %} - {{ mod.mod_short }} - {% endif %} -
- {% if mod.wiki_url %} - wiki - {% endif %} - - {% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %} - -
- {% endfor %} - {% else %} -
No compatible mods found in database.
- {% endif %} -
-
+
Optional slots (not counted in lightest build weight)
+ {% for slot in optional_slots %} + {{ slot_card(slot) }} {% endfor %}
@@ -311,8 +280,8 @@ const div = document.getElementById('other-slots'); div.classList.toggle('visible'); btn.textContent = div.classList.contains('visible') - ? 'Hide other slots ▲' - : 'Show {{ other_slots | length }} other slots ▼'; + ? 'Hide optional slots ▲' + : 'Show {{ optional_slots | length }} optional slots ▼'; } diff --git a/templates/loadout.html b/templates/loadout.html index 9e5317f..8b927c2 100644 --- a/templates/loadout.html +++ b/templates/loadout.html @@ -231,7 +231,7 @@

- {% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('builder','Build Builder')] %} + {% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('plates','Plates'),('builder','Build Builder')] %} {{ t_label }} {% endfor %}
@@ -328,6 +328,7 @@ border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px; } .slot-pill.key { border-color: #5a7a3a; background: #141e10; } + .slot-pill.optional { opacity: 0.55; } .slot-pill .sp-name { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; } .slot-pill .sp-mod { font-size: 0.82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; } .slot-pill .sp-w { color: var(--amber); font-size: 0.82rem; font-weight: bold; margin-top: 2px; } @@ -382,23 +383,39 @@ const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)'); if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0; + // Only sum required slots for the lightest build weight let total = baseWeight; - let pills = ''; + let reqPills = ''; + let optPills = ''; for (const s of slots) { const isKey = KEY.has(s.slot_nameid); const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—'; - if (s.weight_kg != null) total += s.weight_kg; - pills += `
- ${s.slot_name} - ${s.mod_name || '—'} - ${w} -
`; + if (s.required) { + if (s.weight_kg != null) total += s.weight_kg; + reqPills += `
+ ${s.slot_name} + ${s.mod_name || '—'} + ${w} +
`; + } else { + optPills += `
+ ${s.slot_name} + ${s.mod_name || '—'} + ${w} +
`; + } } + const optSection = optPills + ? `
Optional slots (not included in weight)
+
${optPills}
` + : ''; + inner.innerHTML = ` -
${pills}
+
${reqPills || 'No required slots'}
+ ${optSection} `; } @@ -450,7 +467,10 @@ {{ item.durability | int if item.durability else '—' }} {{ item.material or '—' }} {{ item.zones or '—' }} - {% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %} + + {% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %} + {% if item.id in carrier_ids_with_open_slots %}
no plates{% endif %} + {% else %} No armor found. @@ -653,7 +673,10 @@ {{ item.capacity or '—' }} {{ item.zones or '—' }} - {% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %} + + {% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %} + {% if item.id in carrier_ids_with_open_slots %}
no plates{% endif %} + {% else %} No rigs found. @@ -662,6 +685,63 @@ {% endif %} + {# =============================== PLATES TAB =============================== #} + {% if tab == "plates" %} +

+ Armor plates that slot into plate carriers. Carrier shell weight does not include plates — add them separately when building your loadout. +

+
+ + + + + + +
+ + + + + + + + + {% for item in plates %} + + + + + + + + + + {% else %} + + {% endfor %} + +
NameClassDurabilityMaterialZonesWeight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + + {% if item.armor_class %} + {{ item.armor_class }} + {% else %}—{% endif %} + {{ item.durability | int if item.durability else '—' }}{{ item.material or '—' }}{{ item.zones or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No plates found.
+ {% endif %} + {# =============================== BUILD BUILDER TAB =============================== #} {% if tab == "builder" %}