In the JS UI, turn each category of Activity into a collapsible section to save space #147

This commit is contained in:
Ian Renton
2026-09-18 15:45:23 +01:00
parent 81cd686a00
commit bf9b4ac68c
7 changed files with 126 additions and 51 deletions
+20 -20
View File
@@ -4,7 +4,7 @@ from data.activity import Activity
from data.band import Band
# General software
SOFTWARE_VERSION = "2.1"
SOFTWARE_VERSION = "2.2-pre"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
@@ -13,10 +13,10 @@ HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}"
# Activities
ACTIVITIES = [
Activity(
name="AMSAT",
name="Satellite",
comment_names=[],
description="Amateur Radio Satellites",
sig_type=ActivityType.WORLDWIDE,
description="Amateur Radio Satellite",
sig_type=ActivityType.TRADITIONAL,
icon="fa-satellite",
refs_globally_unique=False,
),
@@ -24,7 +24,7 @@ ACTIVITIES = [
name="EME",
comment_names=[],
description="Moonbounce",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.TRADITIONAL,
icon="fa-moon",
refs_globally_unique=False,
),
@@ -32,7 +32,7 @@ ACTIVITIES = [
name="POTA",
comment_names=["POTA"],
description="Parks on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.PARK,
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
icon="fa-tree",
@@ -42,7 +42,7 @@ ACTIVITIES = [
name="SOTA",
comment_names=["SOTA"],
description="Summits on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-mountain-sun",
@@ -52,7 +52,7 @@ ACTIVITIES = [
name="WWFF",
comment_names=["WWFF"],
description="World Wide Flora & Fauna",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.PARK,
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
icon="fa-seedling",
@@ -62,7 +62,7 @@ ACTIVITIES = [
name="GMA",
comment_names=["GMA"],
description="Global Mountain Activity",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-person-hiking",
@@ -72,7 +72,7 @@ ACTIVITIES = [
name="WWBOTA",
comment_names=["WWBOTA", "BOTA"],
description="Worldwide Bunkers on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.BUNKER,
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
icon="fa-radiation",
@@ -82,7 +82,7 @@ ACTIVITIES = [
name="HEMA",
comment_names=["HEMA"],
description="HuMPs Excluding Marilyns Award",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
icon="fa-mound",
@@ -92,7 +92,7 @@ ACTIVITIES = [
name="IOTA",
comment_names=["IOTA"],
description="Islands on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.ISLAND,
ref_regex=r"[A-Z]{2}\-\d{3}",
icon="fa-book-atlas",
@@ -102,7 +102,7 @@ ACTIVITIES = [
name="GMA Islands",
comment_names=[],
description="Global Mountain Activity - Islands",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.ISLAND,
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
icon="fa-person-hiking",
@@ -112,7 +112,7 @@ ACTIVITIES = [
name="ARLHS",
comment_names=["ARLHS"],
description="Amateur Radio Lighthouse Society",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
icon="fa-house-flood-water",
@@ -142,7 +142,7 @@ ACTIVITIES = [
name="SIOTA",
comment_names=["SIOTA"],
description="Silos on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.SILO,
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
icon="fa-wheat-awn",
@@ -152,7 +152,7 @@ ACTIVITIES = [
name="WCA",
comment_names=["WCA"],
description="World Castles Award",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.CASTLE,
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
icon="fa-chess-rook",
@@ -184,7 +184,7 @@ ACTIVITIES = [
name="BOTA",
comment_names=[],
description="Beaches on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.BEACH,
icon="fa-umbrella-beach",
refs_globally_unique=False,
@@ -215,7 +215,7 @@ ACTIVITIES = [
name="LLOTA",
comment_names=["LLOTA"],
description="Lagos y Lagunas on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.LAKE,
ref_regex=r"LL[A-Z]{2}\-\d{4}",
icon="fa-water",
@@ -225,7 +225,7 @@ ACTIVITIES = [
name="Towers",
comment_names=["TOTA"],
description="Towers on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.TOWER,
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
icon="fa-tower-observation",
@@ -235,7 +235,7 @@ ACTIVITIES = [
name="Tiles",
comment_names=[],
description="Tiles on the Air",
sig_type=ActivityType.WORLDWIDE,
sig_type=ActivityType.ADVENTURE,
ref_type=ActivityRefType.GRID,
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
icon="fa-square",
+2 -1
View File
@@ -124,7 +124,8 @@ class AlertType(str, Enum):
class ActivityType(str, Enum):
"""Type of an activity. Used to group them in the web UI."""
WORLDWIDE = "WORLDWIDE"
TRADITIONAL = "TRADITIONAL"
ADVENTURE = "ADVENTURE"
REGIONAL = "REGIONAL"
EVENT = "EVENT"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "spothole"
version = "2.1"
version = "2.2-pre"
authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" },
]
+7 -6
View File
@@ -917,10 +917,11 @@ components:
ActivityType:
type: string
enum:
- WORLDWIDE
- TRADITIONAL
- ADVENTURE
- REGIONAL
- EVENT
example: WORLDWIDE
example: TRADITIONAL
ActivityNameIncludingNoSig:
oneOf:
@@ -1597,16 +1598,16 @@ components:
sig_type:
type: boolean
description: >
Whether the activity is worldwide, regional, or for a specific event. Generally for Spothole's own
internal use, clients probably won't need this. Used to group them in the web UI. Still named
"sig_type" in the API for backwards compatibility.
Whether the activity is traditional (e.g. EME), adventure (e.g. POTA), regional (e.g. WAB), or for a
specific event (e.g. MOTA). Generally for Spothole's own internal use, clients probably won't need this.
Used to group them in the web UI. Still named "sig_type" in the API for backwards compatibility.
$ref: "#/components/schemas/ActivityType"
comment_names:
type: array
description: >
Names by which this activity may be referred to in cluster spot comments. Most activities have a
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
the name is ambiguous with other activities, such as Tiles and Toilets on the Air) or multiple
the name is ambiguous with other activities, such as "TOTA" for Tiles and Toilets on the Air) or multiple
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
items:
type: string
+45
View File
@@ -279,6 +279,51 @@ div#map {
}
/* COLLAPSIBLE FILTER SECTIONS (e.g. Activities panel) */
ul.filter-section-list {
margin-bottom: 0;
}
button.btn-toggle {
display: block;
width: 100%;
padding: .25rem 0;
font-weight: 600;
text-align: left;
color: var(--bs-emphasis-color);
background-color: transparent;
border: 0;
}
button.btn-toggle:hover,
button.btn-toggle:focus {
background-color: transparent;
box-shadow: none;
}
button.btn-toggle::before {
display: inline-block;
width: 1.25em;
line-height: 0;
content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
transition: transform .35s ease;
transform-origin: .5em 50%;
}
[data-bs-theme="dark"] button.btn-toggle::before {
content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28255,255,255,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
}
button.btn-toggle[aria-expanded="true"]::before {
transform: rotate(90deg);
}
div.filter-section-body {
padding-left: 1.25em;
}
/* BANDS PANEL */
div#bands-container {
+50 -22
View File
@@ -38,33 +38,61 @@ function setHamHFBandToggles() {
filtersUpdated();
}
// Generate a collapsable section for the activities filter card, and append it to the given list. The section is
// expanded by default if isExpanded is true.
function appendActivityFilterSection($list, sectionId, heading, isExapanded, buildBody) {
const collapseId = `activity-options-${sectionId}-collapse`;
const $li = $('<li class="mb-1">');
$li.append(`<button class="btn-toggle${isExapanded ? '' : ' collapsed'}" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isExapanded ? 'true' : 'false'}" aria-controls="${collapseId}">${heading}</button>`);
const $body = $(`<div class="collapse filter-section-body${isExapanded ? ' show' : ''}" id="${collapseId}">`);
buildBody($body);
$li.append($body);
$list.append($li);
}
// Generate activities filter card. This one is also a special case.
function generateActivitiesMultiToggleFilterCard(activity_options) {
const $grid1 = $('<div class="row row-cols-2 g-1 mb-1">');
$("#sig-options").append('<strong>Worldwide</strong>');
activity_options.filter(o => o["sig_type"] === "WORLDWIDE").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid1.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
const $list = $('<ul class="list-unstyled filter-section-list ps-0">');
appendActivityFilterSection($list, 'traditional', 'Traditional', true, $body => {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.filter(o => o["sig_type"] === "TRADITIONAL").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$body.append($grid);
});
$("#sig-options").append($grid1);
const $grid2 = $('<div class="row row-cols-2 g-1 mb-1">');
$("#sig-options").append('<strong>Regional</strong>');
activity_options.filter(o => o["sig_type"] === "REGIONAL").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid2.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
appendActivityFilterSection($list, 'adventure', 'Adventure', true, $body => {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.filter(o => o["sig_type"] === "ADVENTURE").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
});
$("#sig-options").append($grid2);
const $grid3 = $('<div class="row row-cols-2 g-1 mb-1">');
$("#sig-options").append('<strong>Event</strong>');
activity_options.filter(o => o["sig_type"] === "EVENT").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid3.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
appendActivityFilterSection($list, 'regional', 'Regional', false, $body => {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.filter(o => o["sig_type"] === "REGIONAL").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
});
$("#sig-options").append($grid3);
$("#sig-options").append('<strong>Other</strong>');
// Bonus "NO_SIG" / "General DX" option
$("#sig-options").append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
appendActivityFilterSection($list, 'event', 'Event', false, $body => {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.filter(o => o["sig_type"] === "EVENT").forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
});
$("#activity-options").append($list);
$("#activity-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
}
// Generate modes filter card. This one is also a special case.
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Activities</h5>
<div id="sig-options" class="card-text spothole-card-text"></div>
<div id="activity-options" class="card-text spothole-card-text"></div>
</div>
</div>