610 lines
27 KiB
HTML
610 lines
27 KiB
HTML
{% 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> › {% 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.') | e }}');">
|
|
<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 %} {{ 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;">{{ _('Ammunition') }}</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 %} lot {{ session.ammo_lot }}{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if session.shooting_position %}
|
|
<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;">{{ _('Position') }}</div>
|
|
<div style="font-weight:600;">{{ session.shooting_position.replace('_',' ').title() }}</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
|
|
{% if session.notes %}
|
|
<h2>{{ _('Notes') }}</h2>
|
|
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
|
|
{% endif %}
|
|
|
|
{# ---- PRS Stages ---- #}
|
|
{% if session.session_type == 'prs' %}
|
|
<h2>{{ _('PRS Stages') }}</h2>
|
|
|
|
{% set stages = session.prs_stages or [] %}
|
|
|
|
{# Stage table (read + edit combined) #}
|
|
<div style="overflow-x:auto;margin-bottom:1rem;">
|
|
<table id="prs-table" style="min-width:900px;font-size:0.88rem;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:36px;">N°</th>
|
|
<th>{{ _('Name') }}</th>
|
|
<th style="width:80px;">Dist. (m)</th>
|
|
<th style="width:75px;">{{ _('Time (s)') }}</th>
|
|
<th style="width:110px;">Position</th>
|
|
<th style="width:110px;">{{ _('Elevation Dope') }}</th>
|
|
<th style="width:110px;">{{ _('Windage Dope') }}</th>
|
|
<th style="width:80px;">{{ _('Hits/Poss.') }}</th>
|
|
<th>{{ _('Notes') }}</th>
|
|
{% if is_owner %}<th style="width:36px;"></th>{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody id="prs-tbody">
|
|
{% for st in stages %}
|
|
<tr data-idx="{{ loop.index0 }}">
|
|
<td style="text-align:center;font-weight:600;">{{ st.num or loop.index }}</td>
|
|
<td>{{ st.name or '' }}</td>
|
|
<td style="text-align:center;">{{ st.distance_m or '' }}</td>
|
|
<td style="text-align:center;">{{ st.time_s or '' }}</td>
|
|
<td>{{ st.position or '' }}</td>
|
|
<td style="text-align:center;">{{ st.dope_elevation or '' }}</td>
|
|
<td style="text-align:center;">{{ st.dope_windage or '' }}</td>
|
|
<td style="text-align:center;">
|
|
{% if st.hits is not none %}{{ st.hits }}{% endif %}{% if st.possible %}/{{ st.possible }}{% endif %}
|
|
</td>
|
|
<td>{{ st.notes or '' }}</td>
|
|
{% if is_owner %}<td></td>{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{% if is_owner %}
|
|
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-bottom:1.5rem;">
|
|
<button onclick="enterEditMode()" id="btn-edit-stages"
|
|
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
|
{{ _('✏ Edit stages') }}
|
|
</button>
|
|
<a href="{{ url_for('sessions.dope_card', session_id=session.id) }}" target="_blank"
|
|
style="background:#27ae60;color:#fff;padding:.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
|
|
{{ _('📄 Generate dope card (PDF)') }}
|
|
</a>
|
|
</div>
|
|
|
|
{# Hidden form used by JS to save stages #}
|
|
<form method="post" action="{{ url_for('sessions.save_stages', session_id=session.id) }}" id="stages-form" style="display:none;">
|
|
<input type="hidden" name="stages_json" id="stages-json-input">
|
|
</form>
|
|
|
|
<script>
|
|
(function () {
|
|
var PRS_POS = [
|
|
["prone", "Couché"],
|
|
["standing", "Debout"],
|
|
["kneeling", "Agenouillé"],
|
|
["sitting", "Assis"],
|
|
["barricade", "Barricade"],
|
|
["rooftop", "Toit"],
|
|
["unknown", "Variable"],
|
|
];
|
|
|
|
var stages = {{ (session.prs_stages or []) | tojson }};
|
|
|
|
function posLabel(slug) {
|
|
var m = PRS_POS.find(function(p) { return p[0] === slug; });
|
|
return m ? m[1] : slug;
|
|
}
|
|
|
|
function posSelect(val) {
|
|
var s = document.createElement('select');
|
|
s.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem;border:1px solid #ccc;border-radius:3px;';
|
|
PRS_POS.forEach(function(p) {
|
|
var o = document.createElement('option');
|
|
o.value = p[0]; o.textContent = p[1];
|
|
if (p[0] === val) o.selected = true;
|
|
s.appendChild(o);
|
|
});
|
|
return s;
|
|
}
|
|
|
|
function inp(val, type, placeholder) {
|
|
var i = document.createElement('input');
|
|
i.type = type || 'text';
|
|
i.value = val == null ? '' : val;
|
|
if (placeholder) i.placeholder = placeholder;
|
|
i.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem .4rem;border:1px solid #ccc;border-radius:3px;box-sizing:border-box;';
|
|
return i;
|
|
}
|
|
|
|
function collectStages() {
|
|
var rows = document.querySelectorAll('#prs-tbody tr');
|
|
var result = [];
|
|
rows.forEach(function(tr, i) {
|
|
var cells = tr.querySelectorAll('td');
|
|
if (!cells.length) return;
|
|
function val(idx) {
|
|
var el = cells[idx];
|
|
if (!el) return '';
|
|
var input = el.querySelector('input,select,textarea');
|
|
return input ? (input.value || '') : (el.textContent.trim() || '');
|
|
}
|
|
result.push({
|
|
num: parseInt(val(0)) || i + 1,
|
|
name: val(1),
|
|
distance_m: parseInt(val(2)) || null,
|
|
time_s: parseInt(val(3)) || null,
|
|
position: val(4),
|
|
dope_elevation: val(5),
|
|
dope_windage: val(6),
|
|
hits: val(7).split('/')[0] !== '' ? parseInt(val(7).split('/')[0]) : null,
|
|
possible: val(7).split('/')[1] !== undefined ? (parseInt(val(7).split('/')[1]) || null) : null,
|
|
notes: val(8),
|
|
});
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function addRow(st) {
|
|
var tbody = document.getElementById('prs-tbody');
|
|
var idx = tbody.rows.length;
|
|
st = st || { num: idx + 1 };
|
|
var tr = document.createElement('tr');
|
|
tr.dataset.idx = idx;
|
|
|
|
var hitsVal = '';
|
|
if (st.hits != null) hitsVal = st.hits;
|
|
if (st.possible != null) hitsVal = (hitsVal !== '' ? hitsVal : '') + '/' + st.possible;
|
|
|
|
[
|
|
inp(st.num, 'number'),
|
|
inp(st.name),
|
|
inp(st.distance_m, 'number'),
|
|
inp(st.time_s, 'number'),
|
|
posSelect(st.position || ''),
|
|
inp(st.dope_elevation, 'text', 'ex : 3.5 MOA'),
|
|
inp(st.dope_windage, 'text', 'ex : 0.5 MOA D'),
|
|
inp(hitsVal, 'text', 'x/y'),
|
|
inp(st.notes),
|
|
].forEach(function(el) {
|
|
var td = document.createElement('td');
|
|
td.appendChild(el);
|
|
tr.appendChild(td);
|
|
});
|
|
|
|
// Remove button
|
|
var tdDel = document.createElement('td');
|
|
tdDel.style.textAlign = 'center';
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.textContent = '✕';
|
|
btn.style.cssText = 'background:none;border:none;color:#c0392b;cursor:pointer;font-size:1rem;padding:0 .3rem;';
|
|
btn.onclick = function() { tr.remove(); renumber(); };
|
|
tdDel.appendChild(btn);
|
|
tr.appendChild(tdDel);
|
|
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
function renumber() {
|
|
document.querySelectorAll('#prs-tbody tr').forEach(function(tr, i) {
|
|
var first = tr.querySelector('td:first-child input');
|
|
if (first && first.type === 'number') first.value = i + 1;
|
|
});
|
|
}
|
|
|
|
window.enterEditMode = function () {
|
|
// Replace read-only rows with editable rows
|
|
var tbody = document.getElementById('prs-tbody');
|
|
tbody.innerHTML = '';
|
|
stages.forEach(function(st) { addRow(st); });
|
|
if (!stages.length) addRow(null);
|
|
|
|
document.getElementById('btn-edit-stages').style.display = 'none';
|
|
|
|
var STR_ADD_STAGE = '{{ _("+ Add stage") | e }}';
|
|
var STR_SAVE = '{{ _("💾 Save") | e }}';
|
|
var STR_CANCEL = '{{ _("Cancel") | e }}';
|
|
|
|
// Add / Save / Cancel buttons
|
|
var bar = document.createElement('div');
|
|
bar.id = 'edit-bar';
|
|
bar.style.cssText = 'display:flex;gap:.6rem;flex-wrap:wrap;margin:.75rem 0;';
|
|
bar.innerHTML =
|
|
'<button type="button" onclick="addRow(null);renumber();" ' +
|
|
'style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
|
|
STR_ADD_STAGE + '</button>' +
|
|
'<button type="button" onclick="saveStages()" ' +
|
|
'style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
|
|
STR_SAVE + '</button>' +
|
|
'<button type="button" onclick="cancelEdit()" ' +
|
|
'style="background:none;color:#666;border:none;font-size:0.88rem;cursor:pointer;padding:.4rem .5rem;">' + STR_CANCEL + '</button>';
|
|
document.getElementById('prs-table').after(bar);
|
|
};
|
|
|
|
window.saveStages = function () {
|
|
var data = collectStages();
|
|
document.getElementById('stages-json-input').value = JSON.stringify(data);
|
|
document.getElementById('stages-form').submit();
|
|
};
|
|
|
|
window.cancelEdit = function () {
|
|
location.reload();
|
|
};
|
|
|
|
window.addRow = addRow;
|
|
window.renumber = renumber;
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
|
|
{% endif %}{# end prs #}
|
|
|
|
{# ---- 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?') | e }}');"
|
|
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;">
|
|
✕
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% if photo.annotations and photo.annotations.stats %}
|
|
{% set s = photo.annotations.stats %}
|
|
{% set a = photo.annotations %}
|
|
<div style="margin-top:.5rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.6rem .75rem;font-size:0.8rem;min-width:180px;">
|
|
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.35rem;font-size:0.82rem;">
|
|
{{ s.shot_count }} shot{{ 's' if s.shot_count != 1 }}
|
|
· {{ s.shooting_distance_m | int }} m
|
|
{% if a.clean_barrel %}<span style="background:#e8f5e9;color:#27ae60;border-radius:3px;padding:.05rem .3rem;margin-left:.3rem;">{{ _('clean barrel') }}</span>{% endif %}
|
|
</div>
|
|
<table style="width:100%;border-collapse:collapse;margin:0;">
|
|
<tr>
|
|
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Group ES') }}</td>
|
|
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
|
|
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.group_size_mm) }} mm</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Mean Radius') }}</td>
|
|
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
|
|
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.mean_radius_mm) }} mm</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Centre') }}</td>
|
|
<td colspan="2" style="text-align:right;padding:.15rem 0;border:none;">
|
|
{{ '%.2f'|format(s.center_dist_moa) }} MOA
|
|
<span style="color:#888;">({{ '%.1f'|format(s.center_x_mm | abs) }} mm {{ 'R' if s.center_x_mm > 0 else 'L' if s.center_x_mm < 0 else '' }}, {{ '%.1f'|format(s.center_y_mm | abs) }} mm {{ _('low') if s.center_y_mm > 0 else _('high') if s.center_y_mm < 0 else '' }})</span>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</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 %}✓{% else %}▶{% 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_display %}
|
|
{% for a, groups_display, overview_chart in analyses_display %}
|
|
<details open style="border:1px solid #e0e0e0;border-radius:8px;margin-bottom:1.25rem;overflow:hidden;">
|
|
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
|
|
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
|
|
<span style="font-weight:700;font-size:1rem;color:#1a1a2e;flex:1;">{{ a.title }}</span>
|
|
<span style="font-size:0.82rem;color:#666;">{{ a.shot_count }} {{ _('shots') }} · {{ a.group_count }} {{ _('group') if a.group_count == 1 else _('groups') }}</span>
|
|
<span style="font-size:0.82rem;color:#888;">{{ "%.2f"|format(a.overall_stats.mean_speed) }} {{ _('m/s mean') }}</span>
|
|
<span style="font-size:0.78rem;color:#aaa;">{{ a.created_at.strftime('%d %b %Y') }}</span>
|
|
</summary>
|
|
|
|
<div style="padding:1.25rem 1.5rem;">
|
|
|
|
{# --- Owner action bar: rename / standalone link / PDF / delete --- #}
|
|
{% if is_owner %}
|
|
<div style="display:flex;flex-wrap:wrap;gap:.6rem;align-items:center;margin-bottom:1.25rem;">
|
|
|
|
{# Rename inline form #}
|
|
<details style="display:inline;">
|
|
<summary style="display:inline-block;padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;
|
|
border:1px solid #c8d4f0;border-radius:4px;font-size:0.82rem;cursor:pointer;list-style:none;">
|
|
{{ _('✏ Rename') }}
|
|
</summary>
|
|
<form method="post" action="{{ url_for('analyses.rename', analysis_id=a.id) }}"
|
|
style="display:flex;gap:.5rem;align-items:center;margin-top:.5rem;">
|
|
<input type="text" name="title" value="{{ a.title }}" required
|
|
style="padding:.4rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;min-width:220px;">
|
|
<button type="submit"
|
|
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.85rem;cursor:pointer;">
|
|
{{ _('Save') }}
|
|
</button>
|
|
</form>
|
|
</details>
|
|
|
|
<a href="{{ url_for('analyses.detail', analysis_id=a.id) }}"
|
|
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
|
|
border-radius:4px;font-size:0.82rem;text-decoration:none;">
|
|
{{ _('Full view') }}
|
|
</a>
|
|
|
|
{% if a.pdf_path %}
|
|
<a href="{{ url_for('analyses.download_pdf', analysis_id=a.id) }}"
|
|
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
|
|
border-radius:4px;font-size:0.82rem;text-decoration:none;">
|
|
↧ PDF
|
|
</a>
|
|
{% endif %}
|
|
|
|
<form method="post" action="{{ url_for('analyses.delete', analysis_id=a.id) }}"
|
|
onsubmit="return confirm('{{ _('Delete this analysis? This cannot be undone.') | e }}');"
|
|
style="display:inline;">
|
|
<button type="submit"
|
|
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;
|
|
padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
|
|
{{ _('Delete') }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# --- Overview chart --- #}
|
|
{% if overview_chart %}
|
|
<img src="data:image/png;base64,{{ overview_chart }}" class="chart-img" alt="Overview chart"
|
|
style="margin-bottom:1.25rem;">
|
|
{% endif %}
|
|
|
|
{# --- Per-group cards --- #}
|
|
{% if groups_display %}
|
|
{% for gs, chart in groups_display %}
|
|
<div class="group-section" style="margin-bottom:1rem;">
|
|
<div class="group-meta">
|
|
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
|
|
· {{ gs.count }} {{ _('shots') }}
|
|
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
|
{% if gs.std_speed is not none %} · {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
|
|
· {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
|
|
</div>
|
|
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart">
|
|
|
|
{% if gs.note %}
|
|
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
|
|
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
|
|
{% endif %}
|
|
|
|
{% if is_owner %}
|
|
<details style="margin-top:.75rem;">
|
|
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
|
|
✎ {{ _('Note') }}
|
|
</summary>
|
|
<form method="post"
|
|
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
|
|
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
|
<textarea name="note" rows="2"
|
|
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:520px;">{{ gs.note or '' }}</textarea>
|
|
<div>
|
|
<button type="submit"
|
|
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
|
|
{{ _('Save note') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
{% elif groups_display is none %}
|
|
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
|
{% endif %}
|
|
|
|
{# --- Re-group panel (owner only) --- #}
|
|
{% if is_owner %}
|
|
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
|
|
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
|
|
⚙ {{ _('Re-group settings') }}
|
|
</summary>
|
|
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
|
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
|
|
<div>
|
|
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
|
{{ _('Outlier factor:') }}
|
|
<span id="factor_val_{{ a.id }}">{{ a.grouping_outlier_factor or 5 }}</span>
|
|
</label>
|
|
<input type="range" name="outlier_factor" min="1" max="20" step="0.5"
|
|
value="{{ a.grouping_outlier_factor or 5 }}"
|
|
style="width:100%;"
|
|
oninput="document.getElementById('factor_val_{{ a.id }}').textContent=this.value">
|
|
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa;">
|
|
<span>{{ _('1 (fine)') }}</span><span>{{ _('20 (coarse)') }}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
|
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
|
|
</label>
|
|
<input type="text" name="manual_splits"
|
|
value="{{ (a.grouping_manual_splits | tojson) if a.grouping_manual_splits else '' }}"
|
|
placeholder="e.g. [5, 12]"
|
|
style="width:100%;padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
|
<div style="font-size:0.75rem;color:#aaa;margin-top:.2rem;">{{ _('Shot positions (0-based) where a new group should always begin.') }}</div>
|
|
</div>
|
|
<div>
|
|
<button type="submit"
|
|
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
|
|
{{ _('Apply') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</details>
|
|
{% endfor %}
|
|
{% 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 & link') }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% endblock %}
|