Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation

This commit is contained in:
Gérald Colangelo
2026-03-17 17:20:54 +01:00
parent 120dc70cf5
commit 5b18fadb60
55 changed files with 5419 additions and 59 deletions

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{{ analysis.title }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
{% if analysis.session_id %}
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">Session</a> &rsaquo;
{% else %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> &rsaquo;
{% endif %}
Analysis
</div>
<h1 style="margin:0;">{{ analysis.title }}</h1>
<div style="font-size:0.85rem;color:#888;margin-top:.3rem;">
{{ analysis.created_at.strftime('%d %b %Y') }}
&nbsp;&middot;&nbsp; {{ analysis.shot_count }} shot(s)
&nbsp;&middot;&nbsp; {{ analysis.group_count }} group(s)
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;">
{% if has_pdf %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=analysis.id) }}"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
&#8659; Download PDF report
</a>
{% endif %}
{% if current_user.is_authenticated and current_user.id == analysis.user_id %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=analysis.id) }}"
onsubmit="return confirm('Delete this analysis? The CSV and PDF will be permanently removed.');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
{% endif %}
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">&#8592; New analysis</a>
</div>
</div>
<h2>Overall Statistics</h2>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>
{% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>Groups &mdash; {{ groups_display|length }} group(s) detected</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>Group {{ stat.group_index }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} shot(s)
</div>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
</thead>
<tbody>
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>
{% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ chart_b64 }}"
alt="Speed chart for group {{ stat.group_index }}">
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Confirm your email — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Check your inbox</h1>
<p style="color:#555;margin-bottom:1.25rem;">
A confirmation link has been sent to <strong>{{ email }}</strong>.
Click the link in that email to activate your account.
</p>
<p style="color:#888;font-size:0.9rem;margin-bottom:1.5rem;">
Didn't receive it? Check your spam folder, or request a new link below.
</p>
<form method="post" action="{{ url_for('auth.resend_confirmation') }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit"
style="background:#f0f4ff;color:#1f77b4;border:1px solid #c0d4f0;border-radius:4px;padding:0.55rem 1.2rem;font-size:0.92rem;cursor:pointer;">
Resend confirmation email
</button>
</form>
{% endblock %}

62
templates/auth/login.html Normal file
View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Login — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Sign in</h1>
<form method="post" action="{{ url_for('auth.login') }}" style="max-width:360px;margin-bottom:1.5rem;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password</label>
<input type="password" name="password" required autocomplete="current-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Sign in
</button>
</form>
{% if show_resend %}
<form method="post" action="{{ url_for('auth.resend_confirmation') }}" style="margin-bottom:1.5rem;">
<input type="hidden" name="email" value="{{ resend_email }}">
<button type="submit" class="btn-link" style="color:#1f77b4;font-size:0.88rem;">
Resend confirmation email
</button>
</form>
{% endif %}
<p style="font-size:0.9rem;color:#555;margin-bottom:1.5rem;">
Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
</p>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem;max-width:360px;">
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
<span style="font-size:0.8rem;color:#999;">or continue with</span>
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;max-width:360px;">
<a href="{{ url_for('auth.login_google') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #dadce0;border-radius:6px;color:#3c4043;text-decoration:none;font-size:0.92rem;font-weight:500;background:#fff;">
<svg width="17" height="17" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
</a>
<a href="{{ url_for('auth.login_github') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #d0d7de;border-radius:6px;color:#24292f;text-decoration:none;font-size:0.92rem;font-weight:500;background:#f6f8fa;">
<svg width="17" height="17" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/>
</svg>
Continue with GitHub
</a>
</div>
{% endblock %}

107
templates/auth/profile.html Normal file
View File

@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}Profile — The Shooter's Network{% endblock %}
{% block content %}
<h1>Profile</h1>
{# ---- Avatar + display name ---- #}
<h2>Account</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
enctype="multipart/form-data"
style="max-width:480px;">
<input type="hidden" name="action" value="update_profile">
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:1.5rem;">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#888;">
&#128100;
</div>
{% endif %}
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
Profile picture
</label>
<input type="file" name="avatar" accept="image/*" style="font-size:0.9rem;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">JPEG/PNG, max 1200 px, auto-resized.</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Display name</label>
<input type="text" name="display_name"
value="{{ current_user.display_name or '' }}"
required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Bio</label>
<textarea name="bio" rows="4" placeholder="Tell others a bit about yourself…"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ current_user.bio or '' }}</textarea>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Email</label>
<input type="text" value="{{ current_user.email }}" disabled
style="width:100%;padding:.55rem .75rem;border:1px solid #e0e0e0;border-radius:4px;font-size:0.95rem;background:#f5f5f5;color:#888;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">
Logged in via <strong>{{ current_user.provider.title() }}</strong>
</div>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;font-size:.95rem;">
<input type="checkbox" name="show_equipment_public"
{% if current_user.show_equipment_public %}checked{% endif %}
style="width:1rem;height:1rem;">
Show my equipment on my public profile
</label>
</div>
<div style="display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Save changes
</button>
<a href="{{ url_for('public_profile', user_id=current_user.id) }}"
style="font-size:0.9rem;color:#1f77b4;" target="_blank">
View my public profile &rarr;
</a>
</div>
</form>
{# ---- Change password (local accounts only) ---- #}
{% if current_user.provider == 'local' %}
<h2>Change password</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
style="max-width:480px;">
<input type="hidden" name="action" value="change_password">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Current password</label>
<input type="password" name="current_password" required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">New password</label>
<input type="password" name="new_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Confirm</label>
<input type="password" name="confirm_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Change password
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}{{ profile_user.display_name or profile_user.email.split('@')[0] }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap;">
{% set av = profile_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;flex-shrink:0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:#888;flex-shrink:0;">
&#128100;
</div>
{% endif %}
<div>
<h1 style="margin:0 0 .25rem;">{{ profile_user.display_name or profile_user.email.split('@')[0] }}</h1>
<div style="font-size:0.85rem;color:#888;">
Member since {{ profile_user.created_at.strftime('%B %Y') }}
</div>
{% if profile_user.bio %}
<p style="margin-top:.75rem;color:#444;white-space:pre-wrap;max-width:600px;">{{ profile_user.bio }}</p>
{% endif %}
</div>
</div>
{# ---- Public Sessions ---- #}
<h2>Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
{% if public_sessions %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Session</th><th>Location</th><th>Distance</th></tr>
</thead>
<tbody>
{% for s in public_sessions %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('sessions.detail', session_id=s.id) }}'">
<td>
<a href="{{ url_for('sessions.detail', session_id=s.id) }}" style="color:inherit;text-decoration:none;">
{{ s.session_date.strftime('%d %b %Y') }}
</a>
</td>
<td style="color:#666;">{{ s.location_name or '—' }}</td>
<td style="color:#666;">{% if s.distance_m %}{{ s.distance_m }} m{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No public sessions yet.</p>
{% endif %}
{# ---- Equipment (optional) ---- #}
{% if equipment is not none %}
<h2>Equipment</h2>
{% if equipment %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Name</th><th>Category</th><th>Brand / Model</th><th>Caliber</th></tr>
</thead>
<tbody>
{% for item in equipment %}
<tr>
<td>{{ item.name }}</td>
<td style="color:#666;font-size:0.88rem;">{{ item.category.title() }}</td>
<td style="color:#666;font-size:0.88rem;">
{% if item.brand or item.model %}
{{ item.brand or '' }}{% if item.brand and item.model %} {% endif %}{{ item.model or '' }}
{% else %}—{% endif %}
</td>
<td style="color:#666;font-size:0.88rem;">{{ item.caliber or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No equipment listed.</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Create account — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Create account</h1>
<form method="post" action="{{ url_for('auth.register') }}" style="max-width:360px;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password
<span style="font-weight:400;color:#888;">(min. 8 characters)</span>
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Confirm password</label>
<input type="password" name="confirm_password" required autocomplete="new-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Create account
</button>
</form>
<p style="font-size:0.9rem;color:#555;margin-top:1.25rem;">
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
</p>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ballistic Analyzer</title>
<title>{% block title %}The Shooter's Network{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -11,8 +11,130 @@
background: #f4f5f7;
color: #222;
min-height: 100vh;
padding: 2rem 1rem;
}
/* ── Nav ── */
.nav {
background: #1a1a2e;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 52px;
gap: 1rem;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #fff;
text-decoration: none;
letter-spacing: 0.02em;
white-space: nowrap;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.nav-links a {
color: #c8cfe0;
text-decoration: none;
font-size: 0.9rem;
}
.nav-links a:hover { color: #fff; }
.nav-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
vertical-align: middle;
object-fit: cover;
}
.nav-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
/* ── User dropdown ── */
.nav-dropdown { position: relative; }
.nav-user-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: none;
border: 1px solid rgba(255,255,255,.25);
border-radius: 20px;
padding: 0.25rem 0.75rem 0.25rem 0.4rem;
color: #c8cfe0;
cursor: pointer;
font-size: 0.88rem;
font-family: inherit;
white-space: nowrap;
}
.nav-user-btn:hover { border-color: rgba(255,255,255,.55); color: #fff; }
.nav-user-btn .caret { font-size: 0.65rem; opacity: .7; }
.nav-dd-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
min-width: 160px;
z-index: 100;
overflow: hidden;
}
.nav-dropdown.open .nav-dd-menu { display: block; }
.nav-dd-menu a,
.nav-dd-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.65rem 1rem;
font-size: 0.9rem;
color: #222;
text-decoration: none;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.nav-dd-menu a:hover,
.nav-dd-menu button:hover { background: #f4f5f7; }
.nav-dd-menu hr { border: none; border-top: 1px solid #e8e8e8; margin: 0; }
.btn-link {
background: none;
border: none;
color: #c8cfe0;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
}
.btn-link:hover { color: #fff; text-decoration: underline; }
/* ── Flash messages ── */
.flashes {
max-width: 960px;
margin: 1rem auto 0;
padding: 0 1rem;
}
.flash {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-size: 0.92rem;
}
.flash.error { background: #fff0f0; border-left: 4px solid #e74c3c; color: #c0392b; }
.flash.success { background: #f0fff4; border-left: 4px solid #27ae60; color: #1e8449; }
.flash.message { background: #f0f4ff; border-left: 4px solid #1f77b4; color: #154360; }
/* ── Page content ── */
.page { padding: 2rem 1rem; }
.container {
max-width: 960px;
margin: 0 auto;
@@ -45,11 +167,7 @@
border-bottom: 1px solid #e8e8e8;
font-size: 0.92rem;
}
th {
background: #f0f4ff;
font-weight: 600;
color: #444;
}
th { background: #f0f4ff; font-weight: 600; color: #444; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafbff; }
.group-section {
@@ -58,11 +176,7 @@
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.group-meta {
font-size: 0.88rem;
color: #666;
margin-bottom: 0.75rem;
}
.group-meta { font-size: 0.88rem; color: #666; margin-bottom: 0.75rem; }
.chart-img {
width: 100%;
max-width: 860px;
@@ -73,8 +187,211 @@
</style>
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
{% block body %}
<nav class="nav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% endif %}
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}
<span style="font-size:1.1rem;line-height:1;">&#128100;</span>
{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
</a>
{% endif %}
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
});
</script>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="page">
<div class="container">
{% block content %}{% endblock %}
</div>
</div>
{% endblock %}
{# ── Lightbox ── always present, activated by any img[data-gallery] #}
<div id="lb" role="dialog" aria-modal="true" aria-label="Photo viewer"
style="display:none;position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.88);
align-items:center;justify-content:center;">
<button id="lb-close" aria-label="Close"
style="position:absolute;top:.9rem;right:1.2rem;background:none;border:none;
color:#fff;font-size:2rem;line-height:1;cursor:pointer;opacity:.8;">&#x2715;</button>
<button id="lb-prev" aria-label="Previous"
style="position:absolute;left:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8249;</button>
<button id="lb-next" aria-label="Next"
style="position:absolute;right:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8250;</button>
<div style="max-width:92vw;text-align:center;pointer-events:none;">
<img id="lb-img" src="" alt=""
style="max-width:92vw;max-height:88vh;object-fit:contain;border-radius:4px;
display:block;margin:0 auto;pointer-events:none;">
<div id="lb-caption"
style="color:#ddd;margin-top:.6rem;font-size:.9rem;min-height:1.2em;"></div>
<div id="lb-counter"
style="color:#aaa;font-size:.78rem;margin-top:.2rem;"></div>
</div>
</div>
<script>
(function () {
var lb = document.getElementById('lb');
var lbImg = document.getElementById('lb-img');
var lbCap = document.getElementById('lb-caption');
var lbCnt = document.getElementById('lb-counter');
var lbPrev = document.getElementById('lb-prev');
var lbNext = document.getElementById('lb-next');
var gallery = []; // [{src, caption}]
var current = 0;
function open(items, idx) {
gallery = items;
current = idx;
render();
lb.style.display = 'flex';
document.body.style.overflow = 'hidden';
lb.focus();
}
function close() {
lb.style.display = 'none';
document.body.style.overflow = '';
}
function render() {
var item = gallery[current];
lbImg.src = item.src;
lbImg.alt = item.caption || '';
lbCap.textContent = item.caption || '';
if (gallery.length > 1) {
lbPrev.style.display = '';
lbNext.style.display = '';
lbCnt.textContent = (current + 1) + ' / ' + gallery.length;
} else {
lbPrev.style.display = 'none';
lbNext.style.display = 'none';
lbCnt.textContent = '';
}
}
function move(delta) {
current = (current + delta + gallery.length) % gallery.length;
render();
}
// Click outside the image closes
lb.addEventListener('click', function (e) {
if (e.target === lb) close();
});
document.getElementById('lb-close').addEventListener('click', close);
lbPrev.addEventListener('click', function (e) { e.stopPropagation(); move(-1); });
lbNext.addEventListener('click', function (e) { e.stopPropagation(); move(1); });
document.addEventListener('keydown', function (e) {
if (lb.style.display === 'none') return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') move(-1);
if (e.key === 'ArrowRight') move(1);
});
// Touch swipe support
var touchStartX = null;
lb.addEventListener('touchstart', function (e) { touchStartX = e.touches[0].clientX; });
lb.addEventListener('touchend', function (e) {
if (touchStartX === null) return;
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) move(dx < 0 ? 1 : -1);
touchStartX = null;
});
// Wire up all gallery images after DOM is ready
function initGalleries() {
var groups = {};
document.querySelectorAll('img[data-gallery]').forEach(function (img) {
var g = img.getAttribute('data-gallery');
if (!groups[g]) groups[g] = [];
groups[g].push({ src: img.getAttribute('data-src') || img.src,
caption: img.getAttribute('data-caption') || '',
el: img });
});
Object.keys(groups).forEach(function (g) {
var items = groups[g];
items.forEach(function (item, idx) {
item.el.style.cursor = 'zoom-in';
item.el.addEventListener('click', function (e) {
e.preventDefault();
open(items.map(function (x) { return { src: x.src, caption: x.caption }; }), idx);
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGalleries);
} else {
initGalleries();
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Dashboard — The Shooter's Network{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p style="color:#555;margin-bottom:2rem;">
Welcome back, <strong>{{ current_user.display_name or current_user.email }}</strong>.
</p>
<div style="display:flex;gap:1.5rem;margin-bottom:2.5rem;flex-wrap:wrap;">
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
</a>
<a href="{{ url_for('equipment.new') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
+ Add equipment
</a>
<a href="{{ url_for('analyze') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
New analysis
</a>
</div>
<h2>Recent Analyses</h2>
{% if analyses %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Shots</th>
<th>Groups</th>
<th>Visibility</th>
</tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="white-space:nowrap;color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td style="color:{% if a.is_public %}#27ae60{% else %}#888{% endif %};font-size:0.88rem;">
{{ 'Public' if a.is_public else 'Private' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-top:1rem;">
No analyses yet. <a href="{{ url_for('analyze') }}">Upload a CSV file</a> to get started — it will be saved here automatically.
</p>
{% endif %}
<div style="display:flex;gap:2rem;margin-top:2.5rem;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Equipment</div>
<a href="{{ url_for('equipment.index') }}">Manage your rifles, scopes &amp; gear &rarr;</a>
</div>
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Sessions</div>
<a href="{{ url_for('sessions.index') }}">View your shooting sessions &rarr;</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}{{ item.name }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
<a href="{{ url_for('equipment.index') }}">Equipment</a> &rsaquo;
{{ categories.get(item.category, item.category).title() }}
</div>
<h1 style="margin:0;">{{ item.name }}</h1>
</div>
<div style="display:flex;gap:0.75rem;">
<a href="{{ url_for('equipment.edit', item_id=item.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
</div>
</div>
{% if item.photo_url %}
<div style="margin-bottom:1.5rem;">
<img src="{{ item.photo_url }}"
data-gallery="equipment-{{ item.id }}"
data-src="{{ item.photo_url }}"
data-caption="{{ item.name }}"
alt="{{ item.name }}"
style="max-width:480px;width:100%;border-radius:8px;display:block;margin-bottom:.6rem;">
<div style="display:flex;gap:.5rem;">
{% for label, deg in [('↺ Left', -90), ('↻ Right', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('equipment.rotate_photo_view', item_id=item.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<table style="max-width:480px;">
<tbody>
<tr><td style="color:#888;width:140px;">Category</td><td>{{ categories.get(item.category, item.category).title() }}</td></tr>
{% if item.brand %}<tr><td style="color:#888;">Brand</td><td>{{ item.brand }}</td></tr>{% endif %}
{% if item.model %}<tr><td style="color:#888;">Model</td><td>{{ item.model }}</td></tr>{% endif %}
{% if item.category == 'scope' %}
{% if item.magnification %}<tr><td style="color:#888;">Magnification</td><td>{{ item.magnification }}</td></tr>{% endif %}
{% if item.reticle %}<tr><td style="color:#888;">Reticle</td><td>{{ item.reticle }}</td></tr>{% endif %}
{% if item.unit %}<tr><td style="color:#888;">Unit</td><td>{{ item.unit }}</td></tr>{% endif %}
{% else %}
{% if item.caliber %}<tr><td style="color:#888;">Caliber</td><td>{{ item.caliber }}</td></tr>{% endif %}
{% endif %}
{% if item.serial_number %}<tr><td style="color:#888;">Serial</td><td>{{ item.serial_number }}</td></tr>{% endif %}
<tr><td style="color:#888;">Added</td><td>{{ item.created_at.strftime('%d %b %Y') }}</td></tr>
</tbody>
</table>
{% if item.notes %}
<div style="margin-top:1.5rem;">
<h3>Notes</h3>
<p style="color:#555;white-space:pre-wrap;">{{ item.notes }}</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% set editing = item is not none %}
{% block title %}{{ 'Edit' if editing else 'Add' }} Equipment — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ 'Edit' if editing else 'Add equipment' }}</h1>
{% set f = prefill or item %}
<form method="post"
action="{{ url_for('equipment.edit', item_id=item.id) if editing else url_for('equipment.new') }}"
enctype="multipart/form-data"
style="max-width:520px;">
<div style="margin-bottom:1rem;">
<label class="field-label">Category *</label>
<select name="category" required style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
{% for key, label in categories %}
<option value="{{ key }}" {% if (f and f.category == key) or (not f and key == 'rifle') %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Name *</label>
<input type="text" name="name" value="{{ f.name if f else '' }}" required
placeholder="e.g. Tikka T3x, Glock 17"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="field-label">Brand</label>
<input type="text" name="brand" value="{{ f.brand if f else '' }}"
placeholder="e.g. Tikka, Leupold"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Model</label>
<input type="text" name="model" value="{{ f.model if f else '' }}"
placeholder="e.g. T3x, VX-3HD"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div id="rifle-fields" style="margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="field-label">Caliber</label>
<input type="text" name="caliber" value="{{ f.caliber if f else '' }}"
placeholder="e.g. .308 Win, 6.5 CM"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
</div>
<div id="scope-fields" style="display:none;margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;">
<div>
<label class="field-label">Magnification</label>
<input type="text" name="magnification" value="{{ f.magnification if f else '' }}"
placeholder="e.g. 3-15x50"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Reticle</label>
<select name="reticle" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="FFP" {% if f and f.reticle == 'FFP' %}selected{% endif %}>FFP (First Focal Plane)</option>
<option value="SFP" {% if f and f.reticle == 'SFP' %}selected{% endif %}>SFP (Second Focal Plane)</option>
</select>
</div>
<div>
<label class="field-label">Unit</label>
<select name="unit" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="MOA" {% if f and f.unit == 'MOA' %}selected{% endif %}>MOA</option>
<option value="MRAD" {% if f and f.unit == 'MRAD' %}selected{% endif %}>MRAD</option>
</select>
</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Serial number</label>
<input type="text" name="serial_number" value="{{ f.serial_number if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Notes</label>
<textarea name="notes" rows="3"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label class="field-label">Photo</label>
{% if editing and item.photo_url %}
<div style="margin-bottom:0.5rem;">
<img src="{{ item.photo_url }}" alt="Current photo"
style="height:80px;border-radius:4px;object-fit:cover;">
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">Upload a new one to replace it.</span>
</div>
{% endif %}
<input type="file" name="photo" accept="image/*"
style="font-size:0.92rem;">
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Add equipment' }}
</button>
<a href="{{ url_for('equipment.detail', item_id=item.id) if editing else url_for('equipment.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
</div>
</form>
<style>.field-label { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
<script>
function toggleCategoryFields() {
var cat = document.querySelector('[name="category"]').value;
var isScope = cat === 'scope';
document.getElementById('scope-fields').style.display = isScope ? '' : 'none';
document.getElementById('rifle-fields').style.display = isScope ? 'none' : '';
}
document.querySelector('[name="category"]').addEventListener('change', toggleCategoryFields);
toggleCategoryFields(); // run on load
</script>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Equipment — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Equipment</h1>
<a href="{{ url_for('equipment.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ Add item
</a>
</div>
{% if items %}
{% set cat_labels = dict(categories) %}
{% for cat_key, cat_label in categories %}
{% set group = items | selectattr('category', 'equalto', cat_key) | list %}
{% if group %}
<h2>{{ cat_label }}s</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;margin-bottom:2rem;">
{% for item in group %}
<div style="border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">
{% if item.photo_url %}
<img src="{{ item.photo_url }}" alt="{{ item.name }}"
style="width:100%;height:150px;object-fit:cover;display:block;">
{% else %}
<div style="width:100%;height:80px;background:#f0f4ff;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#c0c8e0;">
{% if item.category == 'rifle' or item.category == 'handgun' %}🔫
{% elif item.category == 'scope' %}🔭
{% else %}🔩{% endif %}
</div>
{% endif %}
<div style="padding:0.9rem 1rem;">
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.2rem;">{{ item.name }}</div>
{% if item.brand or item.model %}
<div style="font-size:0.85rem;color:#666;margin-bottom:0.3rem;">
{{ [item.brand, item.model] | select | join(' · ') }}
</div>
{% endif %}
{% if item.caliber %}
<div style="font-size:0.82rem;color:#888;margin-bottom:0.6rem;">{{ item.caliber }}</div>
{% endif %}
<div style="display:flex;gap:0.75rem;margin-top:0.5rem;">
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">View</a>
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">Edit</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}" style="display:inline;"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🔫</div>
<p style="margin-bottom:1rem;">No equipment yet.</p>
<a href="{{ url_for('equipment.new') }}">Add your first item</a>
</div>
{% endif %}
{% endblock %}

157
templates/index.html Normal file
View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}The Shooter's Network — Track, analyze, share{% endblock %}
{% block body %}
<nav class="nav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% endif %}
</div>
<div class="nav-right">
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}<span style="font-size:1.1rem;line-height:1;">&#128100;</span>{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
</a>
{% endif %}
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
});
</script>
<!-- ── Hero ── -->
<section style="background:#1a1a2e;color:#fff;padding:4rem 1.5rem;text-align:center;">
<h1 style="font-size:2.4rem;font-weight:800;color:#fff;margin-bottom:0.75rem;letter-spacing:-0.02em;">
The Shooter's Network
</h1>
<p style="font-size:1.15rem;color:#a0aec0;max-width:560px;margin:0 auto 2rem;">
Analyze your ballistic data, track every session, manage your equipment,
and share your performance with the community.
</p>
<div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
New Analysis
</a>
<a href="{{ url_for('sessions.new') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Log a Session
</a>
{% else %}
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
Get started — free
</a>
<a href="{{ url_for('analyze') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Try without account
</a>
{% endif %}
</div>
</section>
<!-- ── Features ── -->
<section style="background:#f8f9fb;padding:2.5rem 1.5rem;">
<div style="max-width:900px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:1.25rem;">
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">📊</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Ballistic Analysis</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🎯</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Session Tracking</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🤝</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Community Feed</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Share your public sessions and see what other shooters are achieving on the range.</p>
</div>
</div>
</section>
<!-- ── Flash messages ── -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="max-width:960px;margin:1rem auto;padding:0 1.5rem;">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- ── Public sessions feed ── -->
<section style="padding:2.5rem 1.5rem 3rem;">
<div style="max-width:960px;margin:0 auto;">
<h2 style="font-size:1.3rem;color:#1a1a2e;margin-bottom:1.25rem;border-bottom:2px solid #e0e0e0;padding-bottom:.4rem;">
Latest sessions
</h2>
{% if public_sessions %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.1rem;">
{% for s in public_sessions %}
<a href="{{ url_for('sessions.detail', session_id=s.id) }}"
style="display:block;background:#fff;border:1px solid #e8e8e8;border-radius:8px;padding:1.1rem 1.25rem;text-decoration:none;color:inherit;">
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.65rem;">
{% if s.user.avatar_url %}
<img src="{{ s.user.avatar_url }}" style="width:26px;height:26px;border-radius:50%;object-fit:cover;" alt="">
{% else %}
<div style="width:26px;height:26px;border-radius:50%;background:#e0e4f0;display:flex;align-items:center;justify-content:center;font-size:0.72rem;color:#666;font-weight:700;">
{{ (s.user.display_name or s.user.email)[0].upper() }}
</div>
{% endif %}
<span style="font-size:0.83rem;color:#666;">{{ s.user.display_name or s.user.email.split('@')[0] }}</span>
</div>
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.35rem;font-size:0.95rem;">{{ s.label }}</div>
<div style="font-size:0.81rem;color:#888;display:flex;flex-wrap:wrap;gap:.3rem .65rem;">
<span>{{ s.session_date.strftime('%d %b %Y') }}</span>
{% if s.location_name %}<span>📍 {{ s.location_name }}</span>{% endif %}
{% if s.distance_m %}<span>{{ s.distance_m }} m</span>{% endif %}
{% if s.weather_cond %}<span>{{ s.weather_cond.replace('_', ' ').title() }}</span>{% endif %}
{% if s.weather_temp_c is not none %}<span>{{ s.weather_temp_c }}°C</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p style="color:#aaa;text-align:center;padding:3rem 0;">
No public sessions yet. Be the first to share one!
</p>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -2,12 +2,16 @@
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">Analysis Results</h1>
<div style="display:flex;gap:0.75rem;align-items:center;">
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
<a href="/">&larr; Upload another file</a>
{% if saved_analysis_id %}
<a href="{{ url_for('analyses.detail', analysis_id=saved_analysis_id) }}"
style="font-size:0.9rem;color:#1f77b4;">View saved report &rarr;</a>
{% endif %}
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
download="ballistic_report.pdf"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
Download PDF report
&#8659; Download PDF report
</a>
</div>
</div>

View File

@@ -0,0 +1,619 @@
{% extends "base.html" %}
{% block title %}Annotate photo — {{ session.label }}{% endblock %}
{% block content %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo;
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> &rsaquo;
Annotate
</div>
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
</div>
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
{# ── Canvas ── #}
<div style="flex:1;min-width:0;">
<canvas id="ann-canvas"
style="width:100%;border-radius:6px;cursor:crosshair;display:block;
box-shadow:0 2px 8px rgba(0,0,0,.18);background:#111;"></canvas>
</div>
{# ── Control panel ── #}
<div style="width:260px;flex-shrink:0;">
{# Step indicator #}
<div style="margin-bottom:1.25rem;">
<div id="si-0" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Reference line
</div>
<div id="si-1" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Point of Aim
</div>
<div id="si-2" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Points of Impact
</div>
<div id="si-3" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Results
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Shooting distance (always visible) #}
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="shoot-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="m">m</option>
<option value="yd">yd</option>
</select>
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Step 0: Reference line #}
<div id="panel-0" class="step-panel">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click <strong>two points</strong> on the image to draw a reference line — e.g. a known grid square or target diameter.
</p>
<div id="ref-dist-row" style="display:none;margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Real distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="ref-dist" min="0.1" step="0.1" placeholder="50"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="ref-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
</select>
</div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-next-0" disabled onclick="goStep(1)">Next →</button>
<button class="btn-ghost" onclick="resetRef()">Reset</button>
</div>
</div>
{# Step 1: POA #}
<div id="panel-1" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click your <strong>Point of Aim</strong> — the center of the target or wherever you were aiming.
</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-ghost" onclick="goStep(0)">← Back</button>
<button class="btn-ghost" onclick="poa=null;redraw();">Reset POA</button>
</div>
</div>
{# Step 2: POIs #}
<div id="panel-2" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click each <strong>bullet hole</strong>. Click an existing point to remove it.
</p>
<p id="poi-count" style="font-size:0.88rem;font-weight:600;color:#1a1a2e;margin-bottom:.75rem;">0 impacts</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-compute" disabled onclick="compute()">Compute →</button>
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
<button class="btn-ghost" onclick="undoPoi()">Undo last</button>
</div>
</div>
{# Step 3: Results #}
<div id="panel-3" class="step-panel" style="display:none;">
<div id="results-box" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save</button>
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
</div>
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
</div>
</div>{# end control panel #}
</div>
<style>
.btn-primary {
background: #1a1a2e; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
.btn-ghost {
background: #f0f4ff; color: #1a1a2e; border: 1px solid #c8d4f0; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.stat-row { display: flex; justify-content: space-between; font-size: 0.85rem;
padding: .3rem 0; border-bottom: 1px solid #f0f0f0; }
.stat-label { color: #666; }
.stat-val { font-weight: 600; color: #1a1a2e; }
.stat-section { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em;
color: #888; margin: .75rem 0 .35rem; }
</style>
<script>
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
// ── State ──────────────────────────────────────────────────────────────────
let step = 0;
// Reference: coords in natural image pixels (fractions stored on save)
let refP1 = null, refP2 = null, refMm = null;
let refClickStage = 0; // 0=waiting p1, 1=waiting p2, 2=done
let poa = null; // natural px
let pois = []; // natural px array
let stats = null;
let mousePos = null; // canvas px, for rubber-band
// ── Canvas / image ─────────────────────────────────────────────────────────
const canvas = document.getElementById('ann-canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = PHOTO_URL;
img.onload = () => { resizeCanvas(); loadExisting(); redraw(); };
function resizeCanvas() {
// Canvas internal size = natural image size (so all coords stay in nat px)
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
}
// Convert canvas mouse event → natural image pixels
function evToNat(e) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
}
// ── Mouse events ───────────────────────────────────────────────────────────
canvas.addEventListener('mousemove', e => {
if (step === 0 && refClickStage === 1) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
mousePos = { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
redraw();
}
});
canvas.addEventListener('click', e => {
const p = evToNat(e);
if (step === 0) handleRefClick(p);
else if (step === 1) handlePoaClick(p);
else if (step === 2) handlePoiClick(p);
});
canvas.addEventListener('mouseleave', () => { mousePos = null; redraw(); });
// ── Step 0: Reference line ─────────────────────────────────────────────────
function handleRefClick(p) {
if (refClickStage === 0) {
refP1 = p; refP2 = null; refClickStage = 1;
canvas.style.cursor = 'crosshair';
redraw();
} else if (refClickStage === 1) {
refP2 = p; refClickStage = 2; mousePos = null;
document.getElementById('ref-dist-row').style.display = '';
redraw();
updateNextBtn0();
}
}
function resetRef() {
refP1 = refP2 = null; refClickStage = 0; refMm = null; mousePos = null;
document.getElementById('ref-dist-row').style.display = 'none';
document.getElementById('ref-dist').value = '';
updateNextBtn0(); redraw();
}
document.getElementById('ref-dist').addEventListener('input', updateNextBtn0);
function updateNextBtn0() {
const v = parseFloat(document.getElementById('ref-dist').value);
document.getElementById('btn-next-0').disabled = !(refP1 && refP2 && v > 0);
}
// ── Step 1: POA ────────────────────────────────────────────────────────────
function handlePoaClick(p) {
poa = p; redraw();
// Auto-advance to step 2
goStep(2);
}
// ── Step 2: POIs ───────────────────────────────────────────────────────────
const HIT_RADIUS = 14; // canvas display px
function handlePoiClick(p) {
// Check if clicking near an existing POI to remove it
const r = canvas.getBoundingClientRect();
const dispScale = r.width / img.naturalWidth; // nat px → display px
for (let i = pois.length - 1; i >= 0; i--) {
const dx = (pois[i].x - p.x) * dispScale;
const dy = (pois[i].y - p.y) * dispScale;
if (Math.sqrt(dx*dx + dy*dy) < HIT_RADIUS) {
pois.splice(i, 1);
updatePoiUI(); redraw(); return;
}
}
pois.push(p);
updatePoiUI(); redraw();
}
function undoPoi() { if (pois.length) { pois.pop(); updatePoiUI(); redraw(); } }
function updatePoiUI() {
document.getElementById('poi-count').textContent = pois.length + ' impact' + (pois.length !== 1 ? 's' : '');
document.getElementById('btn-compute').disabled = pois.length < 1;
}
// ── Step navigation ────────────────────────────────────────────────────────
function goStep(n) {
// Validate before advancing
if (n === 1) {
const distVal = parseFloat(document.getElementById('ref-dist').value);
const unitSel = document.getElementById('ref-unit').value;
if (!(refP1 && refP2 && distVal > 0)) { alert('Please draw the reference line and enter its distance.'); return; }
refMm = toMm(distVal, unitSel);
}
step = n;
updateStepUI(); redraw();
}
function updateStepUI() {
// Panels
for (let i = 0; i <= 3; i++) {
document.getElementById('panel-' + i).style.display = (i === step) ? '' : 'none';
}
// Step indicators
const labels = ['Reference line', 'Point of Aim', 'Points of Impact', 'Results'];
for (let i = 0; i <= 3; i++) {
const el = document.getElementById('si-' + i);
const num = el.querySelector('.si-num');
if (i < step) {
el.style.background = '#e8f5e9'; el.style.color = '#27ae60';
num.style.background = '#27ae60'; num.style.color = '#fff';
num.textContent = '✓';
} else if (i === step) {
el.style.background = '#f0f4ff'; el.style.color = '#1a1a2e';
num.style.background = '#1a1a2e'; num.style.color = '#fff';
num.textContent = i + 1;
} else {
el.style.background = ''; el.style.color = '#aaa';
num.style.background = '#e0e0e0'; num.style.color = '#888';
num.textContent = i + 1;
}
}
// Cursor
canvas.style.cursor = (step <= 2) ? 'crosshair' : 'default';
}
// ── Computation ────────────────────────────────────────────────────────────
function dist2(a, b) { return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2); }
function toMm(val, unit) {
if (unit === 'cm') return val * 10;
if (unit === 'in') return val * 25.4;
return val;
}
function toMoa(sizeMm, distM) {
// true angular MOA
return Math.atan(sizeMm / (distM * 1000)) * (180 / Math.PI * 60);
}
function compute() {
const shootDistEl = document.getElementById('shoot-dist');
const shootUnitEl = document.getElementById('shoot-unit');
let distM = parseFloat(shootDistEl.value);
if (isNaN(distM) || distM <= 0) { alert('Enter a valid shooting distance first.'); shootDistEl.focus(); return; }
if (shootUnitEl.value === 'yd') distM *= 0.9144; // yards → metres
// Scale factor: pixels per mm
const refPxDist = dist2(refP1, refP2);
const pxPerMm = refPxDist / refMm;
// Convert POIs to mm relative to POA
const poisMm = pois.map(p => ({
x: (p.x - poa.x) / pxPerMm,
y: (p.y - poa.y) / pxPerMm,
}));
// Group centre
const cx = poisMm.reduce((s, p) => s + p.x, 0) / poisMm.length;
const cy = poisMm.reduce((s, p) => s + p.y, 0) / poisMm.length;
// Extreme Spread: max pairwise distance
let es = 0, esI = 0, esJ = 0;
for (let i = 0; i < poisMm.length; i++) {
for (let j = i + 1; j < poisMm.length; j++) {
const d = dist2(poisMm[i], poisMm[j]);
if (d > es) { es = d; esI = i; esJ = j; }
}
}
// Mean Radius: average distance from group centre
const mr = poisMm.reduce((s, p) => s + dist2(p, {x:cx,y:cy}), 0) / poisMm.length;
// POA → centre
const poaToCenter = dist2({x:0,y:0}, {x:cx,y:cy});
stats = {
shot_count: pois.length,
group_size_mm: es,
group_size_moa: distM > 0 ? toMoa(es, distM) : null,
mean_radius_mm: mr,
mean_radius_moa: distM > 0 ? toMoa(mr, distM) : null,
center_x_mm: cx, // + = right, - = left
center_y_mm: cy, // + = down, - = up
center_dist_mm: poaToCenter,
center_dist_moa: distM > 0 ? toMoa(poaToCenter, distM) : null,
shooting_distance_m: distM,
es_poi_indices: [esI, esJ],
};
renderResults();
goStep(3);
redraw();
}
function renderResults() {
if (!stats) return;
const f1 = v => (v != null ? v.toFixed(1) : '—');
const f2 = v => (v != null ? v.toFixed(2) : '—');
const sign = v => v >= 0 ? '+' : '';
const dir = (mm, axis) => {
if (axis === 'x') return mm > 0 ? 'right' : mm < 0 ? 'left' : 'center';
return mm > 0 ? 'low' : mm < 0 ? 'high' : 'center';
};
document.getElementById('results-box').innerHTML = `
<div class="stat-section">Group size</div>
<div class="stat-row"><span class="stat-label">Extreme Spread</span>
<span class="stat-val">${f2(stats.group_size_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.group_size_mm)} mm</span></div>
<div class="stat-section">Precision</div>
<div class="stat-row"><span class="stat-label">Mean Radius</span>
<span class="stat-val">${f2(stats.mean_radius_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.mean_radius_mm)} mm</span></div>
<div class="stat-section">Center vs POA</div>
<div class="stat-row"><span class="stat-label">Distance</span>
<span class="stat-val">${f2(stats.center_dist_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label">Horiz.</span>
<span class="stat-val">${f1(Math.abs(stats.center_x_mm))} mm ${dir(stats.center_x_mm,'x')}</span></div>
<div class="stat-row"><span class="stat-label">Vert.</span>
<span class="stat-val">${f1(Math.abs(stats.center_y_mm))} mm ${dir(stats.center_y_mm,'y')}</span></div>
<div class="stat-section">Info</div>
<div class="stat-row"><span class="stat-label">Shots</span>
<span class="stat-val">${stats.shot_count}</span></div>
<div class="stat-row"><span class="stat-label">@ distance</span>
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
`;
}
// ── Drawing ────────────────────────────────────────────────────────────────
const COLORS = {
ref: '#2196f3',
poa: '#e53935',
poi: '#1565c0',
center: '#ff9800',
es: '#9c27b0',
mr: '#00897b',
};
function lineW(px) {
// px in display pixels → natural pixels
const r = canvas.getBoundingClientRect();
return px * (img.naturalWidth / r.width);
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const lw = lineW(2);
const dotR = lineW(7);
// Reference line
if (refP1) {
const p2 = refClickStage === 1 && mousePos ? mousePos : refP2;
if (p2) {
ctx.save();
ctx.setLineDash([lineW(8), lineW(5)]);
ctx.strokeStyle = COLORS.ref; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.setLineDash([]);
// Endpoints
drawDot(refP1, dotR * 0.7, COLORS.ref);
if (refP2) {
drawDot(refP2, dotR * 0.7, COLORS.ref);
// Label
const mid = { x: (refP1.x + refP2.x) / 2, y: (refP1.y + refP2.y) / 2 };
drawLabel(mid, refMm ? refMm.toFixed(0) + ' mm' : '?', COLORS.ref, lineW(12));
}
ctx.restore();
} else {
drawDot(refP1, dotR * 0.7, COLORS.ref);
}
}
// POA
if (poa) {
const r = dotR * 1.3;
ctx.save();
ctx.strokeStyle = COLORS.poa; ctx.lineWidth = lw * 1.5;
// Circle
ctx.beginPath(); ctx.arc(poa.x, poa.y, r, 0, Math.PI * 2); ctx.stroke();
// Crosshair
ctx.beginPath();
ctx.moveTo(poa.x - r * 1.6, poa.y); ctx.lineTo(poa.x - r * 0.4, poa.y);
ctx.moveTo(poa.x + r * 0.4, poa.y); ctx.lineTo(poa.x + r * 1.6, poa.y);
ctx.moveTo(poa.x, poa.y - r * 1.6); ctx.lineTo(poa.x, poa.y - r * 0.4);
ctx.moveTo(poa.x, poa.y + r * 0.4); ctx.lineTo(poa.x, poa.y + r * 1.6);
ctx.stroke();
ctx.restore();
}
// Group overlay (if computed)
if (stats && poa) {
const pxPerMm = dist2(refP1, refP2) / refMm;
const cx = poa.x + stats.center_x_mm * pxPerMm;
const cy = poa.y + stats.center_y_mm * pxPerMm;
// Mean radius circle
const mrPx = stats.mean_radius_mm * pxPerMm;
ctx.save();
ctx.setLineDash([lineW(6), lineW(4)]);
ctx.strokeStyle = COLORS.mr; ctx.lineWidth = lw;
ctx.beginPath(); ctx.arc(cx, cy, mrPx, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// ES line between furthest pair
if (stats.shot_count >= 2) {
const [ei, ej] = stats.es_poi_indices;
ctx.save();
ctx.strokeStyle = COLORS.es; ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(pois[ei].x, pois[ei].y);
ctx.lineTo(pois[ej].x, pois[ej].y);
ctx.stroke();
ctx.restore();
}
// Group centre
drawDot({x:cx,y:cy}, dotR * 0.8, COLORS.center);
// Line POA → centre
if (dist2(poa, {x:cx,y:cy}) > dotR) {
ctx.save();
ctx.strokeStyle = COLORS.center; ctx.lineWidth = lw * 0.7;
ctx.setLineDash([lineW(4), lineW(3)]);
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
ctx.restore();
}
}
// POIs
pois.forEach((p, i) => {
drawDot(p, dotR, COLORS.poi);
drawLabel(p, String(i + 1), '#fff', dotR * 0.85);
});
}
function drawDot(p, r, color) {
ctx.save();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawLabel(p, text, color, size) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `bold ${size}px system-ui,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, p.x, p.y);
ctx.restore();
}
// ── Save ───────────────────────────────────────────────────────────────────
async function saveAnnotations() {
const btn = document.getElementById('btn-save');
const status = document.getElementById('save-status');
btn.disabled = true;
status.textContent = 'Saving…';
const refDistVal = parseFloat(document.getElementById('ref-dist').value);
const refUnitVal = document.getElementById('ref-unit').value;
// Store coords as fractions of natural image size for portability
function toFrac(p) { return { x: p.x / img.naturalWidth, y: p.y / img.naturalHeight }; }
const payload = {
ref: { p1: toFrac(refP1), p2: toFrac(refP2), dist_value: refDistVal, dist_unit: refUnitVal, dist_mm: refMm },
poa: toFrac(poa),
pois: pois.map(toFrac),
shooting_distance_m: stats.shooting_distance_m,
stats: stats,
};
try {
const resp = await fetch(SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
status.style.color = '#27ae60';
status.textContent = 'Saved!';
} else {
throw new Error('Server error');
}
} catch {
status.style.color = '#e53935';
status.textContent = 'Save failed.';
btn.disabled = false;
}
}
// ── Load existing annotations ──────────────────────────────────────────────
function loadExisting() {
if (!EXISTING || !EXISTING.ref) return;
const W = img.naturalWidth, H = img.naturalHeight;
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
refP1 = fromFrac(EXISTING.ref.p1);
refP2 = fromFrac(EXISTING.ref.p2);
refMm = EXISTING.ref.dist_mm;
refClickStage = 2;
document.getElementById('ref-dist').value = EXISTING.ref.dist_value || '';
document.getElementById('ref-unit').value = EXISTING.ref.dist_unit || 'mm';
document.getElementById('ref-dist-row').style.display = '';
updateNextBtn0();
if (EXISTING.poa) poa = fromFrac(EXISTING.poa);
if (EXISTING.pois) pois = EXISTING.pois.map(fromFrac);
if (EXISTING.shooting_distance_m) {
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
document.getElementById('shoot-unit').value = 'm';
}
if (EXISTING.stats) {
stats = EXISTING.stats;
renderResults();
goStep(3);
} else if (pois.length > 0) {
goStep(2); updatePoiUI();
} else if (poa) {
goStep(2);
} else {
goStep(0);
}
redraw();
}
// ── Init ───────────────────────────────────────────────────────────────────
updateStepUI();
updatePoiUI();
</script>
{% endblock %}

View File

@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}{{ session.label }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
{% if is_owner %}<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo; {% endif %}
{{ session.session_date.strftime('%d %b %Y') }}
{% if session.is_public %}
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">Public</span>
{% endif %}
</div>
<h1 style="margin:0;">{{ session.label }}</h1>
<div style="font-size:0.88rem;color:#666;margin-top:.4rem;">
by {{ session.user.display_name or session.user.email.split('@')[0] }}
</div>
</div>
{% if is_owner %}
<div style="display:flex;gap:.75rem;">
<a href="{{ url_for('sessions.edit', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=session.id) }}"
onsubmit="return confirm('Delete this session? This cannot be undone.');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
</div>
{% endif %}
</div>
{# ---- Stats cards ---- #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin-bottom:2rem;">
{% if session.location_name or session.distance_m %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Location</div>
{% if session.location_name %}<div style="font-weight:600;">{{ session.location_name }}</div>{% endif %}
{% if session.distance_m %}<div style="color:#555;font-size:0.9rem;">{{ session.distance_m }} m</div>{% endif %}
</div>
{% endif %}
{% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Weather</div>
{% if session.weather_cond %}<div style="font-weight:600;">{{ session.weather_cond.replace('_',' ').title() }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %}
{% if session.weather_wind_kph is not none %}&nbsp; {{ session.weather_wind_kph }} km/h wind{% endif %}
</div>
</div>
{% endif %}
{% if session.rifle %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Rifle / Handgun</div>
<div style="font-weight:600;">{{ session.rifle.name }}</div>
{% if session.rifle.caliber %}<div style="color:#555;font-size:0.9rem;">{{ session.rifle.caliber }}</div>{% endif %}
</div>
{% endif %}
{% if session.scope %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Scope</div>
<div style="font-weight:600;">{{ session.scope.name }}</div>
</div>
{% endif %}
{% if session.ammo_brand or session.ammo_weight_gr is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Ammo</div>
{% if session.ammo_brand %}<div style="font-weight:600;">{{ session.ammo_brand }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %}
{% if session.ammo_lot %}&nbsp; lot {{ session.ammo_lot }}{% endif %}
</div>
</div>
{% endif %}
</div>
{% if session.notes %}
<h2>Notes</h2>
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
{% endif %}
{# ---- Photos ---- #}
{% if session.photos or is_owner %}
<h2>Photos</h2>
{% if session.photos %}
<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
{% for photo in session.photos %}
<div>
<div style="position:relative;display:inline-block;">
<img src="{{ photo.photo_url }}"
data-gallery="session-{{ session.id }}"
data-src="{{ photo.photo_url }}"
data-caption="{{ photo.caption or '' }}"
alt="{{ photo.caption or '' }}"
style="height:180px;width:auto;border-radius:6px;object-fit:cover;display:block;">
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.delete_photo', session_id=session.id, photo_id=photo.id) }}"
onsubmit="return confirm('Delete this photo?');"
style="position:absolute;top:4px;right:4px;">
<button type="submit"
style="background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:3px;padding:.2rem .45rem;font-size:0.8rem;cursor:pointer;line-height:1.2;">
&#x2715;
</button>
</form>
{% endif %}
</div>
{% if photo.annotations and photo.annotations.stats %}
{% set s = photo.annotations.stats %}
<div style="font-size:0.78rem;background:#f0f4ff;color:#1a1a2e;padding:.2rem .45rem;border-radius:3px;margin-top:.3rem;font-weight:600;">
{{ s.shot_count }} shots &middot; {{ '%.2f'|format(s.group_size_moa) }} MOA ES
</div>
{% endif %}
{% if photo.caption %}
<div style="font-size:0.78rem;color:#666;margin-top:.25rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ photo.caption }}
</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;gap:.35rem;margin-top:.35rem;">
{% for label, deg in [('↺', -90), ('↻', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('sessions.rotate_photo_view', session_id=session.id, photo_id=photo.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.2rem .55rem;font-size:0.8rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
<a href="{{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) }}"
style="display:inline-block;margin-top:.4rem;padding:.3rem .75rem;border-radius:4px;font-size:0.82rem;text-decoration:none;
{% if photo.annotations and photo.annotations.stats %}
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
{% else %}
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if photo.annotations and photo.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
Measure group
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_photo', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Add photo</label>
<input type="file" name="photo" accept="image/*" required style="font-size:0.9rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Caption (optional)</label>
<input type="text" name="caption" placeholder="e.g. 300 m target"
style="padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Upload
</button>
</form>
{% endif %}
{% endif %}
{# ---- Analyses ---- #}
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
{% if analyses %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Title</th><th>Date</th><th>Shots</th><th>Groups</th><th>Mean speed</th></tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td>{{ "%.2f"|format(a.overall_stats.mean_speed) }} m/s</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No analyses yet.</p>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_csv', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Upload chronograph CSV</label>
<input type="file" name="csv_file" accept=".csv,text/csv" required style="font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Analyse &amp; link
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% set editing = session is not none %}
{% block title %}{{ 'Edit session' if editing else 'New session' }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ 'Edit session' if editing else 'Log a session' }}</h1>
{% set f = prefill or session %}
<form method="post"
action="{{ url_for('sessions.edit', session_id=session.id) if editing else url_for('sessions.new') }}"
style="max-width:580px;">
<h2>Basic info</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Date *</label>
<input type="date" name="session_date" required
value="{{ (f.session_date.isoformat() if f.session_date else '') if f else (today or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Distance (m)</label>
<input type="number" name="distance_m" min="1" max="5000"
value="{{ f.distance_m if f and f.distance_m else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="fl">Location</label>
<input type="text" name="location_name" value="{{ f.location_name if f else '' }}"
placeholder="e.g. Range name, city"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<h2>Weather</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Condition</label>
<select name="weather_cond"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{% for val, label in weather_conditions %}
<option value="{{ val }}" {% if f and f.weather_cond == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="fl">Temp (°C)</label>
<input type="number" name="weather_temp_c" step="0.1"
value="{{ f.weather_temp_c if f and f.weather_temp_c is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Wind (km/h)</label>
<input type="number" name="weather_wind_kph" step="0.1" min="0"
value="{{ f.weather_wind_kph if f and f.weather_wind_kph is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Equipment &amp; Ammo</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Rifle / Handgun</label>
<select name="rifle_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
{% for r in rifles %}
<option value="{{ r.id }}" {% if f and f.rifle_id == r.id %}selected{% endif %}>
{{ r.name }}{% if r.caliber %} ({{ r.caliber }}){% endif %}
</option>
{% endfor %}
</select>
{% if not rifles %}
<div style="font-size:0.78rem;color:#aaa;margin-top:.25rem;">
<a href="{{ url_for('equipment.new') }}">Add a rifle first</a>
</div>
{% endif %}
</div>
<div>
<label class="fl">Scope</label>
<select name="scope_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
{% for sc in scopes %}
<option value="{{ sc.id }}" {% if f and f.scope_id == sc.id %}selected{% endif %}>{{ sc.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Ammo brand</label>
<input type="text" name="ammo_brand" value="{{ f.ammo_brand if f else '' }}"
placeholder="e.g. Lapua, Federal"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Bullet weight (gr)</label>
<input type="number" name="ammo_weight_gr" step="0.1" min="0"
value="{{ f.ammo_weight_gr if f and f.ammo_weight_gr is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Lot number</label>
<input type="text" name="ammo_lot" value="{{ f.ammo_lot if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Notes &amp; Visibility</h2>
<div style="margin-bottom:1rem;">
<label class="fl">Notes</label>
<textarea name="notes" rows="4"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:flex;align-items:center;gap:0.6rem;cursor:pointer;font-size:0.95rem;">
<input type="checkbox" name="is_public" value="1"
{% if f and f.is_public %}checked{% endif %}
style="width:16px;height:16px;">
Make this session public (visible in the community feed)
</label>
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Log session' }}
</button>
<a href="{{ url_for('sessions.detail', session_id=session.id) if editing else url_for('sessions.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
</div>
</form>
<style>.fl { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Sessions — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Sessions</h1>
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
</a>
</div>
{% if sessions %}
<table>
<thead>
<tr>
<th>Session</th>
<th>Location</th>
<th>Visibility</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><a href="{{ url_for('sessions.detail', session_id=s.id) }}">{{ s.session_date.strftime('%d %b %Y') }}</a></td>
<td style="color:#666;font-size:0.88rem;">{{ s.location_name or '—' }}</td>
<td style="font-size:0.85rem;color:{% if s.is_public %}#27ae60{% else %}#aaa{% endif %};">
{{ 'Public' if s.is_public else 'Private' }}
</td>
<td style="white-space:nowrap;">
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">Edit</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('Delete this session?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🎯</div>
<p style="margin-bottom:1rem;">No sessions recorded yet.</p>
<a href="{{ url_for('sessions.new') }}">Log your first session</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Ballistic Analyzer</h1>
<h1>New Analysis</h1>
{% if error %}
<div class="error">{{ error }}</div>