Compare commits
7 Commits
84768ae587
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a5755b6f | ||
|
|
90f2601c1d | ||
|
|
7650633af4 | ||
|
|
289c45e233 | ||
|
|
4893f3deee | ||
|
|
394c7ebde7 | ||
|
|
9d572f5d15 |
@@ -1,4 +1,4 @@
|
|||||||
# OnlyScavs v0.1.1
|
# OnlyScavs v0.2
|
||||||
|
|
||||||
A personal Escape from Tarkov database and toolkit. The goal is to maintain a **local SQLite database that I fully control** — tarkov.dev is used only as a one-time (or on-demand) data source to seed it. Once imported, the local DB is the source of truth and can be edited, annotated, and extended freely without relying on any external API being up or accurate.
|
A personal Escape from Tarkov database and toolkit. The goal is to maintain a **local SQLite database that I fully control** — tarkov.dev is used only as a one-time (or on-demand) data source to seed it. Once imported, the local DB is the source of truth and can be edited, annotated, and extended freely without relying on any external API being up or accurate.
|
||||||
|
|
||||||
|
|||||||
598
app.py
598
app.py
@@ -1,31 +1,85 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__, static_folder="assets", static_url_path="/assets")
|
||||||
DB_PATH = "tarkov.db"
|
DB_PATH = "tarkov.db"
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_key_ids_and_maps():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
|
||||||
|
# Backfill missing key IDs with their api_id so ratings can join correctly.
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE keys
|
||||||
|
SET id = api_id
|
||||||
|
WHERE id IS NULL AND api_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# If key_maps was created with INTEGER key_id, migrate to TEXT to match keys.id.
|
||||||
|
cols = conn.execute("PRAGMA table_info(key_maps)").fetchall()
|
||||||
|
key_id_type = None
|
||||||
|
if cols:
|
||||||
|
for col in cols:
|
||||||
|
if col["name"] == "key_id":
|
||||||
|
key_id_type = (col["type"] or "").upper()
|
||||||
|
break
|
||||||
|
if key_id_type and key_id_type != "TEXT":
|
||||||
|
conn.execute("ALTER TABLE key_maps RENAME TO key_maps_old")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE key_maps (
|
||||||
|
key_id TEXT NOT NULL,
|
||||||
|
map_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (key_id, map_id),
|
||||||
|
FOREIGN KEY (key_id) REFERENCES keys(id),
|
||||||
|
FOREIGN KEY (map_id) REFERENCES maps(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO key_maps (key_id, map_id)
|
||||||
|
SELECT CAST(key_id AS TEXT), map_id
|
||||||
|
FROM key_maps_old
|
||||||
|
WHERE key_id IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM keys WHERE id = CAST(key_id AS TEXT))
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE key_maps_old")
|
||||||
|
|
||||||
|
# Remove orphaned ratings created with "None" or missing keys.
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM key_ratings
|
||||||
|
WHERE key_id IS NULL
|
||||||
|
OR key_id = 'None'
|
||||||
|
OR key_id NOT IN (SELECT id FROM keys)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_migrate_key_ids_and_maps()
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def landing():
|
||||||
|
return render_template("landing.html")
|
||||||
|
|
||||||
|
|
||||||
|
def _keys_context():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
maps = conn.execute("""
|
maps = conn.execute("SELECT id, name FROM maps ORDER BY name").fetchall()
|
||||||
SELECT id, name
|
|
||||||
FROM maps
|
|
||||||
ORDER BY name
|
|
||||||
""").fetchall()
|
|
||||||
map_filter = request.args.get("map_id", type=int)
|
map_filter = request.args.get("map_id", type=int)
|
||||||
sort = request.args.get("sort", "priority_desc")
|
sort = request.args.get("sort", "priority_desc")
|
||||||
show = request.args.get("show", "all")
|
show = request.args.get("show", "all")
|
||||||
key_map_rows = conn.execute("""
|
key_map_rows = conn.execute("SELECT key_id, map_id FROM key_maps").fetchall()
|
||||||
SELECT key_id, map_id
|
|
||||||
FROM key_maps
|
|
||||||
""").fetchall()
|
|
||||||
key_maps = {}
|
key_maps = {}
|
||||||
for row in key_map_rows:
|
for row in key_map_rows:
|
||||||
key_maps.setdefault(row["key_id"], set()).add(row["map_id"])
|
key_maps.setdefault(row["key_id"], set()).add(row["map_id"])
|
||||||
@@ -50,9 +104,7 @@ def index():
|
|||||||
AND kmf.map_id = ?
|
AND kmf.map_id = ?
|
||||||
"""
|
"""
|
||||||
params.append(map_filter)
|
params.append(map_filter)
|
||||||
key_query += """
|
key_query += " LEFT JOIN key_ratings r ON k.id = r.key_id "
|
||||||
LEFT JOIN key_ratings r ON k.id = r.key_id
|
|
||||||
"""
|
|
||||||
if show == "rated":
|
if show == "rated":
|
||||||
key_query += " WHERE r.priority IS NOT NULL "
|
key_query += " WHERE r.priority IS NOT NULL "
|
||||||
elif show == "unrated":
|
elif show == "unrated":
|
||||||
@@ -74,15 +126,13 @@ def index():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
key_maps = {k: sorted(v) for k, v in key_maps.items()}
|
key_maps = {k: sorted(v) for k, v in key_maps.items()}
|
||||||
return render_template(
|
return dict(keys=keys, maps=maps, key_maps=key_maps,
|
||||||
"index.html",
|
map_filter=map_filter, sort=sort, show=show)
|
||||||
keys=keys,
|
|
||||||
maps=maps,
|
|
||||||
key_maps=key_maps,
|
@app.route("/keys")
|
||||||
map_filter=map_filter,
|
def keys_page():
|
||||||
sort=sort,
|
return render_template("keys.html", **_keys_context())
|
||||||
show=show,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/rate", methods=["POST"])
|
@app.route("/rate", methods=["POST"])
|
||||||
@@ -129,7 +179,7 @@ def rate_key():
|
|||||||
redirect_args["sort"] = sort
|
redirect_args["sort"] = sort
|
||||||
if show:
|
if show:
|
||||||
redirect_args["show"] = show
|
redirect_args["show"] = show
|
||||||
base_url = url_for("index", **redirect_args)
|
base_url = url_for("keys_page", **redirect_args)
|
||||||
return redirect(f"{base_url}#key-{key_id}")
|
return redirect(f"{base_url}#key-{key_id}")
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +238,7 @@ def rate_all():
|
|||||||
redirect_args["sort"] = sort
|
redirect_args["sort"] = sort
|
||||||
if show:
|
if show:
|
||||||
redirect_args["show"] = show
|
redirect_args["show"] = show
|
||||||
base_url = url_for("index", **redirect_args)
|
base_url = url_for("keys_page", **redirect_args)
|
||||||
if save_one:
|
if save_one:
|
||||||
return redirect(f"{base_url}#key-{save_one}")
|
return redirect(f"{base_url}#key-{save_one}")
|
||||||
return redirect(base_url)
|
return redirect(base_url)
|
||||||
@@ -198,6 +248,7 @@ def rate_all():
|
|||||||
def quests():
|
def quests():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
only_collector = request.args.get("collector") == "1"
|
only_collector = request.args.get("collector") == "1"
|
||||||
|
view = request.args.get("view", "flow") # "flow" or "list"
|
||||||
|
|
||||||
# All quests + done state
|
# All quests + done state
|
||||||
all_quests = conn.execute("""
|
all_quests = conn.execute("""
|
||||||
@@ -243,7 +294,7 @@ def quests():
|
|||||||
|
|
||||||
# Filter to collector-only if requested
|
# Filter to collector-only if requested
|
||||||
if only_collector:
|
if only_collector:
|
||||||
visible = collector_prereqs | {collector_row["id"]}
|
visible = set(collector_prereqs)
|
||||||
else:
|
else:
|
||||||
visible = set(quest_by_id.keys())
|
visible = set(quest_by_id.keys())
|
||||||
|
|
||||||
@@ -270,12 +321,14 @@ def quests():
|
|||||||
visible=visible,
|
visible=visible,
|
||||||
collector_prereqs=collector_prereqs,
|
collector_prereqs=collector_prereqs,
|
||||||
only_collector=only_collector,
|
only_collector=only_collector,
|
||||||
|
view=view,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/collector")
|
@app.route("/collector")
|
||||||
def collector():
|
def collector():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
view = request.args.get("view", "flow")
|
||||||
collector = conn.execute(
|
collector = conn.execute(
|
||||||
"SELECT id FROM quests WHERE name = 'Collector'"
|
"SELECT id FROM quests WHERE name = 'Collector'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -284,33 +337,76 @@ def collector():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return "Run import_quests.py first to populate quest data.", 503
|
return "Run import_quests.py first to populate quest data.", 503
|
||||||
|
|
||||||
# Recursive CTE: all transitive prerequisites, then keep only leaves
|
# All quests + done state
|
||||||
# (quests that are not themselves a dependency of another prereq)
|
all_quests = conn.execute("""
|
||||||
prereqs = conn.execute("""
|
SELECT q.id, q.name, q.trader, q.wiki_link,
|
||||||
|
COALESCE(qp.done, 0) AS done
|
||||||
|
FROM quests q
|
||||||
|
LEFT JOIN quest_progress qp ON q.id = qp.quest_id
|
||||||
|
ORDER BY q.trader, q.name
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
# All dependency edges
|
||||||
|
all_deps = conn.execute("SELECT quest_id, depends_on FROM quest_deps").fetchall()
|
||||||
|
|
||||||
|
# Collector prereq set (transitive)
|
||||||
|
rows = conn.execute("""
|
||||||
WITH RECURSIVE deps(quest_id) AS (
|
WITH RECURSIVE deps(quest_id) AS (
|
||||||
SELECT depends_on FROM quest_deps WHERE quest_id = ?
|
SELECT depends_on FROM quest_deps WHERE quest_id = ?
|
||||||
UNION
|
UNION
|
||||||
SELECT qd.depends_on FROM quest_deps qd
|
SELECT qd.depends_on FROM quest_deps qd
|
||||||
JOIN deps d ON qd.quest_id = d.quest_id
|
JOIN deps d ON qd.quest_id = d.quest_id
|
||||||
)
|
)
|
||||||
SELECT q.id, q.name, q.trader, q.wiki_link,
|
SELECT quest_id FROM deps
|
||||||
COALESCE(qp.done, 0) AS done
|
|
||||||
FROM quests q
|
|
||||||
JOIN deps d ON q.id = d.quest_id
|
|
||||||
LEFT JOIN quest_progress qp ON q.id = qp.quest_id
|
|
||||||
WHERE q.id NOT IN (
|
|
||||||
SELECT qd2.depends_on
|
|
||||||
FROM quest_deps qd2
|
|
||||||
WHERE qd2.quest_id IN (SELECT quest_id FROM deps)
|
|
||||||
AND qd2.depends_on IN (SELECT quest_id FROM deps)
|
|
||||||
)
|
|
||||||
ORDER BY q.trader, q.name
|
|
||||||
""", (collector["id"],)).fetchall()
|
""", (collector["id"],)).fetchall()
|
||||||
|
collector_prereqs = {r[0] for r in rows}
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
total = len(prereqs)
|
|
||||||
done = sum(1 for q in prereqs if q["done"])
|
# Build lookup structures
|
||||||
return render_template("collector.html", quests=prereqs, total=total, done=done)
|
quest_by_id = {q["id"]: q for q in all_quests}
|
||||||
|
# children[parent_id] = [child_id, ...] (child depends_on parent)
|
||||||
|
children = {}
|
||||||
|
parents = {}
|
||||||
|
for dep in all_deps:
|
||||||
|
child, parent = dep["quest_id"], dep["depends_on"]
|
||||||
|
children.setdefault(parent, []).append(child)
|
||||||
|
parents.setdefault(child, []).append(parent)
|
||||||
|
# Sort each child list by quest name
|
||||||
|
for parent_id in children:
|
||||||
|
children[parent_id].sort(key=lambda i: quest_by_id[i]["name"] if i in quest_by_id else "")
|
||||||
|
|
||||||
|
visible = set(collector_prereqs)
|
||||||
|
|
||||||
|
# Root quests: in visible set and have no parents also in visible set
|
||||||
|
roots = [
|
||||||
|
qid for qid in visible
|
||||||
|
if not any(p in visible for p in parents.get(qid, []))
|
||||||
|
]
|
||||||
|
|
||||||
|
# Group roots by trader, sorted
|
||||||
|
trader_roots = {}
|
||||||
|
for qid in sorted(roots, key=lambda i: (quest_by_id[i]["trader"], quest_by_id[i]["name"])):
|
||||||
|
t = quest_by_id[qid]["trader"]
|
||||||
|
trader_roots.setdefault(t, []).append(qid)
|
||||||
|
|
||||||
|
traders = sorted(trader_roots.keys())
|
||||||
|
|
||||||
|
total = len(collector_prereqs)
|
||||||
|
done = sum(1 for qid in collector_prereqs if qid in quest_by_id and quest_by_id[qid]["done"])
|
||||||
|
return render_template(
|
||||||
|
"collector.html",
|
||||||
|
quest_by_id=quest_by_id,
|
||||||
|
children=children,
|
||||||
|
trader_roots=trader_roots,
|
||||||
|
traders=traders,
|
||||||
|
visible=visible,
|
||||||
|
collector_prereqs=collector_prereqs,
|
||||||
|
collector_id=collector["id"],
|
||||||
|
total=total,
|
||||||
|
done=done,
|
||||||
|
view=view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/collector/toggle", methods=["POST"])
|
@app.route("/collector/toggle", methods=["POST"])
|
||||||
@@ -340,24 +436,62 @@ LOADOUT_SLOT_FILTERS = [
|
|||||||
|
|
||||||
def _sort_col(sort):
|
def _sort_col(sort):
|
||||||
return {
|
return {
|
||||||
"weight_asc": "weight_kg ASC NULLS LAST",
|
"weight_asc": "weight_kg ASC NULLS LAST",
|
||||||
"weight_desc": "weight_kg DESC NULLS LAST",
|
"weight_desc": "weight_kg DESC NULLS LAST",
|
||||||
"name_asc": "name ASC",
|
"name_asc": "name ASC",
|
||||||
"name_desc": "name DESC",
|
"name_desc": "name DESC",
|
||||||
"class_desc": "armor_class DESC NULLS LAST, weight_kg ASC NULLS LAST",
|
"class_desc": "armor_class DESC NULLS LAST, weight_kg ASC NULLS LAST",
|
||||||
"class_asc": "armor_class ASC NULLS LAST, weight_kg ASC NULLS LAST",
|
"class_asc": "armor_class ASC NULLS LAST, weight_kg ASC NULLS LAST",
|
||||||
"capacity_desc": "capacity DESC NULLS LAST, weight_kg ASC NULLS LAST",
|
"capacity_desc": "capacity DESC NULLS LAST, weight_kg ASC NULLS LAST",
|
||||||
"capacity_asc": "capacity ASC NULLS LAST, weight_kg ASC NULLS LAST",
|
"capacity_asc": "capacity ASC NULLS LAST, weight_kg ASC NULLS LAST",
|
||||||
|
# carry_efficiency sorts are handled in Python after query; fall back to weight
|
||||||
|
"efficiency_desc": "weight_kg ASC NULLS LAST",
|
||||||
|
"efficiency_asc": "weight_kg ASC NULLS LAST",
|
||||||
}.get(sort, "weight_kg ASC NULLS LAST")
|
}.get(sort, "weight_kg ASC NULLS LAST")
|
||||||
|
|
||||||
|
|
||||||
|
def _carry_efficiency(weight_kg, slot_count):
|
||||||
|
"""Return (slots_per_kg, kg_per_slot) or (None, None) if inputs are invalid."""
|
||||||
|
if not weight_kg or not slot_count:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
w = float(weight_kg)
|
||||||
|
s = int(slot_count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, None
|
||||||
|
if w <= 0 or s <= 0:
|
||||||
|
return None, None
|
||||||
|
return round(s / w, 2), round(w / s, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_with_efficiency(rows):
|
||||||
|
"""Attach slots_per_kg and kg_per_slot to each sqlite3.Row (returns plain dicts)."""
|
||||||
|
enriched = []
|
||||||
|
for row in rows:
|
||||||
|
d = dict(row)
|
||||||
|
d["slots_per_kg"], d["kg_per_slot"] = _carry_efficiency(
|
||||||
|
d.get("weight_kg"), d.get("capacity")
|
||||||
|
)
|
||||||
|
enriched.append(d)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_enriched(rows, sort):
|
||||||
|
"""Sort a list of enriched dicts by carry efficiency when requested."""
|
||||||
|
if sort == "efficiency_desc":
|
||||||
|
return sorted(rows, key=lambda r: (r["slots_per_kg"] is None, -(r["slots_per_kg"] or 0)))
|
||||||
|
if sort == "efficiency_asc":
|
||||||
|
return sorted(rows, key=lambda r: (r["slots_per_kg"] is None, r["slots_per_kg"] or 0))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@app.route("/loadout")
|
@app.route("/loadout")
|
||||||
def loadout():
|
def loadout():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
tab = request.args.get("tab", "guns")
|
tab = request.args.get("tab", "guns")
|
||||||
sort = request.args.get("sort", "weight_asc")
|
sort = request.args.get("sort", "weight_asc")
|
||||||
|
|
||||||
guns = armor = helmets = headwear = backpacks = rigs = []
|
guns = armor = helmets = headwear = backpacks = rigs = armored_rigs = plates = []
|
||||||
builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = []
|
builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = []
|
||||||
requires = request.args.getlist("requires") # list of slot_nameids that must exist
|
requires = request.args.getlist("requires") # list of slot_nameids that must exist
|
||||||
min_class = request.args.get("min_class", 0, type=int)
|
min_class = request.args.get("min_class", 0, type=int)
|
||||||
@@ -427,21 +561,42 @@ def loadout():
|
|||||||
""", (min_class, min_class)).fetchall()
|
""", (min_class, min_class)).fetchall()
|
||||||
|
|
||||||
elif tab == "backpacks":
|
elif tab == "backpacks":
|
||||||
backpacks = conn.execute(f"""
|
rows = conn.execute(f"""
|
||||||
SELECT * FROM gear_items
|
SELECT * FROM gear_items
|
||||||
WHERE category = 'backpack'
|
WHERE category = 'backpack'
|
||||||
AND (? = 0 OR capacity >= ?)
|
AND (? = 0 OR capacity >= ?)
|
||||||
ORDER BY {sort_frag}
|
ORDER BY {sort_frag}
|
||||||
""", (min_capacity, min_capacity)).fetchall()
|
""", (min_capacity, min_capacity)).fetchall()
|
||||||
|
backpacks = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
||||||
|
|
||||||
elif tab == "rigs":
|
elif tab == "rigs":
|
||||||
rigs = conn.execute(f"""
|
rows = conn.execute(f"""
|
||||||
SELECT * FROM gear_items
|
SELECT * FROM gear_items
|
||||||
WHERE category = 'rig'
|
WHERE category = 'rig'
|
||||||
|
AND armor_class IS NULL
|
||||||
|
AND (? = 0 OR capacity >= ?)
|
||||||
|
ORDER BY {sort_frag}
|
||||||
|
""", (min_capacity, min_capacity)).fetchall()
|
||||||
|
rigs = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
||||||
|
|
||||||
|
elif tab == "armored_rigs":
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT * FROM gear_items
|
||||||
|
WHERE category = 'rig'
|
||||||
|
AND armor_class IS NOT NULL
|
||||||
AND (? = 0 OR capacity >= ?)
|
AND (? = 0 OR capacity >= ?)
|
||||||
AND (? = 0 OR armor_class >= ?)
|
AND (? = 0 OR armor_class >= ?)
|
||||||
ORDER BY {sort_frag}
|
ORDER BY {sort_frag}
|
||||||
""", (min_capacity, min_capacity, min_class, min_class)).fetchall()
|
""", (min_capacity, min_capacity, min_class, min_class)).fetchall()
|
||||||
|
armored_rigs = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
||||||
|
|
||||||
|
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":
|
elif tab == "builder":
|
||||||
builder_guns = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='gun' ORDER BY name").fetchall()
|
builder_guns = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='gun' ORDER BY name").fetchall()
|
||||||
@@ -450,12 +605,16 @@ def loadout():
|
|||||||
builder_rigs = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='rig' ORDER BY name").fetchall()
|
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()
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"loadout.html",
|
"loadout.html",
|
||||||
tab=tab, sort=sort,
|
tab=tab, sort=sort,
|
||||||
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
|
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
|
||||||
backpacks=backpacks, rigs=rigs,
|
backpacks=backpacks, rigs=rigs, armored_rigs=armored_rigs, plates=plates,
|
||||||
slot_filters=LOADOUT_SLOT_FILTERS,
|
slot_filters=LOADOUT_SLOT_FILTERS,
|
||||||
requires=requires,
|
requires=requires,
|
||||||
min_class=min_class, min_capacity=min_capacity,
|
min_class=min_class, min_capacity=min_capacity,
|
||||||
@@ -464,6 +623,7 @@ def loadout():
|
|||||||
builder_helmets=builder_helmets,
|
builder_helmets=builder_helmets,
|
||||||
builder_rigs=builder_rigs,
|
builder_rigs=builder_rigs,
|
||||||
builder_backpacks=builder_backpacks,
|
builder_backpacks=builder_backpacks,
|
||||||
|
carrier_ids_with_open_slots=carrier_ids_with_open_slots,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -506,17 +666,19 @@ def gun_detail(gun_id):
|
|||||||
if row["mod_id"]:
|
if row["mod_id"]:
|
||||||
slots[sid]["mods"].append(dict(row))
|
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"}
|
KEY_SLOTS = {"mod_muzzle", "mod_magazine"}
|
||||||
ordered_slots = [slots[s] for s in slot_order]
|
ordered_slots = [slots[s] for s in slot_order]
|
||||||
key_slots = [s for s in ordered_slots if s["slot_nameid"] in KEY_SLOTS]
|
# Required slots (always needed) shown at top — key slots (magazine/muzzle) highlighted
|
||||||
other_slots = [s for s in ordered_slots if s["slot_nameid"] not in KEY_SLOTS]
|
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(
|
lightest_total = (gun["weight_kg"] or 0) + sum(
|
||||||
s["mods"][0]["weight_kg"]
|
s["mods"][0]["weight_kg"]
|
||||||
for s in ordered_slots
|
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()
|
conn.close()
|
||||||
@@ -524,7 +686,8 @@ def gun_detail(gun_id):
|
|||||||
"gun_detail.html",
|
"gun_detail.html",
|
||||||
gun=gun,
|
gun=gun,
|
||||||
key_slots=key_slots,
|
key_slots=key_slots,
|
||||||
other_slots=other_slots,
|
req_slots=req_slots,
|
||||||
|
optional_slots=optional_slots,
|
||||||
lightest_total=lightest_total,
|
lightest_total=lightest_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -571,6 +734,45 @@ def gun_slots_json(gun_id):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/loadout/carrier/<carrier_id>/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"])
|
@app.route("/loadout/save-build", methods=["POST"])
|
||||||
def save_build():
|
def save_build():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -593,5 +795,277 @@ def save_build():
|
|||||||
return jsonify({"build_id": build_id, "name": name})
|
return jsonify({"build_id": build_id, "name": name})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/meds")
|
||||||
|
def meds():
|
||||||
|
import requests as _req
|
||||||
|
|
||||||
|
API_URL = "https://api.tarkov.dev/graphql"
|
||||||
|
query = """
|
||||||
|
{
|
||||||
|
items(types: [injectors], lang: en) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
iconLink
|
||||||
|
wikiLink
|
||||||
|
properties {
|
||||||
|
__typename
|
||||||
|
... on ItemPropertiesStim {
|
||||||
|
useTime
|
||||||
|
cures
|
||||||
|
stimEffects {
|
||||||
|
type
|
||||||
|
skill { name }
|
||||||
|
value
|
||||||
|
percent
|
||||||
|
duration
|
||||||
|
delay
|
||||||
|
chance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = _req.post(API_URL, json={"query": query}, timeout=8)
|
||||||
|
raw_items = resp.json()["data"]["items"]
|
||||||
|
except Exception:
|
||||||
|
raw_items = []
|
||||||
|
|
||||||
|
# ── helper: pick first matching effect ──────────────────────────────
|
||||||
|
def pick(effects, type_str, positive_only=False, negative_only=False):
|
||||||
|
for e in effects:
|
||||||
|
if e["type"] == type_str:
|
||||||
|
if positive_only and e["value"] <= 0:
|
||||||
|
continue
|
||||||
|
if negative_only and e["value"] >= 0:
|
||||||
|
continue
|
||||||
|
return e
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pick_skill(effects, skill_name):
|
||||||
|
for e in effects:
|
||||||
|
if e["type"] == "Skill" and e.get("skill") and e["skill"]["name"] == skill_name:
|
||||||
|
return e
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── collect all skill names across all injectors ──────────────────
|
||||||
|
all_skills = []
|
||||||
|
for item in raw_items:
|
||||||
|
p = item.get("properties") or {}
|
||||||
|
for e in p.get("stimEffects", []):
|
||||||
|
if e["type"] == "Skill" and e.get("skill"):
|
||||||
|
sn = e["skill"]["name"]
|
||||||
|
if sn not in all_skills:
|
||||||
|
all_skills.append(sn)
|
||||||
|
skill_rows = sorted(all_skills)
|
||||||
|
|
||||||
|
# ── build injector data rows ──────────────────────────────────────
|
||||||
|
injectors = []
|
||||||
|
for item in raw_items:
|
||||||
|
p = item.get("properties") or {}
|
||||||
|
effs = p.get("stimEffects", [])
|
||||||
|
|
||||||
|
def _val(eff): return round(eff["value"], 2) if eff else None
|
||||||
|
def _dur(eff): return eff["duration"] if eff else 0
|
||||||
|
def _delay(eff): return eff["delay"] if eff else 0
|
||||||
|
|
||||||
|
hp_e = pick(effs, "Health regeneration", positive_only=True)
|
||||||
|
stam_e = pick(effs, "Stamina recovery", positive_only=True)
|
||||||
|
stam_neg_e = pick(effs, "Stamina recovery", negative_only=True)
|
||||||
|
stam_rec_e = stam_e or stam_neg_e
|
||||||
|
maxstam_e = pick(effs, "Max stamina")
|
||||||
|
weight_e = pick(effs, "Weight limit")
|
||||||
|
energy_e = pick(effs, "Energy recovery")
|
||||||
|
hydra_e = pick(effs, "Hydration recovery")
|
||||||
|
bleed_e = pick(effs, "Stops and prevents bleedings")
|
||||||
|
anti_e = pick(effs, "Antidote")
|
||||||
|
tremor_e = pick(effs, "Hands tremor")
|
||||||
|
tunnel_e = pick(effs, "Tunnel effect")
|
||||||
|
pain_e = pick(effs, "Pain")
|
||||||
|
temp_e = pick(effs, "Body temperature")
|
||||||
|
|
||||||
|
# skills dict
|
||||||
|
skills = {}
|
||||||
|
for sn in skill_rows:
|
||||||
|
se = pick_skill(effs, sn)
|
||||||
|
if se:
|
||||||
|
skills[sn] = {"value": round(se["value"], 1), "duration": se["duration"]}
|
||||||
|
|
||||||
|
# tags for column filtering
|
||||||
|
tags = []
|
||||||
|
if hp_e or bleed_e or anti_e:
|
||||||
|
tags.append("heal")
|
||||||
|
if maxstam_e or stam_rec_e:
|
||||||
|
tags.append("stam")
|
||||||
|
if skills:
|
||||||
|
tags.append("skill")
|
||||||
|
if temp_e or weight_e:
|
||||||
|
tags.append("special")
|
||||||
|
if not tags:
|
||||||
|
tags.append("special")
|
||||||
|
|
||||||
|
injectors.append({
|
||||||
|
"name": item["name"],
|
||||||
|
"short": item["shortName"],
|
||||||
|
"icon": item.get("iconLink"),
|
||||||
|
"wiki": item.get("wikiLink"),
|
||||||
|
"tags": ",".join(tags),
|
||||||
|
# healing
|
||||||
|
"hp_regen": _val(hp_e),
|
||||||
|
"hp_regen_dur": _dur(hp_e),
|
||||||
|
"stops_bleed": bool(bleed_e),
|
||||||
|
"antidote": bool(anti_e),
|
||||||
|
# stamina
|
||||||
|
"max_stam": _val(maxstam_e),
|
||||||
|
"max_stam_dur": _dur(maxstam_e),
|
||||||
|
"stam_rec": _val(stam_rec_e),
|
||||||
|
"stam_rec_dur": _dur(stam_rec_e),
|
||||||
|
# weight
|
||||||
|
"weight": _val(weight_e),
|
||||||
|
"weight_dur": _dur(weight_e),
|
||||||
|
# special
|
||||||
|
"body_temp": _val(temp_e),
|
||||||
|
"body_temp_dur": _dur(temp_e),
|
||||||
|
"energy": round(energy_e["value"], 2) if energy_e else None,
|
||||||
|
"energy_dur": _dur(energy_e),
|
||||||
|
"hydration": round(hydra_e["value"], 2) if hydra_e else None,
|
||||||
|
"hydration_dur": _dur(hydra_e),
|
||||||
|
# skills
|
||||||
|
"skills": skills,
|
||||||
|
# side effects
|
||||||
|
"tremor": bool(tremor_e),
|
||||||
|
"tremor_delay": _delay(tremor_e),
|
||||||
|
"tremor_dur": _dur(tremor_e),
|
||||||
|
"tunnel": bool(tunnel_e),
|
||||||
|
"tunnel_delay": _delay(tunnel_e),
|
||||||
|
"tunnel_dur": _dur(tunnel_e),
|
||||||
|
"pain": bool(pain_e),
|
||||||
|
"pain_delay": _delay(pain_e),
|
||||||
|
"pain_dur": _dur(pain_e),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── situation guide ───────────────────────────────────────────────
|
||||||
|
situations = {
|
||||||
|
"bleed": [
|
||||||
|
{"short": "Zagustin", "desc": "Stops bleeding, +Vitality 180s", "warn": "tremors delayed"},
|
||||||
|
{"short": "AHF1-M", "desc": "Stops bleeding, +Health 60s", "warn": "-hydration"},
|
||||||
|
{"short": "Perfotoran", "desc": "Stops bleed + antidote + regen", "warn": "-energy after"},
|
||||||
|
{"short": "xTG-12", "desc": "Antidote only (no bleed stop)", "warn": "-Health skill"},
|
||||||
|
],
|
||||||
|
"regen": [
|
||||||
|
{"short": "eTG-c", "desc": "+6.5 HP/s for 60s (fast burst)", "warn": "-energy after"},
|
||||||
|
{"short": "Adrenaline","desc": "+4 HP/s for 15s, stam boost", "warn": "-hydration after"},
|
||||||
|
{"short": "PNB", "desc": "+3 HP/s for 40s, +Strength", "warn": "tremors + skill debuff"},
|
||||||
|
{"short": "Propital", "desc": "+1 HP/s for 300s, skill buffs", "warn": "tremors at 270s"},
|
||||||
|
{"short": "Perfotoran","desc": "+1.5 HP/s for 60s + antidote", "warn": "-energy after"},
|
||||||
|
],
|
||||||
|
"stam": [
|
||||||
|
{"short": "Trimadol", "desc": "+3 stam rec, +10 max stam, 180s", "warn": "-energy/-hydration"},
|
||||||
|
{"short": "SJ6", "desc": "+2 stam rec, +30 max stam, 240s", "warn": "tremors + tunnel after"},
|
||||||
|
{"short": "Meldonin", "desc": "+0.5 stam rec, +Endurance 900s", "warn": "-hydration/-energy (minor)"},
|
||||||
|
{"short": "L1", "desc": "+30 max stam, +Strength, 120s", "warn": "-hydration/-energy"},
|
||||||
|
{"short": "SJ1", "desc": "+Endurance/Strength 180s", "warn": "-energy/-hydration after"},
|
||||||
|
{"short": "Adrenaline","desc": "Short burst +Endurance/Strength", "warn": "-Stress Resist"},
|
||||||
|
],
|
||||||
|
"skill": [
|
||||||
|
{"short": "Obdolbos 2", "desc": "All skills +20, weight +45%, 1800s", "warn": "-stam, -HP regen"},
|
||||||
|
{"short": "3-(b-TG)", "desc": "+Attention/Perception/Strength 240s", "warn": "tremors after"},
|
||||||
|
{"short": "SJ12", "desc": "+Perception 600s, body cool", "warn": "overheats at end"},
|
||||||
|
{"short": "2A2-(b-TG)", "desc": "+Attention/Perception, weight +15%, 900s", "warn": "-hydration"},
|
||||||
|
{"short": "Trimadol", "desc": "Broad skill buff + stam, 180s", "warn": "-energy/-hydration"},
|
||||||
|
],
|
||||||
|
"special": [
|
||||||
|
{"short": "SJ9", "desc": "Cools body -7°, 300s — hot map survival", "warn": "HP drain + tremors"},
|
||||||
|
{"short": "SJ12", "desc": "Cools body -4°, +Perception, 600s", "warn": "rebound heat after"},
|
||||||
|
{"short": "M.U.L.E.", "desc": "Weight limit +50% for 900s", "warn": "-HP regen"},
|
||||||
|
{"short": "Obdolbos","desc": "25% chance: all buffs + all debuffs", "warn": "may kill you"},
|
||||||
|
],
|
||||||
|
"risky": [
|
||||||
|
{"short": "Obdolbos", "desc": "25% chance everything fires at once", "warn": "may cause -600 HP"},
|
||||||
|
{"short": "PNB", "desc": "Fast HP/Strength burst then hard crash", "warn": "-Health/-Vitality 180s"},
|
||||||
|
{"short": "SJ9", "desc": "-HP regen whole duration, tremors", "warn": "don't use while injured"},
|
||||||
|
{"short": "Propital", "desc": "Tremors + tunnel vision at 270s delay", "warn": "plan ahead"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template("meds.html",
|
||||||
|
injectors=injectors,
|
||||||
|
skill_rows=skill_rows,
|
||||||
|
situations=situations)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/barters")
|
||||||
|
def barters():
|
||||||
|
import requests as _req
|
||||||
|
|
||||||
|
API_URL = "https://api.tarkov.dev/graphql"
|
||||||
|
query = """
|
||||||
|
{
|
||||||
|
barters(lang: en) {
|
||||||
|
id
|
||||||
|
trader { name }
|
||||||
|
level
|
||||||
|
taskUnlock { name }
|
||||||
|
requiredItems {
|
||||||
|
item { id name shortName iconLink wikiLink }
|
||||||
|
count
|
||||||
|
}
|
||||||
|
rewardItems {
|
||||||
|
item { id name shortName iconLink wikiLink }
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = _req.post(API_URL, json={"query": query}, timeout=15)
|
||||||
|
data = resp.json()
|
||||||
|
raw_barters = data.get("data", {}).get("barters", [])
|
||||||
|
except Exception:
|
||||||
|
raw_barters = []
|
||||||
|
|
||||||
|
barter_list = []
|
||||||
|
for b in raw_barters:
|
||||||
|
reward_items = b.get("rewardItems", [])
|
||||||
|
required_items = b.get("requiredItems", [])
|
||||||
|
if not reward_items or not required_items:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use first reward item as the "output" item
|
||||||
|
reward = reward_items[0]
|
||||||
|
reward_item = reward.get("item") or {}
|
||||||
|
reward_count = reward.get("count", 1)
|
||||||
|
|
||||||
|
required = []
|
||||||
|
for ri in required_items:
|
||||||
|
item = ri.get("item") or {}
|
||||||
|
required.append({
|
||||||
|
"id": item.get("id", ""),
|
||||||
|
"name": item.get("name", "Unknown"),
|
||||||
|
"short": item.get("shortName", ""),
|
||||||
|
"icon": item.get("iconLink"),
|
||||||
|
"count": ri.get("count", 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
task_unlock = b.get("taskUnlock")
|
||||||
|
barter_list.append({
|
||||||
|
"id": b.get("id", ""),
|
||||||
|
"trader": (b.get("trader") or {}).get("name", "Unknown"),
|
||||||
|
"level": b.get("level", 1),
|
||||||
|
"task_unlock": task_unlock.get("name") if task_unlock else None,
|
||||||
|
"reward_name": reward_item.get("name", "Unknown"),
|
||||||
|
"reward_short": reward_item.get("shortName", ""),
|
||||||
|
"reward_icon": reward_item.get("iconLink"),
|
||||||
|
"reward_wiki": reward_item.get("wikiLink"),
|
||||||
|
"reward_count": reward_count,
|
||||||
|
"required": required,
|
||||||
|
})
|
||||||
|
|
||||||
|
barter_list.sort(key=lambda b: (b["trader"], b["level"], b["reward_name"]))
|
||||||
|
return render_template("barters.html", barters=barter_list)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
|
|||||||
BIN
assets/onlyscavs.png
Normal file
BIN
assets/onlyscavs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
125
import_gear.py
125
import_gear.py
@@ -64,6 +64,14 @@ GRAPHQL_QUERY_GEAR = """
|
|||||||
durability
|
durability
|
||||||
material { name }
|
material { name }
|
||||||
zones
|
zones
|
||||||
|
armorSlots {
|
||||||
|
__typename
|
||||||
|
... on ItemArmorSlotOpen {
|
||||||
|
nameId
|
||||||
|
zones
|
||||||
|
allowedPlates { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +120,26 @@ GRAPHQL_QUERY_GEAR = """
|
|||||||
class
|
class
|
||||||
durability
|
durability
|
||||||
zones
|
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):
|
def import_armor(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
|
slot_data = {} # carrier_id -> armorSlots list
|
||||||
for item in items:
|
for item in items:
|
||||||
item_id = item.get("id")
|
item_id = item.get("id")
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
@@ -229,8 +258,11 @@ def import_armor(conn, items):
|
|||||||
zones=zones,
|
zones=zones,
|
||||||
)
|
)
|
||||||
counts[result] += 1
|
counts[result] += 1
|
||||||
|
armor_slots = props.get("armorSlots") or []
|
||||||
|
if armor_slots:
|
||||||
|
slot_data[item_id] = armor_slots
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return counts
|
return counts, slot_data
|
||||||
|
|
||||||
|
|
||||||
def import_helmets(conn, items):
|
def import_helmets(conn, items):
|
||||||
@@ -291,6 +323,7 @@ def import_backpacks(conn, items):
|
|||||||
def import_rigs(conn, items):
|
def import_rigs(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
|
slot_data = {} # carrier_id -> armorSlots list
|
||||||
for item in items:
|
for item in items:
|
||||||
item_id = item.get("id")
|
item_id = item.get("id")
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
@@ -308,10 +341,75 @@ def import_rigs(conn, items):
|
|||||||
zones=zones,
|
zones=zones,
|
||||||
)
|
)
|
||||||
counts[result] += 1
|
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()
|
conn.commit()
|
||||||
return counts
|
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):
|
def import_suppressors(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
@@ -438,6 +536,7 @@ def main():
|
|||||||
rigs = gear_data.get("rigs", [])
|
rigs = gear_data.get("rigs", [])
|
||||||
suppressors = gear_data.get("suppressors", [])
|
suppressors = gear_data.get("suppressors", [])
|
||||||
mods = gear_data.get("mods", [])
|
mods = gear_data.get("mods", [])
|
||||||
|
plates = gear_data.get("plates", [])
|
||||||
|
|
||||||
# Wearables: only keep those whose properties resolved to ItemPropertiesHelmet
|
# Wearables: only keep those whose properties resolved to ItemPropertiesHelmet
|
||||||
# (i.e. they have 'class' or 'headZones' set — not decorative items)
|
# (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"))
|
and (w["properties"].get("class") or w["properties"].get("headZones"))
|
||||||
]
|
]
|
||||||
print(f" armor={len(armor)}, helmets={len(helmets)}, wearable_helmets={len(wearable_helmets)}, "
|
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
|
# Phase 2: insert into DB
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -462,8 +562,12 @@ def main():
|
|||||||
wcounts, slot_data = import_weapons(conn, weapons)
|
wcounts, slot_data = import_weapons(conn, weapons)
|
||||||
print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped")
|
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...")
|
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(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing helmets...")
|
print("Importing helmets...")
|
||||||
@@ -479,7 +583,7 @@ def main():
|
|||||||
print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing rigs...")
|
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(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing suppressors...")
|
print("Importing suppressors...")
|
||||||
@@ -496,6 +600,19 @@ def main():
|
|||||||
print(f" gun_slots: {slots_ins} rows")
|
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)")
|
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
|
# Phase 4: classify mod_type for mods based on which slots they appear in
|
||||||
print("Classifying mod types from slot data...")
|
print("Classifying mod types from slot data...")
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ def upsert_keys(conn, keys):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"SELECT id FROM keys WHERE api_id = ?",
|
||||||
SELECT id FROM keys WHERE api_id = ?
|
|
||||||
""",
|
|
||||||
(api_id,)
|
(api_id,)
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -73,19 +71,20 @@ def upsert_keys(conn, keys):
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE keys
|
UPDATE keys
|
||||||
SET name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ?
|
SET id = COALESCE(id, ?),
|
||||||
|
name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ?
|
||||||
WHERE api_id = ?
|
WHERE api_id = ?
|
||||||
""",
|
""",
|
||||||
(name, short_name, weight, uses, wiki_url, grid_image_url, api_id)
|
(api_id, name, short_name, weight, uses, wiki_url, grid_image_url, api_id)
|
||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO keys (api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url)
|
INSERT INTO keys (id, api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(api_id, name, short_name, weight, uses, wiki_url, grid_image_url)
|
(api_id, api_id, name, short_name, weight, uses, wiki_url, grid_image_url)
|
||||||
)
|
)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
|
|
||||||
|
|||||||
21
migration_add_plates.sql
Normal file
21
migration_add_plates.sql
Normal file
@@ -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);
|
||||||
@@ -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);
|
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
|
-- Saved loadout builds
|
||||||
CREATE TABLE IF NOT EXISTS saved_builds (
|
CREATE TABLE IF NOT EXISTS saved_builds (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
408
templates/barters.html
Normal file
408
templates/barters.html
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OnlyScavs – Barter Calculator</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #121212;
|
||||||
|
--panel: #1a1a1a;
|
||||||
|
--panel2: #1e1e1e;
|
||||||
|
--text: #eee;
|
||||||
|
--muted: #888;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--accent: #9ccfff;
|
||||||
|
--accent2: #ffd580;
|
||||||
|
--good: #6ec96e;
|
||||||
|
--bad: #e06060;
|
||||||
|
--warn: #e0a040;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
|
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||||
|
.subtitle { color: var(--muted); font-size: 0.88rem; margin: 0 0 20px; }
|
||||||
|
|
||||||
|
/* ── Filters ── */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.filters input[type=text] {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.filters input[type=text]:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.filters select {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.filters label { color: var(--muted); font-size: 0.82rem; }
|
||||||
|
|
||||||
|
/* ── Barter Cards ── */
|
||||||
|
.barter-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.barter-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.barter-card.task-locked {
|
||||||
|
border-color: #3a3020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.reward-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reward-info { flex: 1; min-width: 0; }
|
||||||
|
.reward-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.reward-name a { color: inherit; text-decoration: none; }
|
||||||
|
.reward-name a:hover { text-decoration: underline; }
|
||||||
|
.reward-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.trader-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #1e2a1e;
|
||||||
|
color: var(--good);
|
||||||
|
border: 1px solid #2a3e2a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.task-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #2a2010;
|
||||||
|
color: var(--warn);
|
||||||
|
border: 1px solid #3a3020;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Required items ── */
|
||||||
|
.required-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.required-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.req-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.req-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.req-icon-placeholder {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background: #222;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.req-text { flex: 1; min-width: 0; }
|
||||||
|
.req-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.req-count { font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.req-price-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.req-price-input {
|
||||||
|
width: 90px;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.req-price-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.req-price-unit { font-size: 0.72rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Total cost row ── */
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.total-label { font-size: 0.82rem; color: var(--muted); }
|
||||||
|
.total-cost {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.total-hint { font-size: 0.75rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 48px 0; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters" class="active">Barters</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Barter Calculator</h1>
|
||||||
|
<p class="subtitle">Enter flea market prices for required items to see the total rouble cost of any barter.</p>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div>
|
||||||
|
<label>Search</label><br>
|
||||||
|
<input type="text" id="search" placeholder="item name, trader…" oninput="applyFilters()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Trader</label><br>
|
||||||
|
<select id="traderFilter" onchange="applyFilters()">
|
||||||
|
<option value="">All traders</option>
|
||||||
|
{% set traders = barters | map(attribute='trader') | unique | sort %}
|
||||||
|
{% for t in traders %}
|
||||||
|
<option value="{{ t }}">{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>LL (min)</label><br>
|
||||||
|
<select id="llFilter" onchange="applyFilters()">
|
||||||
|
<option value="0">Any</option>
|
||||||
|
<option value="1">LL1+</option>
|
||||||
|
<option value="2">LL2+</option>
|
||||||
|
<option value="3">LL3+</option>
|
||||||
|
<option value="4">LL4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Task locked</label><br>
|
||||||
|
<select id="taskFilter" onchange="applyFilters()">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="no">No task required</option>
|
||||||
|
<option value="yes">Task required</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if barters %}
|
||||||
|
<div class="barter-list" id="barterList">
|
||||||
|
{% for b in barters %}
|
||||||
|
<div class="barter-card{% if b.task_unlock %} task-locked{% endif %}"
|
||||||
|
data-trader="{{ b.trader }}"
|
||||||
|
data-level="{{ b.level }}"
|
||||||
|
data-task="{{ 'yes' if b.task_unlock else 'no' }}"
|
||||||
|
data-search="{{ (b.reward_name ~ ' ' ~ b.trader ~ ' ' ~ (b.required | map(attribute='name') | join(' '))) | lower }}">
|
||||||
|
|
||||||
|
<div class="barter-header">
|
||||||
|
{% if b.reward_icon %}
|
||||||
|
<img class="reward-icon" src="{{ b.reward_icon }}" alt="{{ b.reward_short }}" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
<div class="reward-info">
|
||||||
|
<div class="reward-name">
|
||||||
|
{% if b.reward_wiki %}
|
||||||
|
<a href="{{ b.reward_wiki }}" target="_blank" rel="noopener">{{ b.reward_name }}{% if b.reward_count > 1 %} ×{{ b.reward_count }}{% endif %}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ b.reward_name }}{% if b.reward_count > 1 %} ×{{ b.reward_count }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="reward-meta">{{ b.trader }} · LL{{ b.level }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="trader-badge">{{ b.trader }} LL{{ b.level }}</span>
|
||||||
|
{% if b.task_unlock %}
|
||||||
|
<span class="task-badge" title="Requires task: {{ b.task_unlock }}">🔒 {{ b.task_unlock }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="required-label">Required items</div>
|
||||||
|
<div class="required-items">
|
||||||
|
{% for ri in b.required %}
|
||||||
|
<div class="req-item">
|
||||||
|
{% if ri.icon %}
|
||||||
|
<img class="req-icon" src="{{ ri.icon }}" alt="{{ ri.short }}" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="req-icon-placeholder"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="req-text">
|
||||||
|
<div class="req-name" title="{{ ri.name }}">{{ ri.name }}</div>
|
||||||
|
<div class="req-count">× {{ ri.count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="req-price-wrap">
|
||||||
|
<input class="req-price-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
placeholder="price"
|
||||||
|
title="Price per unit in roubles"
|
||||||
|
data-count="{{ ri.count }}"
|
||||||
|
oninput="recalc(this)">
|
||||||
|
<span class="req-price-unit">₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-row">
|
||||||
|
<span class="total-label">Total cost:</span>
|
||||||
|
<span class="total-cost" data-total="0">—</span>
|
||||||
|
<span class="total-hint">Enter prices above to calculate</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">No barter data available. Check your connection to tarkov.dev.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Recalculate total cost for a barter card when any price input changes
|
||||||
|
function recalc(input) {
|
||||||
|
const card = input.closest('.barter-card');
|
||||||
|
const inputs = card.querySelectorAll('.req-price-input');
|
||||||
|
let total = 0;
|
||||||
|
let allFilled = true;
|
||||||
|
|
||||||
|
inputs.forEach(inp => {
|
||||||
|
const price = parseFloat(inp.value) || 0;
|
||||||
|
const count = parseInt(inp.dataset.count, 10) || 1;
|
||||||
|
if (inp.value.trim() === '') allFilled = false;
|
||||||
|
total += price * count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEl = card.querySelector('.total-cost');
|
||||||
|
const hintEl = card.querySelector('.total-hint');
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
totalEl.textContent = total.toLocaleString() + ' ₽';
|
||||||
|
hintEl.textContent = allFilled ? 'all items priced' : 'some items unpriced';
|
||||||
|
} else {
|
||||||
|
totalEl.textContent = '—';
|
||||||
|
hintEl.textContent = 'Enter prices above to calculate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter cards by search text, trader, level, and task lock
|
||||||
|
function applyFilters() {
|
||||||
|
const search = document.getElementById('search').value.toLowerCase();
|
||||||
|
const trader = document.getElementById('traderFilter').value;
|
||||||
|
const ll = parseInt(document.getElementById('llFilter').value, 10) || 0;
|
||||||
|
const task = document.getElementById('taskFilter').value;
|
||||||
|
|
||||||
|
document.querySelectorAll('.barter-card').forEach(card => {
|
||||||
|
const matchSearch = !search || card.dataset.search.includes(search);
|
||||||
|
const matchTrader = !trader || card.dataset.trader === trader;
|
||||||
|
const matchLL = !ll || parseInt(card.dataset.level, 10) >= ll;
|
||||||
|
const matchTask = !task || card.dataset.task === task;
|
||||||
|
card.style.display = (matchSearch && matchTrader && matchLL && matchTask) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +1,62 @@
|
|||||||
|
{# ══════════════════════════════════════════════
|
||||||
|
MACROS — must come before first use in Jinja2
|
||||||
|
══════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
{# List view: indented tree with ├── / └── connector lines.
|
||||||
|
open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank).
|
||||||
|
is_last: whether this node is the last sibling among its parent's children. #}
|
||||||
|
{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last, collector_id) %}
|
||||||
|
{% set q = quest_by_id[qid] %}
|
||||||
|
{% set visible_kids = [] %}
|
||||||
|
{% for cid in children.get(qid, []) %}
|
||||||
|
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-indent">
|
||||||
|
{% for open in open_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if open_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'elbow' if is_last else 'tee' }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="list-row{% if q.done %} done{% endif %}"
|
||||||
|
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}"
|
||||||
|
data-counted="{{ '1' if qid in collector_prereqs else '0' }}">
|
||||||
|
{% if qid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="list-name">{{ q.name }}</span>
|
||||||
|
{% if q.wiki_link %}<a class="list-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
{% if qid != collector_id %}<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set child_stack = open_stack + [not is_last] %}
|
||||||
|
{% for cid in visible_kids %}
|
||||||
|
{% set child = quest_by_id[cid] %}
|
||||||
|
{% set child_last = loop.last %}
|
||||||
|
{% if child.trader != q.trader %}
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-indent">
|
||||||
|
{% for open in child_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="list-indent-seg {{ 'elbow' if child_last else 'tee' }}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="list-row{% if child.done %} done{% endif %}"
|
||||||
|
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}"
|
||||||
|
data-counted="{{ '1' if cid in collector_prereqs else '0' }}">
|
||||||
|
{% if cid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="list-name">{{ child.name }}</span>
|
||||||
|
{% if child.wiki_link %}<a class="list-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
<span class="cross-badge">{{ child.trader }}</span>
|
||||||
|
{% if cid != collector_id %}<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last, collector_id) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -8,24 +67,68 @@
|
|||||||
--bg: #121212;
|
--bg: #121212;
|
||||||
--panel: #1a1a1a;
|
--panel: #1a1a1a;
|
||||||
--text: #eee;
|
--text: #eee;
|
||||||
--muted: #bbb;
|
--muted: #888;
|
||||||
--border: #333;
|
--border: #2a2a2a;
|
||||||
--accent: #9ccfff;
|
--accent: #9ccfff;
|
||||||
--done-bg: #1a2a1a;
|
|
||||||
--done-text: #6ec96e;
|
--done-text: #6ec96e;
|
||||||
|
--done-bg: #1a2a1a;
|
||||||
|
--kappa: #f0c040;
|
||||||
|
--line: #333;
|
||||||
}
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
}
|
}
|
||||||
.page {
|
body::before {
|
||||||
max-width: 780px;
|
content: '';
|
||||||
margin: 0 auto;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
h1 { margin-bottom: 4px; }
|
.page { max-width: 960px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
|
h1 { margin: 0 0 4px; }
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.filter-btn.active { border-color: var(--kappa); color: var(--kappa); }
|
||||||
|
.sep { color: var(--border); }
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -45,68 +148,180 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
.trader-group { margin-bottom: 8px; }
|
.legend {
|
||||||
.trader-header {
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 12px 0 4px;
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid var(--border);
|
margin-bottom: 16px;
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
.quest-row {
|
.legend span { display: flex; align-items: center; gap: 5px; }
|
||||||
|
|
||||||
|
/* Trader section */
|
||||||
|
.trader-section { margin-bottom: 8px; }
|
||||||
|
.trader-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 8px 4px;
|
padding: 10px 8px;
|
||||||
border-bottom: 1px solid #222;
|
background: var(--panel);
|
||||||
border-radius: 4px;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.quest-row.done {
|
.trader-header:hover { border-color: #444; }
|
||||||
background: var(--done-bg);
|
.trader-name {
|
||||||
}
|
font-weight: bold;
|
||||||
.quest-row.done .quest-name {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--done-text);
|
|
||||||
}
|
|
||||||
.quest-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.quest-name a {
|
.trader-counts { font-size: 0.8rem; color: var(--muted); }
|
||||||
color: var(--accent);
|
.chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; }
|
||||||
font-size: 0.8rem;
|
.trader-section.collapsed .chevron { transform: rotate(-90deg); }
|
||||||
margin-left: 6px;
|
.trader-body { padding: 6px 0 6px 8px; }
|
||||||
|
.trader-section.collapsed .trader-body { display: none; }
|
||||||
|
|
||||||
|
/* Flow tree */
|
||||||
|
.tree-root {
|
||||||
|
margin: 6px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tree-children {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tree-children:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.tree-children > .tree-root {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.tree-children > .tree-root:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.quest-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px 0;
|
||||||
|
position: relative;
|
||||||
|
background: #141820;
|
||||||
|
border: 1px solid #1b2230;
|
||||||
|
}
|
||||||
|
.quest-node.has-children:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -8px;
|
||||||
|
width: 1px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.quest-node:hover { background: #1e1e1e; }
|
||||||
|
.quest-node.done .quest-label { text-decoration: line-through; color: var(--done-text); }
|
||||||
|
.quest-node.done { background: var(--done-bg); }
|
||||||
|
.quest-label { flex: 1; font-size: 0.9rem; }
|
||||||
|
.quest-label a { color: var(--accent); font-size: 0.75rem; margin-left: 6px; }
|
||||||
|
.kappa-star { color: var(--kappa); font-size: 0.75rem; flex-shrink: 0; }
|
||||||
|
.cross-trader {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: #2a2a2a;
|
background: transparent;
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
border-radius: 6px;
|
color: var(--muted);
|
||||||
padding: 4px 10px;
|
border-radius: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.75rem;
|
||||||
white-space: nowrap;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.quest-row.done .toggle-btn {
|
.quest-node.done .toggle-btn {
|
||||||
background: #1e3a1e;
|
|
||||||
border-color: #3a6a3a;
|
border-color: #3a6a3a;
|
||||||
color: var(--done-text);
|
color: var(--done-text);
|
||||||
}
|
}
|
||||||
nav { margin-bottom: 20px; }
|
|
||||||
nav a { color: var(--accent); font-size: 0.9rem; }
|
/* ── LIST VIEW ──
|
||||||
|
Classic file-manager tree: ├── and └── connectors. */
|
||||||
|
.list-view .trader-body { padding: 4px 0; }
|
||||||
|
.list-tree { padding: 0; margin: 0; }
|
||||||
|
.list-item { display: flex; align-items: flex-start; }
|
||||||
|
.list-indent { display: flex; flex-shrink: 0; }
|
||||||
|
.list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; }
|
||||||
|
.list-indent-seg.vert::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.tee::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.tee::after {
|
||||||
|
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.elbow::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.elbow::after {
|
||||||
|
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.blank {}
|
||||||
|
.list-row {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0;
|
||||||
|
background: transparent; min-height: 30px;
|
||||||
|
}
|
||||||
|
.list-row:hover { background: #1a1a1a; }
|
||||||
|
.list-row.done .list-name { text-decoration: line-through; color: var(--done-text); }
|
||||||
|
.list-name { font-size: 0.85rem; flex: 1; }
|
||||||
|
.list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; }
|
||||||
|
.list-wiki:hover { text-decoration: underline; }
|
||||||
|
.list-row .kappa-star { font-size: 0.72rem; }
|
||||||
|
.list-row .cross-badge { font-size: 0.7rem; }
|
||||||
|
.list-row .toggle-btn { font-size: 0.72rem; }
|
||||||
|
.list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
|
||||||
|
|
||||||
|
.flow-view.hidden, .list-view.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<nav>
|
<nav class="site-nav">
|
||||||
<a href="/">← Keys</a>
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
|
<div class="nav-links">
|
||||||
<a href="/quests">Quest Trees</a>
|
<a href="/keys">Keys</a>
|
||||||
|
|
<a href="/collector" class="active">Collector</a>
|
||||||
<a href="/loadout">Loadout Planner</a>
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<h1>Collector Checklist</h1>
|
<h1>Collector Checklist</h1>
|
||||||
<p class="subtitle">
|
<p class="subtitle">
|
||||||
@@ -117,30 +332,131 @@
|
|||||||
<div class="progress-bar-fill" style="width: {{ (done / total * 100) | round(1) if total else 0 }}%"></div>
|
<div class="progress-bar-fill" style="width: {{ (done / total * 100) | round(1) if total else 0 }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% set ns = namespace(current_trader=None) %}
|
<div class="toolbar">
|
||||||
{% for quest in quests %}
|
<a class="filter-btn {% if view != 'list' %}active{% endif %}" href="/collector?view=flow">Flow</a>
|
||||||
{% if quest.trader != ns.current_trader %}
|
<a class="filter-btn {% if view == 'list' %}active{% endif %}" href="/collector?view=list">List</a>
|
||||||
{% if ns.current_trader is not none %}</div>{% endif %}
|
<span class="sep">|</span>
|
||||||
<div class="trader-group">
|
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(true)">Collapse all</button>
|
||||||
<div class="trader-header">{{ quest.trader }}</div>
|
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(false)">Expand all</button>
|
||||||
{% set ns.current_trader = quest.trader %}
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="quest-row {% if quest.done %}done{% endif %}" id="quest-{{ quest.id }}" data-id="{{ quest.id }}" data-done="{{ '1' if quest.done else '0' }}">
|
<div class="legend">
|
||||||
<span class="quest-name">
|
<span><span style="color:var(--kappa)">★</span> Required for Collector</span>
|
||||||
{{ quest.name }}
|
<span><span style="color:var(--done-text)">✓</span> Marked done</span>
|
||||||
{% if quest.wiki_link %}
|
<span><span style="color:var(--muted);font-style:italic">← Trader</span> Cross-trader dependency</span>
|
||||||
<a href="{{ quest.wiki_link }}" target="_blank">wiki</a>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</span>
|
{% macro render_node(qid, quest_by_id, children, visible, collector_prereqs, collector_id, is_root=False) %}
|
||||||
|
{% set q = quest_by_id[qid] %}
|
||||||
|
{% set visible_kids = [] %}
|
||||||
|
{% for cid in children.get(qid, []) %}
|
||||||
|
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="tree-root">
|
||||||
|
<div class="quest-node {% if q.done %}done{% endif %} {% if is_root %}root-node{% endif %}{% if visible_kids %} has-children{% endif %}"
|
||||||
|
id="qnode-{{ qid }}"
|
||||||
|
data-id="{{ qid }}"
|
||||||
|
data-done="{{ '1' if q.done else '0' }}"
|
||||||
|
data-counted="{{ '1' if qid in collector_prereqs else '0' }}">
|
||||||
|
{% if qid in collector_prereqs %}<span class="kappa-star" title="Required for Collector">★</span>{% endif %}
|
||||||
|
<span class="quest-label">
|
||||||
|
{{ q.name }}
|
||||||
|
{% if q.wiki_link %}<a href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if qid != collector_id %}
|
||||||
<button class="toggle-btn" onclick="toggle(this)">
|
<button class="toggle-btn" onclick="toggle(this)">
|
||||||
{{ '✓ Done' if quest.done else 'Mark done' }}
|
{{ '✓' if q.done else '○' }}
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if visible_kids %}
|
||||||
|
<div class="tree-children">
|
||||||
|
{% for cid in visible_kids %}
|
||||||
|
{% set child = quest_by_id[cid] %}
|
||||||
|
{% if child.trader != q.trader %}
|
||||||
|
<div class="quest-node {% if child.done %}done{% endif %}"
|
||||||
|
data-id="{{ cid }}"
|
||||||
|
data-done="{{ '1' if child.done else '0' }}"
|
||||||
|
data-counted="{{ '1' if cid in collector_prereqs else '0' }}">
|
||||||
|
{% if cid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="quest-label">
|
||||||
|
{{ child.name }}
|
||||||
|
{% if child.wiki_link %}<a href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="cross-trader">← {{ child.trader }}</span>
|
||||||
|
{% if cid != collector_id %}
|
||||||
|
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ render_node(cid, quest_by_id, children, visible, collector_prereqs, collector_id, false) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ── FLOW VIEW ── #}
|
||||||
|
<div class="flow-view {% if view == 'list' %}hidden{% endif %}">
|
||||||
|
{% for trader in traders %}
|
||||||
|
{% set roots = trader_roots[trader] %}
|
||||||
|
{% set total_trader = namespace(n=0) %}
|
||||||
|
{% set done_trader = namespace(n=0) %}
|
||||||
|
{% for qid in visible %}
|
||||||
|
{% if quest_by_id[qid].trader == trader and qid in collector_prereqs %}
|
||||||
|
{% set total_trader.n = total_trader.n + 1 %}
|
||||||
|
{% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="trader-section" id="flow-{{ trader | replace(' ', '-') }}">
|
||||||
|
<div class="trader-header" onclick="toggleTrader(this)">
|
||||||
|
<span class="trader-name">{{ trader }}</span>
|
||||||
|
<span class="trader-counts">{{ done_trader.n }} / {{ total_trader.n }}</span>
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="trader-body">
|
||||||
|
{% for root_id in roots %}
|
||||||
|
{{ render_node(root_id, quest_by_id, children, visible, collector_prereqs, collector_id, true) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if ns.current_trader is not none %}</div>{% endif %}
|
</div>
|
||||||
|
|
||||||
|
{# ── LIST VIEW ── #}
|
||||||
|
<div class="list-view {% if view != 'list' %}hidden{% endif %}">
|
||||||
|
{% for trader in traders %}
|
||||||
|
{% set roots = trader_roots[trader] %}
|
||||||
|
{% set total_trader = namespace(n=0) %}
|
||||||
|
{% set done_trader = namespace(n=0) %}
|
||||||
|
{% for qid in visible %}
|
||||||
|
{% if quest_by_id[qid].trader == trader and qid in collector_prereqs %}
|
||||||
|
{% set total_trader.n = total_trader.n + 1 %}
|
||||||
|
{% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="trader-section" id="list-{{ trader | replace(' ', '-') }}">
|
||||||
|
<div class="trader-header" onclick="toggleTrader(this)">
|
||||||
|
<span class="trader-name">{{ trader }}</span>
|
||||||
|
<span class="trader-counts">{{ done_trader.n }} / {{ total_trader.n }}</span>
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="trader-body">
|
||||||
|
<div class="list-tree">
|
||||||
|
{% for root_id in roots %}
|
||||||
|
{{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last, collector_id) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let doneCount = {{ done }};
|
let doneCount = {{ done }};
|
||||||
const total = {{ total }};
|
const total = {{ total }};
|
||||||
@@ -151,27 +467,70 @@
|
|||||||
document.querySelector('.progress-bar-fill').style.width = pct + '%';
|
document.querySelector('.progress-bar-fill').style.width = pct + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(btn) {
|
function toggleTrader(header) {
|
||||||
const row = btn.closest('.quest-row');
|
const section = header.closest('.trader-section');
|
||||||
const id = row.dataset.id;
|
section.classList.toggle('collapsed');
|
||||||
const nowDone = row.dataset.done === '1' ? 0 : 1;
|
persistCollapsed();
|
||||||
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({ quest_id: id, done: nowDone });
|
function setAllTradersCollapsed(collapsed) {
|
||||||
fetch('/collector/toggle', { method: 'POST', body })
|
document.querySelectorAll('.trader-section').forEach(section => {
|
||||||
.then(r => r.json())
|
section.classList.toggle('collapsed', collapsed);
|
||||||
.then(() => {
|
});
|
||||||
row.dataset.done = nowDone;
|
persistCollapsed();
|
||||||
if (nowDone) {
|
}
|
||||||
row.classList.add('done');
|
|
||||||
btn.textContent = '✓ Done';
|
const COLLAPSE_KEY = 'collector.collapsedTraders2';
|
||||||
doneCount++;
|
|
||||||
} else {
|
function persistCollapsed() {
|
||||||
row.classList.remove('done');
|
const collapsed = Array.from(document.querySelectorAll('.trader-section.collapsed'))
|
||||||
btn.textContent = 'Mark done';
|
.map(s => s.id);
|
||||||
doneCount--;
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||||||
}
|
}
|
||||||
updateProgress();
|
|
||||||
|
function restoreCollapsed() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const ids = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(ids)) return;
|
||||||
|
ids.forEach(id => {
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
if (section) section.classList.add('collapsed');
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore storage/parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreCollapsed();
|
||||||
|
|
||||||
|
function toggle(btn) {
|
||||||
|
const node = btn.closest('[data-id]');
|
||||||
|
const id = node.dataset.id;
|
||||||
|
const wasDone = node.dataset.done === '1';
|
||||||
|
const nowDone = wasDone ? 0 : 1;
|
||||||
|
|
||||||
|
fetch('/collector/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ quest_id: id, done: nowDone })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
document.querySelectorAll(`[data-id="${id}"]`).forEach(n => {
|
||||||
|
n.dataset.done = nowDone;
|
||||||
|
const b = n.querySelector('.toggle-btn');
|
||||||
|
if (b) {
|
||||||
|
if (nowDone) { n.classList.add('done'); b.textContent = '✓'; }
|
||||||
|
else { n.classList.remove('done'); b.textContent = '○'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (node.dataset.counted === '1') {
|
||||||
|
doneCount += nowDone ? 1 : -1;
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -15,11 +15,40 @@
|
|||||||
--key-border: #5a7a3a;
|
--key-border: #5a7a3a;
|
||||||
--key-bg: #141e10;
|
--key-bg: #141e10;
|
||||||
}
|
}
|
||||||
body { font-family: sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 16px; }
|
body {
|
||||||
.page { max-width: 900px; margin: 0 auto; }
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
nav { margin-bottom: 20px; }
|
background: var(--bg);
|
||||||
nav a { color: var(--accent); font-size: 0.9rem; }
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page { max-width: 900px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
h1 { margin: 0 0 2px; font-size: 1.4rem; }
|
h1 { margin: 0 0 2px; font-size: 1.4rem; }
|
||||||
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; }
|
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; }
|
||||||
|
|
||||||
@@ -153,8 +182,16 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<nav>
|
<nav class="site-nav">
|
||||||
<a href="/loadout?tab=guns">← Back to Guns</a>
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout" class="active">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="gun-card">
|
<div class="gun-card">
|
||||||
@@ -197,106 +234,75 @@
|
|||||||
<div class="total-bar">
|
<div class="total-bar">
|
||||||
<span class="lbl">Lightest possible build:</span>
|
<span class="lbl">Lightest possible build:</span>
|
||||||
<span class="big">{{ "%.3f"|format(lightest_total) }} kg</span>
|
<span class="big">{{ "%.3f"|format(lightest_total) }} kg</span>
|
||||||
<span class="breakdown">base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per slot</span>
|
<span class="breakdown">base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per required slot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if key_slots %}
|
{# ---- Macro to render a slot card ---- #}
|
||||||
<div class="slot-section">
|
{% macro slot_card(slot, extra_class='', open=False) %}
|
||||||
<div class="section-hdr">Key slots</div>
|
{% set lightest = slot.mods[0] if slot.mods else none %}
|
||||||
{% for slot in key_slots %}
|
<div class="slot-card {{ extra_class }} {% if open %}open{% endif %}" id="slot-{{ slot.slot_id }}">
|
||||||
{% set lightest = slot.mods[0] if slot.mods else none %}
|
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
||||||
<div class="slot-card key-slot {% if loop.first %}open{% endif %}" id="slot-{{ slot.slot_id }}">
|
<span class="slot-name">{{ slot.slot_name }}</span>
|
||||||
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
{% if extra_class == 'key-slot' %}<span class="key-badge">key slot</span>{% endif %}
|
||||||
<span class="slot-name">{{ slot.slot_name }}</span>
|
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
||||||
<span class="key-badge">key slot</span>
|
<span class="slot-lightest">
|
||||||
{% if slot.required %}<span class="required-badge">required</span>{% endif %}
|
{% if lightest and lightest.weight_kg is not none %}
|
||||||
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
lightest {{ "%.3f"|format(lightest.weight_kg) }} kg
|
||||||
<span class="slot-lightest">
|
{% else %}—{% endif %}
|
||||||
{% if lightest and lightest.weight_kg is not none %}
|
</span>
|
||||||
lightest {{ "%.3f"|format(lightest.weight_kg) }} kg
|
<span class="chevron">▶</span>
|
||||||
{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="chevron">▶</span>
|
|
||||||
</div>
|
|
||||||
<div class="mod-list">
|
|
||||||
{% if slot.mods %}
|
|
||||||
{% for mod in slot.mods %}
|
|
||||||
<div class="mod-row">
|
|
||||||
{% if mod.grid_image_url %}
|
|
||||||
<img src="{{ mod.grid_image_url }}" loading="lazy" alt="">
|
|
||||||
{% else %}
|
|
||||||
<div style="width:36px;height:36px;background:#222;border-radius:4px;flex-shrink:0"></div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="mod-name">
|
|
||||||
{{ mod.mod_name }}
|
|
||||||
{% if mod.mod_short and mod.mod_short != mod.mod_name %}
|
|
||||||
<small>{{ mod.mod_short }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if mod.wiki_url %}
|
|
||||||
<a class="mod-wiki" href="{{ mod.wiki_url }}" target="_blank">wiki</a>
|
|
||||||
{% endif %}
|
|
||||||
<span class="mod-weight {% if loop.first %}lightest{% endif %}">
|
|
||||||
{% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="no-mods">No compatible mods found in database.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mod-list">
|
||||||
|
{% if slot.mods %}
|
||||||
|
{% for mod in slot.mods %}
|
||||||
|
<div class="mod-row">
|
||||||
|
{% if mod.grid_image_url %}
|
||||||
|
<img src="{{ mod.grid_image_url }}" loading="lazy" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div style="width:36px;height:36px;background:#222;border-radius:4px;flex-shrink:0"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mod-name">
|
||||||
|
{{ mod.mod_name }}
|
||||||
|
{% if mod.mod_short and mod.mod_short != mod.mod_name %}
|
||||||
|
<small>{{ mod.mod_short }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if mod.wiki_url %}
|
||||||
|
<a class="mod-wiki" href="{{ mod.wiki_url }}" target="_blank">wiki</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="mod-weight {% if loop.first %}lightest{% endif %}">
|
||||||
|
{% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="no-mods">No compatible mods found in database.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ---- Required slots (key slots highlighted, open by default) ---- #}
|
||||||
|
{% if key_slots or req_slots %}
|
||||||
|
<div class="slot-section">
|
||||||
|
<div class="section-hdr">Required slots <span style="font-weight:normal;color:#666">(counted in lightest build weight)</span></div>
|
||||||
|
{% for slot in key_slots %}
|
||||||
|
{{ slot_card(slot, 'key-slot', open=True) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% for slot in req_slots %}
|
||||||
|
{{ slot_card(slot, '', open=False) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if other_slots %}
|
{# ---- Optional slots (collapsed behind toggle) ---- #}
|
||||||
<button class="toggle-other" onclick="toggleOther(this)">Show {{ other_slots | length }} other slots ▼</button>
|
{% if optional_slots %}
|
||||||
|
<button class="toggle-other" onclick="toggleOther(this)">Show {{ optional_slots | length }} optional slots ▼</button>
|
||||||
<div id="other-slots">
|
<div id="other-slots">
|
||||||
<div class="slot-section">
|
<div class="slot-section">
|
||||||
<div class="section-hdr">All other slots</div>
|
<div class="section-hdr">Optional slots <span style="font-weight:normal;color:#666">(not counted in lightest build weight)</span></div>
|
||||||
{% for slot in other_slots %}
|
{% for slot in optional_slots %}
|
||||||
{% set lightest = slot.mods[0] if slot.mods else none %}
|
{{ slot_card(slot) }}
|
||||||
<div class="slot-card" id="slot-{{ slot.slot_id }}">
|
|
||||||
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
|
||||||
<span class="slot-name">{{ slot.slot_name }}</span>
|
|
||||||
{% if slot.required %}<span class="required-badge">required</span>{% endif %}
|
|
||||||
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
|
||||||
<span class="slot-lightest">
|
|
||||||
{% if lightest and lightest.weight_kg is not none %}
|
|
||||||
lightest {{ "%.3f"|format(lightest.weight_kg) }} kg
|
|
||||||
{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="chevron">▶</span>
|
|
||||||
</div>
|
|
||||||
<div class="mod-list">
|
|
||||||
{% if slot.mods %}
|
|
||||||
{% for mod in slot.mods %}
|
|
||||||
<div class="mod-row">
|
|
||||||
{% if mod.grid_image_url %}
|
|
||||||
<img src="{{ mod.grid_image_url }}" loading="lazy" alt="">
|
|
||||||
{% else %}
|
|
||||||
<div style="width:36px;height:36px;background:#222;border-radius:4px;flex-shrink:0"></div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="mod-name">
|
|
||||||
{{ mod.mod_name }}
|
|
||||||
{% if mod.mod_short and mod.mod_short != mod.mod_name %}
|
|
||||||
<small>{{ mod.mod_short }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if mod.wiki_url %}
|
|
||||||
<a class="mod-wiki" href="{{ mod.wiki_url }}" target="_blank">wiki</a>
|
|
||||||
{% endif %}
|
|
||||||
<span class="mod-weight {% if loop.first %}lightest{% endif %}">
|
|
||||||
{% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="no-mods">No compatible mods found in database.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,8 +317,8 @@
|
|||||||
const div = document.getElementById('other-slots');
|
const div = document.getElementById('other-slots');
|
||||||
div.classList.toggle('visible');
|
div.classList.toggle('visible');
|
||||||
btn.textContent = div.classList.contains('visible')
|
btn.textContent = div.classList.contains('visible')
|
||||||
? 'Hide other slots ▲'
|
? 'Hide optional slots ▲'
|
||||||
: 'Show {{ other_slots | length }} other slots ▼';
|
: 'Show {{ optional_slots | length }} optional slots ▼';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -174,6 +174,10 @@
|
|||||||
<a href="/quests">Quest Trees</a>
|
<a href="/quests">Quest Trees</a>
|
||||||
|
|
|
|
||||||
<a href="/loadout">Loadout Planner</a>
|
<a href="/loadout">Loadout Planner</a>
|
||||||
|
|
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
|
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
</nav>
|
</nav>
|
||||||
<h1>OnlyScavs – Keys</h1>
|
<h1>OnlyScavs – Keys</h1>
|
||||||
|
|
||||||
|
|||||||
382
templates/keys.html
Normal file
382
templates/keys.html
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OnlyScavs – Key Ratings</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #121212;
|
||||||
|
--panel: #1a1a1a;
|
||||||
|
--text: #eee;
|
||||||
|
--muted: #999;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--accent: #9ccfff;
|
||||||
|
--accent2: #ffd580;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.site-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── filters ───────────────────────────────────────────── */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.filters label { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
select, input[type="text"] {
|
||||||
|
background: #222;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
select:focus, input:focus { outline: 1px solid var(--accent); }
|
||||||
|
button {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid #444;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button:hover { background: #333; }
|
||||||
|
|
||||||
|
/* ── save-all bar ──────────────────────────────────────── */
|
||||||
|
.save-all {
|
||||||
|
margin: 4px 0 18px;
|
||||||
|
}
|
||||||
|
.save-all button {
|
||||||
|
background: #1d3a52;
|
||||||
|
border-color: #2d5a7a;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.save-all button:hover { background: #254a66; }
|
||||||
|
|
||||||
|
/* ── key card ──────────────────────────────────────────── */
|
||||||
|
.key {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 14px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.key:hover { background: #161616; }
|
||||||
|
|
||||||
|
.key-thumb {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* left column: name + tags */
|
||||||
|
.key-info { min-width: 0; }
|
||||||
|
.key-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.key-title strong { font-size: 0.95rem; line-height: 1.3; }
|
||||||
|
.key-title a { color: var(--accent); font-size: 0.8rem; }
|
||||||
|
.map-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.map-tag {
|
||||||
|
background: #1e2e1e;
|
||||||
|
border: 1px solid #2a3f2a;
|
||||||
|
color: #8fbc8f;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* right column: controls */
|
||||||
|
.key-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-select {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* priority colour coding */
|
||||||
|
option[value="4"], select.p4 { color: #ff6b6b; }
|
||||||
|
option[value="3"], select.p3 { color: var(--accent2); }
|
||||||
|
option[value="2"], select.p2 { color: #9ccfff; }
|
||||||
|
option[value="1"], select.p1 { color: var(--muted); }
|
||||||
|
option[value="0"], select.p0 { color: #555; }
|
||||||
|
|
||||||
|
.quest-flag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.quest-flag input { margin: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
.note-input {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 260px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.map-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.map-checkbox input { margin: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.key {
|
||||||
|
grid-template-columns: 48px 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
.key-controls {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: unset;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.key { grid-template-columns: 1fr; }
|
||||||
|
.key-thumb { width: 48px; height: 48px; }
|
||||||
|
select, input, button { min-height: 38px; font-size: 0.95rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/keys" class="active">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<h1>Key Ratings</h1>
|
||||||
|
|
||||||
|
<form method="get" class="filters">
|
||||||
|
<label for="map_id">Map</label>
|
||||||
|
<select id="map_id" name="map_id">
|
||||||
|
<option value="">All maps</option>
|
||||||
|
{% for map in maps %}
|
||||||
|
<option value="{{ map.id }}" {% if map_filter == map.id %}selected{% endif %}>
|
||||||
|
{{ map.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="show">Show</label>
|
||||||
|
<select id="show" name="show">
|
||||||
|
<option value="all" {% if show == "all" %}selected{% endif %}>All</option>
|
||||||
|
<option value="rated" {% if show == "rated" %}selected{% endif %}>Rated</option>
|
||||||
|
<option value="unrated" {% if show == "unrated" %}selected{% endif %}>Unrated</option>
|
||||||
|
<option value="quest" {% if show == "quest" %}selected{% endif %}>Quest keys</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="sort">Sort</label>
|
||||||
|
<select id="sort" name="sort">
|
||||||
|
<option value="priority_desc" {% if sort == "priority_desc" %}selected{% endif %}>Priority ↓</option>
|
||||||
|
<option value="priority_asc" {% if sort == "priority_asc" %}selected{% endif %}>Priority ↑</option>
|
||||||
|
<option value="name_asc" {% if sort == "name_asc" %}selected{% endif %}>Name A–Z</option>
|
||||||
|
<option value="name_desc" {% if sort == "name_desc" %}selected{% endif %}>Name Z–A</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/rate_all">
|
||||||
|
{% if map_filter %}<input type="hidden" name="map_id" value="{{ map_filter }}">{% endif %}
|
||||||
|
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
|
||||||
|
{% if show %}<input type="hidden" name="show" value="{{ show }}">{% endif %}
|
||||||
|
|
||||||
|
<div class="save-all">
|
||||||
|
<button type="submit" name="save_all" value="1">Save all changes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for key in keys %}
|
||||||
|
{% set selected_maps = key_maps.get(key.id, []) %}
|
||||||
|
<div class="key" id="key-{{ key.id }}">
|
||||||
|
|
||||||
|
<img class="key-thumb" src="{{ key.grid_image_url }}" loading="lazy" alt="">
|
||||||
|
|
||||||
|
<div class="key-info">
|
||||||
|
<div class="key-title">
|
||||||
|
<strong>{{ key.name }}</strong>
|
||||||
|
{% if key.wiki_url %}<a href="{{ key.wiki_url }}" target="_blank">wiki ↗</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if selected_maps %}
|
||||||
|
<div class="map-tags">
|
||||||
|
{% for map in maps %}
|
||||||
|
{% if map.id in selected_maps %}
|
||||||
|
<span class="map-tag">{{ map.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-controls">
|
||||||
|
<input type="hidden" name="key_ids" value="{{ key.id }}">
|
||||||
|
|
||||||
|
<select name="priority_{{ key.id }}" class="priority-select">
|
||||||
|
<option value="" {% if key.priority is none %}selected{% endif %}>— unrated</option>
|
||||||
|
{% for i, label in [(0,'IGNORE'),(1,'LOW'),(2,'MED'),(3,'HIGH'),(4,'SUPER')] %}
|
||||||
|
<option value="{{ i }}" {% if key.priority == i %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="quest-flag" title="Used in a quest?">
|
||||||
|
<input type="checkbox" name="used_in_quest_{{ key.id }}" {% if key.used_in_quest %}checked{% endif %}>
|
||||||
|
<span>Quest</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input class="note-input" name="reason_{{ key.id }}" placeholder="note…" value="{{ key.reason or '' }}">
|
||||||
|
|
||||||
|
<div class="map-list">
|
||||||
|
{% for map in maps %}
|
||||||
|
<label class="map-checkbox">
|
||||||
|
<input type="checkbox" name="map_ids_{{ key.id }}" value="{{ map.id }}"
|
||||||
|
{% if map.id in selected_maps %}checked{% endif %}>
|
||||||
|
<span>{{ map.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="save-btn" type="submit" name="save_one" value="{{ key.id }}">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="save-all">
|
||||||
|
<button type="submit" name="save_all" value="1">Save all changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
295
templates/landing.html
Normal file
295
templates/landing.html
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OnlyScavs</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0e0e0e;
|
||||||
|
--panel: #161616;
|
||||||
|
--panel2: #1c1c1c;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #777;
|
||||||
|
--muted2: #555;
|
||||||
|
--border: #262626;
|
||||||
|
--accent: #9ccfff;
|
||||||
|
--accent2: #ffd580;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NAV ─────────────────────────────────────────────────── */
|
||||||
|
.site-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 32px;
|
||||||
|
height: 52px;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HERO ─────────────────────────────────────────────────── */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
height: 580px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.hero-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 65%;
|
||||||
|
}
|
||||||
|
.hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(14,14,14,0.05) 0%,
|
||||||
|
rgba(14,14,14,0.1) 35%,
|
||||||
|
rgba(14,14,14,0.7) 68%,
|
||||||
|
rgba(14,14,14,1) 92%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0 48px 48px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
.hero-content h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.hero-content h1 span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.hero-content p {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
max-width: 420px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOOLS GRID ───────────────────────────────────────────── */
|
||||||
|
.tools-section {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 32px 80px;
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted2);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 22px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
transition: border-color 0.15s, background 0.15s, transform 0.1s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: #333;
|
||||||
|
background: var(--panel2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.card-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.card-arrow {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 14px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover .card-arrow {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FOOTER ───────────────────────────────────────────────── */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 20px 32px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESPONSIVE ───────────────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.site-nav { padding: 0 16px; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.hero { height: 420px; }
|
||||||
|
.hero-content { padding: 0 20px 40px; }
|
||||||
|
.hero-content h1 { font-size: 2rem; }
|
||||||
|
.tools-section { padding: 32px 16px 60px; }
|
||||||
|
.cards { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.cards { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<img class="hero-img" src="/assets/onlyscavs.png" alt="OnlyScavs">
|
||||||
|
<div class="hero-overlay"></div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1>Only<span>Scavs</span></h1>
|
||||||
|
<p>Escape from Tarkov reference tools. Keys, quests, loadouts — all local, all yours.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="tools-section">
|
||||||
|
<div class="section-label">Tools</div>
|
||||||
|
<div class="cards">
|
||||||
|
<a class="card" href="/keys">
|
||||||
|
<div class="card-icon">🗝</div>
|
||||||
|
<div class="card-title">Key Ratings</div>
|
||||||
|
<div class="card-desc">Rate and filter keys by map, priority, and quest use.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/collector">
|
||||||
|
<div class="card-icon">★</div>
|
||||||
|
<div class="card-title">Collector</div>
|
||||||
|
<div class="card-desc">Track your Kappa container progress across all 255 quests.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/quests">
|
||||||
|
<div class="card-icon">📋</div>
|
||||||
|
<div class="card-title">Quest Trees</div>
|
||||||
|
<div class="card-desc">Visualize quest chains and trader dependencies.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/loadout">
|
||||||
|
<div class="card-icon">🎽</div>
|
||||||
|
<div class="card-title">Loadout Planner</div>
|
||||||
|
<div class="card-desc">Browse and compare guns, armor, rigs, and more.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/meds">
|
||||||
|
<div class="card-icon">💉</div>
|
||||||
|
<div class="card-title">Injectors</div>
|
||||||
|
<div class="card-desc">Compare stim effects, skills, and side effects.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="/barters">
|
||||||
|
<div class="card-icon">🔄</div>
|
||||||
|
<div class="card-title">Barter Calculator</div>
|
||||||
|
<div class="card-desc">Calculate the true rouble cost of any barter deal.</div>
|
||||||
|
<div class="card-arrow">Open →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
OnlyScavs — personal Tarkov toolkit
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -14,17 +14,40 @@
|
|||||||
--amber: #ffd580;
|
--amber: #ffd580;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
}
|
}
|
||||||
.page { max-width: 1100px; margin: 0 auto; }
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
h1 { margin-bottom: 4px; }
|
h1 { margin-bottom: 4px; }
|
||||||
nav { margin-bottom: 20px; }
|
|
||||||
nav a { color: var(--accent); font-size: 0.9rem; }
|
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
|
|
||||||
/* Tab bar */
|
/* Tab bar */
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
@@ -221,9 +244,16 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<nav>
|
<nav class="site-nav">
|
||||||
<a href="/">← Keys</a> |
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
<a href="/collector">Collector</a>
|
<div class="nav-links">
|
||||||
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout" class="active">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<h1>Loadout Planner</h1>
|
<h1>Loadout Planner</h1>
|
||||||
<p style="color:var(--muted);margin:0 0 16px;font-size:0.9rem;">
|
<p style="color:var(--muted);margin:0 0 16px;font-size:0.9rem;">
|
||||||
@@ -231,7 +261,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
{% 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','Soft Rigs'),('armored_rigs','Armored Rigs'),('plates','Plates'),('builder','Build Builder')] %}
|
||||||
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
|
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +358,7 @@
|
|||||||
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
|
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
|
||||||
}
|
}
|
||||||
.slot-pill.key { border-color: #5a7a3a; background: #141e10; }
|
.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-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-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; }
|
.slot-pill .sp-w { color: var(--amber); font-size: 0.82rem; font-weight: bold; margin-top: 2px; }
|
||||||
@@ -382,23 +413,39 @@
|
|||||||
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
|
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
|
||||||
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
|
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
|
||||||
|
|
||||||
|
// Only sum required slots for the lightest build weight
|
||||||
let total = baseWeight;
|
let total = baseWeight;
|
||||||
let pills = '';
|
let reqPills = '';
|
||||||
|
let optPills = '';
|
||||||
for (const s of slots) {
|
for (const s of slots) {
|
||||||
const isKey = KEY.has(s.slot_nameid);
|
const isKey = KEY.has(s.slot_nameid);
|
||||||
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
|
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
|
||||||
if (s.weight_kg != null) total += s.weight_kg;
|
if (s.required) {
|
||||||
pills += `<div class="slot-pill${isKey ? ' key' : ''}">
|
if (s.weight_kg != null) total += s.weight_kg;
|
||||||
<span class="sp-name">${s.slot_name}</span>
|
reqPills += `<div class="slot-pill${isKey ? ' key' : ''}">
|
||||||
<span class="sp-mod">${s.mod_name || '—'}</span>
|
<span class="sp-name">${s.slot_name}</span>
|
||||||
<span class="sp-w">${w}</span>
|
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||||||
</div>`;
|
<span class="sp-w">${w}</span>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
optPills += `<div class="slot-pill optional">
|
||||||
|
<span class="sp-name">${s.slot_name}</span>
|
||||||
|
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||||||
|
<span class="sp-w">${w}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const optSection = optPills
|
||||||
|
? `<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin:8px 0 4px">Optional slots (not included in weight)</div>
|
||||||
|
<div class="slot-summary">${optPills}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
inner.innerHTML = `
|
inner.innerHTML = `
|
||||||
<div class="slot-summary">${pills}</div>
|
<div class="slot-summary">${reqPills || '<span style="color:var(--muted);font-size:0.82rem">No required slots</span>'}</div>
|
||||||
|
${optSection}
|
||||||
<div class="expand-footer">
|
<div class="expand-footer">
|
||||||
<span class="expand-total">Lightest build: ${total.toFixed(3)} kg</span>
|
<span class="expand-total">Lightest build (required slots): ${total.toFixed(3)} kg</span>
|
||||||
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
|
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -450,7 +497,10 @@
|
|||||||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||||||
<td class="muted">{{ item.material or '—' }}</td>
|
<td class="muted">{{ item.material or '—' }}</td>
|
||||||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
<td class="w">
|
||||||
|
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||||||
|
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="empty">No armor found.</td></tr>
|
<tr><td colspan="7" class="empty">No armor found.</td></tr>
|
||||||
@@ -575,18 +625,19 @@
|
|||||||
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||||||
<label>Sort</label>
|
<label>Sort</label>
|
||||||
<select name="sort">
|
<select name="sort">
|
||||||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||||||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||||||
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||||||
<option value="capacity_asc" {% if sort=='capacity_asc' %}selected{% endif %}>Capacity ↑</option>
|
<option value="capacity_asc" {% if sort=='capacity_asc' %}selected{% endif %}>Capacity ↑</option>
|
||||||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||||||
|
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="gear-table">
|
<table class="gear-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th><th>Name</th><th>Capacity (slots)</th><th>Weight</th>
|
<th></th><th>Name</th><th>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -600,45 +651,159 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="muted">{{ item.capacity or '—' }}</td>
|
<td class="muted">{{ item.capacity or '—' }}</td>
|
||||||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||||||
|
<td class="muted">
|
||||||
|
{% if item.slots_per_kg is not none %}
|
||||||
|
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="empty">No backpacks found.</td></tr>
|
<tr><td colspan="5" class="empty">No backpacks found.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# =============================== RIGS TAB =============================== #}
|
{# =============================== SOFT RIGS TAB =============================== #}
|
||||||
{% if tab == "rigs" %}
|
{% if tab == "rigs" %}
|
||||||
<form method="get" class="filter-bar">
|
<form method="get" class="filter-bar">
|
||||||
<input type="hidden" name="tab" value="rigs">
|
<input type="hidden" name="tab" value="rigs">
|
||||||
<label>Min slots</label>
|
<label>Min slots</label>
|
||||||
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||||||
<label>Min class</label>
|
|
||||||
<select name="min_class">
|
|
||||||
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
|
||||||
{% for c in range(1,7) %}
|
|
||||||
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<label>Sort</label>
|
<label>Sort</label>
|
||||||
<select name="sort">
|
<select name="sort">
|
||||||
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||||||
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||||||
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||||||
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||||||
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="gear-table">
|
<table class="gear-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th><th>Name</th><th>Class</th><th>Capacity (slots)</th><th>Zones</th><th>Weight</th>
|
<th></th><th>Name</th><th>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in rigs %}
|
{% for item in rigs %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||||||
|
<td class="name-cell">
|
||||||
|
<strong>{{ item.short_name or item.name }}</strong>
|
||||||
|
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||||||
|
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ item.capacity or '—' }}</td>
|
||||||
|
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||||||
|
<td class="muted">
|
||||||
|
{% if item.slots_per_kg is not none %}
|
||||||
|
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="empty">No soft rigs found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# =============================== ARMORED RIGS TAB =============================== #}
|
||||||
|
{% if tab == "armored_rigs" %}
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="hidden" name="tab" value="armored_rigs">
|
||||||
|
<label>Min slots</label>
|
||||||
|
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
|
||||||
|
<label>Min class</label>
|
||||||
|
<select name="min_class">
|
||||||
|
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||||||
|
{% for c in range(1,7) %}
|
||||||
|
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Sort</label>
|
||||||
|
<select name="sort">
|
||||||
|
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||||||
|
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||||||
|
<option value="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
|
||||||
|
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||||||
|
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</option>
|
||||||
|
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<table class="gear-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th><th>Name</th><th>Class</th><th>Capacity (slots)</th><th>Zones</th><th>Weight</th><th>Carry Efficiency</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in armored_rigs %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||||||
|
<td class="name-cell">
|
||||||
|
<strong>{{ item.short_name or item.name }}</strong>
|
||||||
|
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||||||
|
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ item.capacity or '—' }}</td>
|
||||||
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
|
<td class="w">
|
||||||
|
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||||||
|
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">
|
||||||
|
{% if item.slots_per_kg is not none %}
|
||||||
|
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="empty">No armored rigs found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# =============================== PLATES TAB =============================== #}
|
||||||
|
{% if tab == "plates" %}
|
||||||
|
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">
|
||||||
|
Armor plates that slot into plate carriers. Carrier shell weight does <em>not</em> include plates — add them separately when building your loadout.
|
||||||
|
</p>
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="hidden" name="tab" value="plates">
|
||||||
|
<label>Min class</label>
|
||||||
|
<select name="min_class">
|
||||||
|
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||||||
|
{% for c in range(1,7) %}
|
||||||
|
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Sort</label>
|
||||||
|
<select name="sort">
|
||||||
|
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||||||
|
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||||||
|
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||||||
|
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||||||
|
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<table class="gear-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th><th>Name</th><th>Class</th>
|
||||||
|
<th>Durability</th><th>Material</th><th>Zones</th><th>Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in plates %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||||||
<td class="name-cell">
|
<td class="name-cell">
|
||||||
@@ -649,14 +814,15 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if item.armor_class %}
|
{% if item.armor_class %}
|
||||||
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||||||
{% else %}<span class="muted">—</span>{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{{ item.capacity or '—' }}</td>
|
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||||||
|
<td class="muted">{{ item.material or '—' }}</td>
|
||||||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="6" class="empty">No rigs found.</td></tr>
|
<tr><td colspan="7" class="empty">No plates found.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -671,6 +837,15 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// carriers that have open plate slots (shell weight only)
|
||||||
|
const CARRIERS_WITH_OPEN_SLOTS = new Set({{ carrier_ids_with_open_slots | list | tojson }});
|
||||||
|
|
||||||
|
// plate weight cache: id -> weight_kg
|
||||||
|
const PLATE_WEIGHTS = {};
|
||||||
|
|
||||||
|
// currently selected plate weights per open slot, keyed by "carrierSlot|slotNameId"
|
||||||
|
const _plateSlotWeights = {};
|
||||||
|
|
||||||
function recalcWeight() {
|
function recalcWeight() {
|
||||||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -679,12 +854,85 @@
|
|||||||
const id = sel ? sel.value : '';
|
const id = sel ? sel.value : '';
|
||||||
const w = id ? (WEIGHTS[id] || 0) : 0;
|
const w = id ? (WEIGHTS[id] || 0) : 0;
|
||||||
const disp = document.getElementById('sw_' + slot);
|
const disp = document.getElementById('sw_' + slot);
|
||||||
if (disp) disp.textContent = id ? w.toFixed(3) + ' kg' : '';
|
if (disp) {
|
||||||
|
if (id) {
|
||||||
|
const isCarrier = CARRIERS_WITH_OPEN_SLOTS.has(id);
|
||||||
|
disp.textContent = w.toFixed(3) + ' kg' + (isCarrier ? ' (shell only)' : '');
|
||||||
|
} else {
|
||||||
|
disp.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
total += w;
|
total += w;
|
||||||
}
|
}
|
||||||
|
// Add plate weights
|
||||||
|
for (const pw of Object.values(_plateSlotWeights)) {
|
||||||
|
total += pw;
|
||||||
|
}
|
||||||
document.getElementById('total-weight').textContent = total.toFixed(3);
|
document.getElementById('total-weight').textContent = total.toFixed(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _carrierSlotCache = {};
|
||||||
|
|
||||||
|
function onCarrierChange(slot) {
|
||||||
|
const sel = document.getElementById('slot_' + slot);
|
||||||
|
const carrierId = sel ? sel.value : '';
|
||||||
|
const container = document.getElementById('plates_' + slot);
|
||||||
|
container.innerHTML = '';
|
||||||
|
// Clear plate slot weights for this carrier slot
|
||||||
|
for (const key of Object.keys(_plateSlotWeights)) {
|
||||||
|
if (key.startsWith(slot + '|')) delete _plateSlotWeights[key];
|
||||||
|
}
|
||||||
|
if (!carrierId || !CARRIERS_WITH_OPEN_SLOTS.has(carrierId)) {
|
||||||
|
recalcWeight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_carrierSlotCache[carrierId]) {
|
||||||
|
renderPlateSlots(slot, carrierId, _carrierSlotCache[carrierId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/loadout/carrier/' + carrierId + '/slots.json')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
_carrierSlotCache[carrierId] = data;
|
||||||
|
renderPlateSlots(slot, carrierId, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlateSlots(carrierSlot, carrierId, slots) {
|
||||||
|
const container = document.getElementById('plates_' + carrierSlot);
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const slot of slots) {
|
||||||
|
const key = carrierSlot + '|' + slot.slot_nameid;
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:block;margin-top:8px;font-size:0.82rem;color:var(--muted)';
|
||||||
|
label.textContent = slot.slot_nameid.replace(/_/g, ' ') + (slot.zones ? ' (' + slot.zones + ')' : '');
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.style.cssText = 'width:100%;margin-top:2px';
|
||||||
|
const none = document.createElement('option');
|
||||||
|
none.value = '';
|
||||||
|
none.textContent = '— No plate —';
|
||||||
|
sel.appendChild(none);
|
||||||
|
for (const p of slot.plates) {
|
||||||
|
PLATE_WEIGHTS[p.id] = p.weight_kg || 0;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = (p.short_name || p.name) +
|
||||||
|
' (Cls ' + (p.armor_class || '?') + ', ' +
|
||||||
|
(p.weight_kg != null ? p.weight_kg.toFixed(3) : '?') + ' kg)';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const pid = sel.value;
|
||||||
|
_plateSlotWeights[key] = pid ? (PLATE_WEIGHTS[pid] || 0) : 0;
|
||||||
|
recalcWeight();
|
||||||
|
});
|
||||||
|
container.appendChild(label);
|
||||||
|
container.appendChild(sel);
|
||||||
|
_plateSlotWeights[key] = 0;
|
||||||
|
}
|
||||||
|
recalcWeight();
|
||||||
|
}
|
||||||
|
|
||||||
function saveBuild() {
|
function saveBuild() {
|
||||||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||||||
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
|
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
|
||||||
@@ -714,24 +962,25 @@
|
|||||||
|
|
||||||
<div class="builder-grid">
|
<div class="builder-grid">
|
||||||
{% set slot_defs = [
|
{% set slot_defs = [
|
||||||
('gun', 'Primary Weapon', builder_guns),
|
('gun', 'Primary Weapon', builder_guns, false),
|
||||||
('armor', 'Body Armor', builder_armor),
|
('armor', 'Body Armor', builder_armor, true),
|
||||||
('helmet', 'Helmet', builder_helmets),
|
('helmet', 'Helmet', builder_helmets, false),
|
||||||
('rig', 'Chest Rig', builder_rigs),
|
('rig', 'Chest Rig', builder_rigs, true),
|
||||||
('backpack', 'Backpack', builder_backpacks),
|
('backpack', 'Backpack', builder_backpacks, false),
|
||||||
] %}
|
] %}
|
||||||
{% for slot_id, slot_label, items in slot_defs %}
|
{% for slot_id, slot_label, items, has_plates in slot_defs %}
|
||||||
<div class="slot-card">
|
<div class="slot-card">
|
||||||
<h3>{{ slot_label }}</h3>
|
<h3>{{ slot_label }}</h3>
|
||||||
<select id="slot_{{ slot_id }}" onchange="recalcWeight()">
|
<select id="slot_{{ slot_id }}" onchange="{% if has_plates %}onCarrierChange('{{ slot_id }}'){% else %}recalcWeight(){% endif %}">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<option value="{{ item.id }}">
|
<option value="{{ item.id }}">
|
||||||
{{ item.name }}{% if item.weight_kg is not none %} ({{ "%.3f"|format(item.weight_kg) }} kg){% endif %}
|
{{ item.name }}{% if item.weight_kg is not none %} ({{ "%.3f"|format(item.weight_kg) }} kg{% if item.id in carrier_ids_with_open_slots %} shell{% endif %}){% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
|
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
|
||||||
|
{% if has_plates %}<div id="plates_{{ slot_id }}"></div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
585
templates/meds.html
Normal file
585
templates/meds.html
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OnlyScavs – Injector Reference</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #121212;
|
||||||
|
--panel: #1a1a1a;
|
||||||
|
--panel2: #1e1e1e;
|
||||||
|
--text: #eee;
|
||||||
|
--muted: #888;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--accent: #9ccfff;
|
||||||
|
--good: #6ec96e;
|
||||||
|
--bad: #e06060;
|
||||||
|
--warn: #e0a040;
|
||||||
|
--neutral: #aaa;
|
||||||
|
--heal: #5db8a0;
|
||||||
|
--stam: #6abfdb;
|
||||||
|
--skill: #a58cf0;
|
||||||
|
--special: #c8a850;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page { max-width: 1200px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
|
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||||
|
.subtitle { color: var(--muted); font-size: 0.88rem; margin: 0 0 28px; }
|
||||||
|
|
||||||
|
/* ── Section headings ── */
|
||||||
|
.section-head {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin: 32px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════
|
||||||
|
SITUATION GUIDE
|
||||||
|
════════════════════════════════════ */
|
||||||
|
.sit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sit-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.sit-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sit-label span.icon { font-size: 1rem; }
|
||||||
|
.sit-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.sit-item:last-child { border-bottom: none; }
|
||||||
|
.sit-name {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 90px;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sit-desc { color: var(--muted); font-size: 0.8rem; flex: 1; }
|
||||||
|
.sit-warn { color: var(--bad); font-size: 0.75rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ════════════════════════════════════
|
||||||
|
COMPARISON GRID
|
||||||
|
════════════════════════════════════ */
|
||||||
|
.grid-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
table.comp {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 900px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
table.comp thead th {
|
||||||
|
background: var(--panel2);
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.comp thead th:first-child {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 14px;
|
||||||
|
min-width: 160px;
|
||||||
|
background: #151515;
|
||||||
|
}
|
||||||
|
table.comp thead .th-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
table.comp thead .th-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
table.comp thead .th-short {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
table.comp tbody tr { border-bottom: 1px solid var(--border); }
|
||||||
|
table.comp tbody tr:last-child { border-bottom: none; }
|
||||||
|
table.comp tbody tr:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
table.comp tbody td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
table.comp tbody td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #151515;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.comp tbody tr.row-group td:first-child {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #555;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
table.comp tbody tr.row-group td {
|
||||||
|
background: #111;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* value cells */
|
||||||
|
.v-good { color: var(--good); font-weight: 600; }
|
||||||
|
.v-bad { color: var(--bad); }
|
||||||
|
.v-warn { color: var(--warn); }
|
||||||
|
.v-none { color: #383838; }
|
||||||
|
.v-heal { color: var(--heal); font-weight: 600; }
|
||||||
|
.v-stam { color: var(--stam); font-weight: 600; }
|
||||||
|
.v-skill { color: var(--skill); }
|
||||||
|
.v-special { color: var(--special); }
|
||||||
|
|
||||||
|
/* check / cross / dash symbols */
|
||||||
|
.sym-check { color: var(--good); font-size: 1rem; }
|
||||||
|
.sym-x { color: var(--bad); font-size: 1rem; }
|
||||||
|
.sym-dash { color: #333; }
|
||||||
|
|
||||||
|
/* duration pill */
|
||||||
|
.dur {
|
||||||
|
display: inline-block;
|
||||||
|
background: #252525;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.dur-long { background: #1e2a1e; color: var(--good); }
|
||||||
|
.dur-med { background: #252020; color: var(--warn); }
|
||||||
|
.dur-short { background: #2a1818; color: var(--bad); }
|
||||||
|
|
||||||
|
/* wiki link in header */
|
||||||
|
table.comp thead th a {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
table.comp thead th a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── filter tabs ── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-btn:hover { color: var(--text); border-color: #555; }
|
||||||
|
.filter-btn.active { color: var(--accent); border-color: var(--accent); background: #1a2533; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds" class="active">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Injector Quick Reference</h1>
|
||||||
|
<p class="subtitle">All stim injectors — situation guide + full effect comparison</p>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SITUATION GUIDE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="section-head">Situation Guide — what to grab when</div>
|
||||||
|
<div class="sit-grid">
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--heal)"><span class="icon">🩸</span> Bleeding / Wound</div>
|
||||||
|
{% for inj in situations.bleed %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--heal)"><span class="icon">❤️</span> HP Regen</div>
|
||||||
|
{% for inj in situations.regen %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--stam)"><span class="icon">⚡</span> Stamina / Speed</div>
|
||||||
|
{% for inj in situations.stam %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--skill)"><span class="icon">💪</span> Skill Boosts</div>
|
||||||
|
{% for inj in situations.skill %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--special)"><span class="icon">🌡️</span> Temperature / Special</div>
|
||||||
|
{% for inj in situations.special %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sit-card">
|
||||||
|
<div class="sit-label" style="color: var(--bad)"><span class="icon">☠️</span> High Risk / Gamble</div>
|
||||||
|
{% for inj in situations.risky %}
|
||||||
|
<div class="sit-item">
|
||||||
|
<span class="sit-name">{{ inj.short }}</span>
|
||||||
|
<span class="sit-desc">{{ inj.desc }}</span>
|
||||||
|
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
COMPARISON GRID
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="section-head" style="margin-top:40px">Full Comparison Grid</div>
|
||||||
|
|
||||||
|
<div class="filter-bar" id="filterBar">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="heal">Healing</button>
|
||||||
|
<button class="filter-btn" data-filter="stam">Stamina</button>
|
||||||
|
<button class="filter-btn" data-filter="skill">Skill</button>
|
||||||
|
<button class="filter-btn" data-filter="special">Special</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-wrap">
|
||||||
|
<table class="comp" id="compTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Effect</th>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<th data-tags="{{ inj.tags }}">
|
||||||
|
{% if inj.icon %}<img class="th-icon" src="{{ inj.icon }}" alt="" onerror="this.style.display='none'">{% endif %}
|
||||||
|
<span class="th-name">{{ inj.short }}</span>
|
||||||
|
{% if inj.wiki %}<a href="{{ inj.wiki }}" target="_blank">wiki ↗</a>{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- HP REGENERATION -->
|
||||||
|
<tr class="row-group">
|
||||||
|
<td>Healing</td>
|
||||||
|
{% for inj in injectors %}<td></td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HP regen /s</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.hp_regen %}
|
||||||
|
<span class="v-heal">+{{ inj.hp_regen }}</span>
|
||||||
|
<span class="dur {% if inj.hp_regen_dur >= 300 %}dur-long{% elif inj.hp_regen_dur >= 60 %}dur-med{% else %}dur-short{% endif %}">{{ inj.hp_regen_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Stops bleeding</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.stops_bleed %}<span class="sym-check">✓</span>{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Antidote</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.antidote %}<span class="sym-check">✓</span>{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- STAMINA -->
|
||||||
|
<tr class="row-group">
|
||||||
|
<td>Stamina</td>
|
||||||
|
{% for inj in injectors %}<td></td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Max stamina</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.max_stam %}
|
||||||
|
<span class="{% if inj.max_stam > 0 %}v-stam{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if inj.max_stam > 0 else '' }}{{ inj.max_stam }}
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.max_stam_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Stam recovery /s</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.stam_rec %}
|
||||||
|
<span class="{% if inj.stam_rec > 0 %}v-stam{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if inj.stam_rec > 0 else '' }}{{ inj.stam_rec }}
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.stam_rec_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Weight limit</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.weight %}
|
||||||
|
<span class="{% if inj.weight > 0 %}v-good{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if inj.weight > 0 else '' }}{{ (inj.weight * 100)|int }}%
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.weight_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- SKILLS -->
|
||||||
|
<tr class="row-group">
|
||||||
|
<td>Skill Buffs</td>
|
||||||
|
{% for inj in injectors %}<td></td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% for skill_name in skill_rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ skill_name }}</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% set val = inj.skills.get(skill_name) %}
|
||||||
|
{% if val %}
|
||||||
|
<span class="{% if val.value > 0 %}v-skill{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if val.value > 0 else '' }}{{ val.value }}
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ val.duration }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- SPECIAL -->
|
||||||
|
<tr class="row-group">
|
||||||
|
<td>Special</td>
|
||||||
|
{% for inj in injectors %}<td></td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Body temp Δ</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.body_temp %}
|
||||||
|
<span class="{% if inj.body_temp > 0 %}v-bad{% else %}v-good{% endif %}">
|
||||||
|
{{ '+' if inj.body_temp > 0 else '' }}{{ inj.body_temp }}°
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.body_temp_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Energy Δ /s</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.energy %}
|
||||||
|
<span class="{% if inj.energy > 0 %}v-good{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if inj.energy > 0 else '' }}{{ inj.energy }}
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.energy_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hydration Δ /s</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.hydration %}
|
||||||
|
<span class="{% if inj.hydration > 0 %}v-good{% else %}v-bad{% endif %}">
|
||||||
|
{{ '+' if inj.hydration > 0 else '' }}{{ inj.hydration }}
|
||||||
|
</span>
|
||||||
|
<span class="dur">{{ inj.hydration_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- SIDE EFFECTS -->
|
||||||
|
<tr class="row-group">
|
||||||
|
<td>Side Effects</td>
|
||||||
|
{% for inj in injectors %}<td></td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tremors</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.tremor %}
|
||||||
|
<span class="v-bad">⚠</span>
|
||||||
|
<span class="dur dur-short">{{ inj.tremor_delay }}s delay · {{ inj.tremor_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tunnel vision</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.tunnel %}
|
||||||
|
<span class="v-bad">⚠</span>
|
||||||
|
<span class="dur dur-short">{{ inj.tunnel_delay }}s delay · {{ inj.tunnel_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pain</td>
|
||||||
|
{% for inj in injectors %}
|
||||||
|
<td>
|
||||||
|
{% if inj.pain %}
|
||||||
|
<span class="v-bad">⚠</span>
|
||||||
|
<span class="dur dur-short">{{ inj.pain_delay }}s delay · {{ inj.pain_dur }}s</span>
|
||||||
|
{% else %}<span class="sym-dash">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div><!-- /grid-wrap -->
|
||||||
|
|
||||||
|
</div><!-- /page -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Filter columns by tag
|
||||||
|
document.getElementById("filterBar").addEventListener("click", function(e) {
|
||||||
|
const btn = e.target.closest(".filter-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
const filter = btn.dataset.filter;
|
||||||
|
const table = document.getElementById("compTable");
|
||||||
|
const headers = table.querySelectorAll("thead th");
|
||||||
|
headers.forEach((th, i) => {
|
||||||
|
if (i === 0) return; // label col
|
||||||
|
const tags = (th.dataset.tags || "").split(",");
|
||||||
|
const show = filter === "all" || tags.includes(filter);
|
||||||
|
// toggle all cells in column i
|
||||||
|
table.querySelectorAll("tr").forEach(tr => {
|
||||||
|
const cells = tr.children;
|
||||||
|
if (cells[i]) cells[i].style.display = show ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +1,102 @@
|
|||||||
|
{# ══════════════════════════════════════════════
|
||||||
|
MACROS — must come before first use in Jinja2
|
||||||
|
══════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
{# Flow view: depth-indented vertical chain with connector lines #}
|
||||||
|
{% macro render_chain(qid, quest_by_id, children, visible, collector_prereqs, depth) %}
|
||||||
|
{% set q = quest_by_id[qid] %}
|
||||||
|
{% set visible_kids = [] %}
|
||||||
|
{% for cid in children.get(qid, []) %}
|
||||||
|
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if depth > 0 %}<div class="fnode-connector" style="margin-left:{{ depth * 22 + 10 }}px"></div>{% endif %}
|
||||||
|
<div class="fnode-wrap" style="padding-left:{{ depth * 22 }}px">
|
||||||
|
<div class="fnode{% if q.done %} done{% endif %}{% if qid in collector_prereqs %} kappa-node{% endif %}"
|
||||||
|
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}">
|
||||||
|
<div class="fnode-top">
|
||||||
|
{% if qid in collector_prereqs %}<span class="kappa-star" title="Collector req">★</span>{% endif %}
|
||||||
|
<span class="fnode-name">{{ q.name }}</span>
|
||||||
|
{% if q.wiki_link %}<a class="fnode-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for cid in visible_kids %}
|
||||||
|
{% set child = quest_by_id[cid] %}
|
||||||
|
{% if child.trader != q.trader %}
|
||||||
|
<div class="fnode-connector" style="margin-left:{{ (depth+1)*22 + 10 }}px"></div>
|
||||||
|
<div class="fnode-wrap" style="padding-left:{{ (depth+1)*22 }}px">
|
||||||
|
<div class="fnode{% if child.done %} done{% endif %}{% if cid in collector_prereqs %} kappa-node{% endif %}"
|
||||||
|
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}">
|
||||||
|
<div class="fnode-top">
|
||||||
|
{% if cid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="fnode-name">{{ child.name }}</span>
|
||||||
|
{% if child.wiki_link %}<a class="fnode-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="fnode-meta"><span class="cross-badge">{{ child.trader }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ render_chain(cid, quest_by_id, children, visible, collector_prereqs, depth + 1) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# List view: indented tree with ├── / └── connector lines.
|
||||||
|
open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank).
|
||||||
|
is_last: whether this node is the last sibling among its parent's children. #}
|
||||||
|
{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last) %}
|
||||||
|
{% set q = quest_by_id[qid] %}
|
||||||
|
{% set visible_kids = [] %}
|
||||||
|
{% for cid in children.get(qid, []) %}
|
||||||
|
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-indent">
|
||||||
|
{% for open in open_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if open_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'elbow' if is_last else 'tee' }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="list-row{% if q.done %} done{% endif %}"
|
||||||
|
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}">
|
||||||
|
{% if qid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="list-name">{{ q.name }}</span>
|
||||||
|
{% if q.wiki_link %}<a class="list-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set child_stack = open_stack + [not is_last] %}
|
||||||
|
{% for cid in visible_kids %}
|
||||||
|
{% set child = quest_by_id[cid] %}
|
||||||
|
{% set child_last = loop.last %}
|
||||||
|
{% if child.trader != q.trader %}
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-indent">
|
||||||
|
{% for open in child_stack %}
|
||||||
|
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="list-indent-seg {{ 'elbow' if child_last else 'tee' }}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="list-row{% if child.done %} done{% endif %}"
|
||||||
|
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}">
|
||||||
|
{% if cid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
||||||
|
<span class="list-name">{{ child.name }}</span>
|
||||||
|
{% if child.wiki_link %}<a class="list-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
||||||
|
<span class="cross-badge">{{ child.trader }}</span>
|
||||||
|
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -14,226 +113,285 @@
|
|||||||
--done-text: #6ec96e;
|
--done-text: #6ec96e;
|
||||||
--done-bg: #1a2a1a;
|
--done-bg: #1a2a1a;
|
||||||
--kappa: #f0c040;
|
--kappa: #f0c040;
|
||||||
|
--line: #333;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding-top: 52px;
|
||||||
|
background-image: url('/assets/onlyscavs.png');
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 65%;
|
||||||
}
|
}
|
||||||
.page { max-width: 960px; margin: 0 auto; }
|
body::before {
|
||||||
nav { margin-bottom: 16px; font-size: 0.9rem; }
|
content: '';
|
||||||
nav a { color: var(--accent); }
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14,14,14,0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
|
||||||
|
.site-nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; height: 52px;
|
||||||
|
background: rgba(14,14,14,0.92);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
|
||||||
|
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
|
||||||
h1 { margin: 0 0 4px; }
|
h1 { margin: 0 0 4px; }
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.filter-btn {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid #444;
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.filter-btn.active {
|
|
||||||
border-color: var(--kappa);
|
|
||||||
color: var(--kappa);
|
|
||||||
}
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.legend span { display: flex; align-items: center; gap: 5px; }
|
|
||||||
|
|
||||||
/* Trader section */
|
/* toolbar */
|
||||||
|
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--panel); border: 1px solid #444; color: var(--text);
|
||||||
|
border-radius: 6px; padding: 5px 14px; cursor: pointer; font-size: 0.85rem; text-decoration: none;
|
||||||
|
}
|
||||||
|
.filter-btn.active { border-color: var(--kappa); color: var(--kappa); }
|
||||||
|
.sep { color: var(--border); }
|
||||||
|
.legend { display: flex; gap: 14px; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; margin-left: auto; }
|
||||||
|
.legend span { display: flex; align-items: center; gap: 4px; }
|
||||||
|
|
||||||
|
/* trader sections */
|
||||||
.trader-section { margin-bottom: 8px; }
|
.trader-section { margin-bottom: 8px; }
|
||||||
.trader-header {
|
.trader-header {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 8px; padding: 9px 10px;
|
||||||
align-items: center;
|
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
|
||||||
gap: 8px;
|
cursor: pointer; user-select: none;
|
||||||
padding: 10px 8px;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
.trader-header:hover { border-color: #444; }
|
.trader-header:hover { border-color: #444; }
|
||||||
.trader-name {
|
.trader-name { font-weight: bold; font-size: 0.9rem; flex: 1; }
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.trader-counts { font-size: 0.8rem; color: var(--muted); }
|
.trader-counts { font-size: 0.8rem; color: var(--muted); }
|
||||||
.chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; }
|
.chevron { color: var(--muted); font-size: 0.75rem; transition: transform 0.15s; }
|
||||||
.trader-section.collapsed .chevron { transform: rotate(-90deg); }
|
.trader-section.collapsed .chevron { transform: rotate(-90deg); }
|
||||||
.trader-body { padding: 6px 0 6px 8px; }
|
.trader-body { padding: 4px 0; }
|
||||||
.trader-section.collapsed .trader-body { display: none; }
|
.trader-section.collapsed .trader-body { display: none; }
|
||||||
|
|
||||||
/* Tree nodes */
|
/* ── FLOW VIEW ──
|
||||||
.tree-root { margin: 4px 0; }
|
Quests rendered as a depth-indented vertical chain.
|
||||||
.tree-children {
|
Parent → children flow top-to-bottom with indent + short vert connector. */
|
||||||
margin-left: 20px;
|
.flow-view .trader-body { padding: 8px 0 4px; }
|
||||||
border-left: 1px solid var(--border);
|
.fnode-connector {
|
||||||
padding-left: 10px;
|
width: 1px; height: 10px; background: var(--line); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.quest-node {
|
.fnode-wrap { display: flex; flex-direction: column; align-items: flex-start; width: 100%; }
|
||||||
display: flex;
|
.fnode {
|
||||||
align-items: center;
|
width: calc(100% - 8px); margin: 0 4px; padding: 5px 8px;
|
||||||
gap: 8px;
|
border-radius: 5px; background: #141820; border: 1px solid #1e2535;
|
||||||
padding: 5px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
}
|
||||||
.quest-node:hover { background: #1e1e1e; }
|
.fnode:hover { background: #1c2030; }
|
||||||
.quest-node.done .quest-label { text-decoration: line-through; color: var(--done-text); }
|
.fnode.done { background: var(--done-bg); border-color: #2a4a2a; }
|
||||||
.quest-node.done { background: var(--done-bg); }
|
.fnode.kappa-node { border-color: #5a4a10; }
|
||||||
.quest-label { flex: 1; font-size: 0.9rem; }
|
.fnode-top { display: flex; align-items: center; gap: 5px; min-height: 20px; }
|
||||||
.quest-label a { color: var(--accent); font-size: 0.75rem; margin-left: 6px; }
|
.fnode-name { font-size: 0.83rem; flex: 1; line-height: 1.3; word-break: break-word; }
|
||||||
.kappa-star { color: var(--kappa); font-size: 0.75rem; flex-shrink: 0; }
|
.fnode.done .fnode-name { text-decoration: line-through; color: var(--done-text); }
|
||||||
.cross-trader {
|
.fnode-wiki { color: var(--accent); font-size: 0.7rem; text-decoration: none; flex-shrink: 0; }
|
||||||
font-size: 0.75rem;
|
.fnode-wiki:hover { text-decoration: underline; }
|
||||||
color: var(--muted);
|
.fnode-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; }
|
||||||
font-style: italic;
|
.kappa-star { color: var(--kappa); font-size: 0.7rem; flex-shrink: 0; }
|
||||||
flex-shrink: 0;
|
.cross-badge {
|
||||||
|
font-size: 0.65rem; color: var(--muted); font-style: italic;
|
||||||
|
background: #222; border-radius: 3px; padding: 1px 4px;
|
||||||
}
|
}
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: transparent;
|
background: transparent; border: 1px solid #444; color: var(--muted);
|
||||||
border: 1px solid #444;
|
border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 0.7rem;
|
||||||
color: var(--muted);
|
flex-shrink: 0; margin-left: auto;
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 7px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.quest-node.done .toggle-btn {
|
.fnode.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
|
||||||
border-color: #3a6a3a;
|
|
||||||
color: var(--done-text);
|
/* ── LIST VIEW ──
|
||||||
|
Classic file-manager tree: ├── and └── connectors. */
|
||||||
|
.list-view .trader-body { padding: 4px 0; }
|
||||||
|
.list-tree { padding: 0; margin: 0; }
|
||||||
|
.list-item { display: flex; align-items: flex-start; }
|
||||||
|
.list-indent { display: flex; flex-shrink: 0; }
|
||||||
|
.list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; }
|
||||||
|
.list-indent-seg.vert::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
|
||||||
}
|
}
|
||||||
|
.list-indent-seg.tee::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.tee::after {
|
||||||
|
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.elbow::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
.list-indent-seg.elbow::after {
|
||||||
|
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
|
||||||
|
}
|
||||||
|
/* blank: spacer only, no line */
|
||||||
|
.list-indent-seg.blank {}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0;
|
||||||
|
background: transparent; min-height: 30px;
|
||||||
|
}
|
||||||
|
.list-row:hover { background: #1a1a1a; }
|
||||||
|
.list-row.done .list-name { text-decoration: line-through; color: var(--done-text); }
|
||||||
|
.list-name { font-size: 0.85rem; flex: 1; }
|
||||||
|
.list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; }
|
||||||
|
.list-wiki:hover { text-decoration: underline; }
|
||||||
|
.list-row .kappa-star { font-size: 0.72rem; }
|
||||||
|
.list-row .cross-badge { font-size: 0.7rem; }
|
||||||
|
.list-row .toggle-btn { font-size: 0.72rem; }
|
||||||
|
.list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
|
||||||
|
|
||||||
|
.flow-view.hidden, .list-view.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<nav>
|
<nav class="site-nav">
|
||||||
<a href="/">← Keys</a>
|
<a class="nav-brand" href="/">OnlyScavs</a>
|
||||||
|
|
<div class="nav-links">
|
||||||
<a href="/collector">Collector Checklist</a>
|
<a href="/keys">Keys</a>
|
||||||
|
<a href="/collector">Collector</a>
|
||||||
|
<a href="/quests" class="active">Quests</a>
|
||||||
|
<a href="/loadout">Loadout</a>
|
||||||
|
<a href="/meds">Injectors</a>
|
||||||
|
<a href="/barters">Barters</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<h1>Quest Trees</h1>
|
<h1>Quest Trees</h1>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="filter-btn {% if not only_collector %}active{% endif %}" href="/quests">All quests</a>
|
<a class="filter-btn {% if not only_collector %}active{% endif %}"
|
||||||
<a class="filter-btn {% if only_collector %}active{% endif %}" href="/quests?collector=1">★ Collector only</a>
|
href="/quests?view={{ view }}">All quests</a>
|
||||||
|
<a class="filter-btn {% if only_collector %}active{% endif %}"
|
||||||
|
href="/quests?collector=1&view={{ view }}">★ Collector only</a>
|
||||||
|
|
||||||
|
<span class="sep">|</span>
|
||||||
|
|
||||||
|
<a class="filter-btn {% if view != 'list' %}active{% endif %}"
|
||||||
|
href="/quests?{% if only_collector %}collector=1&{% endif %}view=flow">Flow</a>
|
||||||
|
<a class="filter-btn {% if view == 'list' %}active{% endif %}"
|
||||||
|
href="/quests?{% if only_collector %}collector=1&{% endif %}view=list">List</a>
|
||||||
|
|
||||||
|
<span class="sep">|</span>
|
||||||
|
|
||||||
|
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(true)">Collapse all</button>
|
||||||
|
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(false)">Expand all</button>
|
||||||
|
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span><span style="color:var(--kappa)">★</span> Required for Collector</span>
|
<span><span style="color:var(--kappa)">★</span> Collector req</span>
|
||||||
<span><span style="color:var(--done-text)">✓</span> Marked done</span>
|
<span><span style="color:var(--done-text)">✓</span> Done</span>
|
||||||
<span><span style="color:var(--muted);font-style:italic">← Trader</span> Cross-trader dependency</span>
|
<span><span style="color:var(--muted);font-style:italic">cross</span> Other trader</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% macro render_node(qid, quest_by_id, children, visible, collector_prereqs) %}
|
{# ── FLOW VIEW ── #}
|
||||||
{% set q = quest_by_id[qid] %}
|
<div class="flow-view {% if view == 'list' %}hidden{% endif %}">
|
||||||
{% set visible_kids = [] %}
|
|
||||||
{% for cid in children.get(qid, []) %}
|
|
||||||
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="tree-root">
|
|
||||||
<div class="quest-node {% if q.done %}done{% endif %}"
|
|
||||||
id="qnode-{{ qid }}" data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}">
|
|
||||||
{% if qid in collector_prereqs %}<span class="kappa-star" title="Required for Collector">★</span>{% endif %}
|
|
||||||
<span class="quest-label">
|
|
||||||
{{ q.name }}
|
|
||||||
{% if q.wiki_link %}<a href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
|
||||||
</span>
|
|
||||||
<button class="toggle-btn" onclick="toggle(this)">
|
|
||||||
{{ '✓' if q.done else '○' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% if visible_kids %}
|
|
||||||
<div class="tree-children">
|
|
||||||
{% for cid in visible_kids %}
|
|
||||||
{% set child = quest_by_id[cid] %}
|
|
||||||
{% if child.trader != q.trader %}
|
|
||||||
<div class="quest-node {% if child.done %}done{% endif %}"
|
|
||||||
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}">
|
|
||||||
{% if cid in collector_prereqs %}<span class="kappa-star">★</span>{% endif %}
|
|
||||||
<span class="quest-label">
|
|
||||||
{{ child.name }}
|
|
||||||
{% if child.wiki_link %}<a href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="cross-trader">← {{ child.trader }}</span>
|
|
||||||
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{{ render_node(cid, quest_by_id, children, visible, collector_prereqs) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% for trader in traders %}
|
{% for trader in traders %}
|
||||||
{% set roots = trader_roots[trader] %}
|
{% set roots = trader_roots[trader] %}
|
||||||
{% set total_trader = namespace(n=0) %}
|
{% set total_t = namespace(n=0) %}
|
||||||
{% set done_trader = namespace(n=0) %}
|
{% set done_t = namespace(n=0) %}
|
||||||
{# count visible quests for this trader #}
|
|
||||||
{% for qid in visible %}
|
{% for qid in visible %}
|
||||||
{% if quest_by_id[qid].trader == trader %}
|
{% if quest_by_id[qid].trader == trader %}
|
||||||
{% set total_trader.n = total_trader.n + 1 %}
|
{% set total_t.n = total_t.n + 1 %}
|
||||||
{% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %}
|
{% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<div class="trader-section" id="flow-{{ trader | replace(' ', '-') }}">
|
||||||
<div class="trader-section" id="trader-{{ trader | replace(' ', '-') }}">
|
|
||||||
<div class="trader-header" onclick="toggleTrader(this)">
|
<div class="trader-header" onclick="toggleTrader(this)">
|
||||||
<span class="trader-name">{{ trader }}</span>
|
<span class="trader-name">{{ trader }}</span>
|
||||||
<span class="trader-counts">{{ done_trader.n }} / {{ total_trader.n }}</span>
|
<span class="trader-counts">{{ done_t.n }} / {{ total_t.n }}</span>
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="trader-body">
|
<div class="trader-body">
|
||||||
{% for root_id in roots %}
|
{% for root_id in roots %}
|
||||||
{{ render_node(root_id, quest_by_id, children, visible, collector_prereqs) }}
|
{{ render_chain(root_id, quest_by_id, children, visible, collector_prereqs, 0) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── LIST VIEW ── #}
|
||||||
|
<div class="list-view {% if view != 'list' %}hidden{% endif %}">
|
||||||
|
{% for trader in traders %}
|
||||||
|
{% set roots = trader_roots[trader] %}
|
||||||
|
{% set total_t = namespace(n=0) %}
|
||||||
|
{% set done_t = namespace(n=0) %}
|
||||||
|
{% for qid in visible %}
|
||||||
|
{% if quest_by_id[qid].trader == trader %}
|
||||||
|
{% set total_t.n = total_t.n + 1 %}
|
||||||
|
{% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="trader-section" id="list-{{ trader | replace(' ', '-') }}">
|
||||||
|
<div class="trader-header" onclick="toggleTrader(this)">
|
||||||
|
<span class="trader-name">{{ trader }}</span>
|
||||||
|
<span class="trader-counts">{{ done_t.n }} / {{ total_t.n }}</span>
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="trader-body">
|
||||||
|
<div class="list-tree">
|
||||||
|
{% for root_id in roots %}
|
||||||
|
{{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleTrader(header) {
|
function toggleTrader(header) {
|
||||||
header.closest('.trader-section').classList.toggle('collapsed');
|
header.closest('.trader-section').classList.toggle('collapsed');
|
||||||
|
persistCollapsed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAllTradersCollapsed(collapsed) {
|
||||||
|
document.querySelectorAll('.trader-section').forEach(s => s.classList.toggle('collapsed', collapsed));
|
||||||
|
persistCollapsed();
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSE_KEY = 'quests.collapsedTraders2';
|
||||||
|
|
||||||
|
function persistCollapsed() {
|
||||||
|
const ids = Array.from(document.querySelectorAll('.trader-section.collapsed')).map(s => s.id);
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
(function restoreCollapsed() {
|
||||||
|
try {
|
||||||
|
JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '[]').forEach(id => {
|
||||||
|
const s = document.getElementById(id);
|
||||||
|
if (s) s.classList.add('collapsed');
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
|
||||||
function toggle(btn) {
|
function toggle(btn) {
|
||||||
const node = btn.closest('.quest-node');
|
const node = btn.closest('[data-id]');
|
||||||
const id = node.dataset.id;
|
const id = node.dataset.id;
|
||||||
const nowDone = node.dataset.done === '1' ? 0 : 1;
|
const nowDone = node.dataset.done === '1' ? 0 : 1;
|
||||||
|
|
||||||
fetch('/collector/toggle', {
|
fetch('/collector/toggle', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: new URLSearchParams({ quest_id: id, done: nowDone })
|
body: new URLSearchParams({ quest_id: id, done: nowDone })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Update all nodes with this quest id (may appear as cross-trader duplicate)
|
document.querySelectorAll(`[data-id="${id}"]`).forEach(n => {
|
||||||
document.querySelectorAll(`.quest-node[data-id="${id}"]`).forEach(n => {
|
|
||||||
n.dataset.done = nowDone;
|
n.dataset.done = nowDone;
|
||||||
const b = n.querySelector('.toggle-btn');
|
const b = n.querySelector('.toggle-btn');
|
||||||
|
if (!b) return;
|
||||||
if (nowDone) { n.classList.add('done'); b.textContent = '✓'; }
|
if (nowDone) { n.classList.add('done'); b.textContent = '✓'; }
|
||||||
else { n.classList.remove('done'); b.textContent = '○'; }
|
else { n.classList.remove('done'); b.textContent = '○'; }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user