Compare commits

..

5 Commits

Author SHA1 Message Date
serversdwn
b5a5755b6f feat: adds efficiency calculation for rigs and back pack (WIP). UX cleaned up, graphic added to landing page. 2026-03-29 04:56:36 +00:00
serversdwn
90f2601c1d feat: add barter calculator page 2026-03-26 05:04:13 +00:00
serversdwn
7650633af4 chore: update gear db via tarkov.dev api pull. 2026-03-26 03:37:08 +00:00
serversdwn
289c45e233 feat: add landing page, Improved key rating page UI 2026-03-08 21:17:50 +00:00
serversdwn
4893f3deee feat: Added new injector chart page. 2026-03-01 21:49:32 +00:00
13 changed files with 2272 additions and 99 deletions

View File

@@ -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.

374
app.py
View File

@@ -1,7 +1,7 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify
import sqlite3
app = Flask(__name__)
app = Flask(__name__, static_folder="assets", static_url_path="/assets")
DB_PATH = "tarkov.db"
@@ -69,20 +69,17 @@ _migrate_key_ids_and_maps()
@app.route("/")
def index():
def landing():
return render_template("landing.html")
def _keys_context():
conn = get_db()
maps = conn.execute("""
SELECT id, name
FROM maps
ORDER BY name
""").fetchall()
maps = conn.execute("SELECT id, name FROM maps ORDER BY name").fetchall()
map_filter = request.args.get("map_id", type=int)
sort = request.args.get("sort", "priority_desc")
show = request.args.get("show", "all")
key_map_rows = conn.execute("""
SELECT key_id, map_id
FROM key_maps
""").fetchall()
key_map_rows = conn.execute("SELECT key_id, map_id FROM key_maps").fetchall()
key_maps = {}
for row in key_map_rows:
key_maps.setdefault(row["key_id"], set()).add(row["map_id"])
@@ -107,9 +104,7 @@ def index():
AND kmf.map_id = ?
"""
params.append(map_filter)
key_query += """
LEFT JOIN key_ratings r ON k.id = r.key_id
"""
key_query += " LEFT JOIN key_ratings r ON k.id = r.key_id "
if show == "rated":
key_query += " WHERE r.priority IS NOT NULL "
elif show == "unrated":
@@ -131,15 +126,13 @@ def index():
conn.close()
key_maps = {k: sorted(v) for k, v in key_maps.items()}
return render_template(
"index.html",
keys=keys,
maps=maps,
key_maps=key_maps,
map_filter=map_filter,
sort=sort,
show=show,
)
return dict(keys=keys, maps=maps, key_maps=key_maps,
map_filter=map_filter, sort=sort, show=show)
@app.route("/keys")
def keys_page():
return render_template("keys.html", **_keys_context())
@app.route("/rate", methods=["POST"])
@@ -186,7 +179,7 @@ def rate_key():
redirect_args["sort"] = sort
if 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}")
@@ -245,7 +238,7 @@ def rate_all():
redirect_args["sort"] = sort
if show:
redirect_args["show"] = show
base_url = url_for("index", **redirect_args)
base_url = url_for("keys_page", **redirect_args)
if save_one:
return redirect(f"{base_url}#key-{save_one}")
return redirect(base_url)
@@ -451,16 +444,54 @@ def _sort_col(sort):
"class_asc": "armor_class ASC 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",
# 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")
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")
def loadout():
conn = get_db()
tab = request.args.get("tab", "guns")
sort = request.args.get("sort", "weight_asc")
guns = armor = helmets = headwear = backpacks = rigs = plates = []
guns = armor = helmets = headwear = backpacks = rigs = armored_rigs = plates = []
builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = []
requires = request.args.getlist("requires") # list of slot_nameids that must exist
min_class = request.args.get("min_class", 0, type=int)
@@ -530,21 +561,34 @@ def loadout():
""", (min_class, min_class)).fetchall()
elif tab == "backpacks":
backpacks = conn.execute(f"""
rows = conn.execute(f"""
SELECT * FROM gear_items
WHERE category = 'backpack'
AND (? = 0 OR capacity >= ?)
ORDER BY {sort_frag}
""", (min_capacity, min_capacity)).fetchall()
backpacks = _sort_enriched(_enrich_with_efficiency(rows), sort)
elif tab == "rigs":
rigs = conn.execute(f"""
rows = conn.execute(f"""
SELECT * FROM gear_items
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 armor_class >= ?)
ORDER BY {sort_frag}
""", (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"""
@@ -570,7 +614,7 @@ def loadout():
"loadout.html",
tab=tab, sort=sort,
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
backpacks=backpacks, rigs=rigs, plates=plates,
backpacks=backpacks, rigs=rigs, armored_rigs=armored_rigs, plates=plates,
slot_filters=LOADOUT_SLOT_FILTERS,
requires=requires,
min_class=min_class, min_capacity=min_capacity,
@@ -751,5 +795,277 @@ def save_build():
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__":
app.run(host="0.0.0.0", port=5000, debug=True)

BIN
assets/onlyscavs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
tarkov.db

Binary file not shown.

408
templates/barters.html Normal file
View 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>

View File

@@ -77,15 +77,38 @@
}
* { box-sizing: border-box; }
body {
font-family: sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
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; }
nav { margin-bottom: 16px; font-size: 0.9rem; }
nav a { color: var(--accent); }
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.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;
@@ -289,12 +312,16 @@
</head>
<body>
<div class="page">
<nav>
<a href="/">← Keys</a>
&nbsp;|&nbsp;
<a href="/quests">Quest Trees</a>
&nbsp;|&nbsp;
<a href="/loadout">Loadout Planner</a>
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector" class="active">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>Collector Checklist</h1>
<p class="subtitle">

View File

@@ -15,11 +15,40 @@
--key-border: #5a7a3a;
--key-bg: #141e10;
}
body { font-family: sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 16px; }
.page { max-width: 900px; margin: 0 auto; }
nav { margin-bottom: 20px; }
nav a { color: var(--accent); font-size: 0.9rem; }
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: 900px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
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; }
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; }
@@ -153,8 +182,16 @@
</head>
<body>
<div class="page">
<nav>
<a href="/loadout?tab=guns">← Back to Guns</a>
<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" class="active">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<div class="gun-card">

View File

@@ -174,6 +174,10 @@
<a href="/quests">Quest Trees</a>
&nbsp;|&nbsp;
<a href="/loadout">Loadout Planner</a>
&nbsp;|&nbsp;
<a href="/meds">Injectors</a>
&nbsp;|&nbsp;
<a href="/barters">Barters</a>
</nav>
<h1>OnlyScavs Keys</h1>

382
templates/keys.html Normal file
View 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 AZ</option>
<option value="name_desc" {% if sort == "name_desc" %}selected{% endif %}>Name ZA</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
View 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>

View File

@@ -14,17 +14,40 @@
--amber: #ffd580;
}
body {
font-family: sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
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; }
nav { margin-bottom: 20px; }
nav a { color: var(--accent); font-size: 0.9rem; }
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 {
@@ -221,9 +244,16 @@
</head>
<body>
<div class="page">
<nav>
<a href="/">← Keys</a> &nbsp;|&nbsp;
<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" class="active">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Loadout Planner</h1>
<p style="color:var(--muted);margin:0 0 16px;font-size:0.9rem;">
@@ -231,7 +261,7 @@
</p>
<div class="tab-bar">
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('plates','Plates'),('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>
{% endfor %}
</div>
@@ -599,6 +629,7 @@
<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_asc" {% if sort=='capacity_asc' %}selected{% endif %}>Capacity ↑</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>
@@ -606,7 +637,7 @@
<table class="gear-table">
<thead>
<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>
</thead>
<tbody>
@@ -620,20 +651,71 @@
</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="4" class="empty">No backpacks found.</td></tr>
<tr><td colspan="5" class="empty">No backpacks found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== RIGS TAB =============================== #}
{# =============================== SOFT RIGS TAB =============================== #}
{% if tab == "rigs" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="rigs">
<label>Min slots</label>
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
<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="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>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
</tr>
</thead>
<tbody>
{% 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>
@@ -647,6 +729,7 @@
<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>
@@ -654,11 +737,11 @@
<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></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 rigs %}
{% 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">
@@ -667,9 +750,7 @@
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
</td>
<td>
{% if item.armor_class %}
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td class="muted">{{ item.capacity or '—' }}</td>
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
@@ -677,9 +758,14 @@
{% 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="6" class="empty">No rigs found.</td></tr>
<tr><td colspan="7" class="empty">No armored rigs found.</td></tr>
{% endfor %}
</tbody>
</table>

585
templates/meds.html Normal file
View 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>

View File

@@ -116,10 +116,39 @@
--line: #333;
}
* { box-sizing: border-box; }
body { font-family: sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 16px; }
.page { max-width: 1100px; margin: 0 auto; }
nav { margin-bottom: 16px; font-size: 0.9rem; }
nav a { color: var(--accent); }
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 { margin: 0 0 4px; }
/* toolbar */
@@ -226,12 +255,16 @@
</head>
<body>
<div class="page">
<nav>
<a href="/">← Keys</a>
&nbsp;|&nbsp;
<a href="/collector">Collector Checklist</a>
&nbsp;|&nbsp;
<a href="/loadout">Loadout Planner</a>
<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" class="active">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Quest Trees</h1>