Add visitor registration app with SQLite, admin tools and Docker deployment

This commit is contained in:
Finn Skov Henriksen 2026-10-02 21:39:38 +02:00
commit 150e0a00b5
27 changed files with 8700 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.env
.env.*
appsettings.Development.json
bin
obj
node_modules
data
*.db
*.db-shm
*.db-wal

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
Database__Path=data/visitors.db
Admin__Username=admin
Admin__Password=change-me
APP_BIND=127.0.0.1
APP_PORT=5187

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
*.pdf binary
*.png binary
*.jpg binary

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
bin/
obj/
node_modules/
appsettings.Development.json
*.user
*.suo
data/
*.db
*.db-shm
*.db-wal
.env

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY SkydesportensDag.csproj ./
RUN dotnet restore --source https://api.nuget.org/v3/index.json
COPY Program.cs appsettings.json ./
COPY wwwroot/ ./wwwroot/
RUN dotnet publish --no-restore -c Release -o /out /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /out/ ./
RUN mkdir -p /app/data && chown app:app /app/data
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_HTTP_PORTS=8080
ENV Database__Path=/app/data/visitors.db
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "SkydesportensDag.dll"]

249
Program.cs Normal file
View File

@ -0,0 +1,249 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Data.Sqlite;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("Basic")
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("Basic", null);
builder.Services.AddAuthorization();
builder.Services.AddSingleton<Database>();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
await app.Services.GetRequiredService<Database>().InitializeAsync();
app.MapPost("/api/visitors", async (VisitorInput input, Database database) =>
{
var errors = Validate(input);
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
await using var connection = await database.OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
insert into visitors (name, street_address, postal_code, city, birthday, email)
values ($name, $street, $postal, $city, $birthday, $email)
""";
command.Parameters.AddWithValue("$name", input.Name.Trim());
command.Parameters.AddWithValue("$street", input.StreetAddress.Trim());
command.Parameters.AddWithValue("$postal", input.PostalCode.Trim());
command.Parameters.AddWithValue("$city", input.City.Trim());
command.Parameters.AddWithValue("$birthday", DateOnly.Parse(input.Birthday).ToString("yyyy-MM-dd"));
command.Parameters.AddWithValue("$email", input.Email.Trim().ToLowerInvariant());
await command.ExecuteNonQueryAsync();
return Results.Created("/api/visitors", null);
});
app.MapGet("/api/visitors", async (Database database) =>
{
var visitors = new List<VisitorListItem>();
await using var connection = await database.OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
select id, name, street_address, postal_code, city, birthday, email, created_at
from visitors
order by created_at desc, id desc
""";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
visitors.Add(new VisitorListItem(
reader.GetInt64(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
DateOnly.Parse(reader.GetString(5)),
reader.GetString(6),
DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc)
));
}
return Results.Ok(visitors);
}).RequireAuthorization();
app.MapDelete("/api/visitors/{id:long}", async (long id, Database database) =>
{
await using var connection = await database.OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = "delete from visitors where id = $id";
command.Parameters.AddWithValue("$id", id);
var deleted = await command.ExecuteNonQueryAsync();
return deleted == 0 ? Results.NotFound() : Results.NoContent();
}).RequireAuthorization();
app.MapGet("/admin", (IWebHostEnvironment environment) =>
Results.File(Path.Combine(environment.WebRootPath, "admin.html"), "text/html"));
app.MapFallbackToFile("index.html");
app.Run();
static Dictionary<string, string[]> Validate(VisitorInput input)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(input.Name))
{
errors["name"] = ["Navn mangler."];
}
if (string.IsNullOrWhiteSpace(input.StreetAddress))
{
errors["streetAddress"] = ["Vejnavn og husnummer mangler."];
}
if (string.IsNullOrWhiteSpace(input.PostalCode) || input.PostalCode.Trim().Length != 4 || !input.PostalCode.All(char.IsDigit))
{
errors["postalCode"] = ["Postnummer skal være 4 cifre."];
}
if (string.IsNullOrWhiteSpace(input.City))
{
errors["city"] = ["Postby mangler."];
}
if (string.IsNullOrWhiteSpace(input.Birthday) || !DateOnly.TryParse(input.Birthday, out _))
{
errors["birthday"] = ["Fødselsdag mangler eller er ugyldig."];
}
if (string.IsNullOrWhiteSpace(input.Email) || !input.Email.Contains('@') || !input.Email.Contains('.'))
{
errors["email"] = ["Email mangler eller er ugyldig."];
}
return errors;
}
public sealed record VisitorInput(string Name, string StreetAddress, string PostalCode, string City, string Birthday, string Email);
public sealed record VisitorListItem(long Id, string Name, string StreetAddress, string PostalCode, string City, DateOnly Birthday, string Email, DateTime CreatedAt);
public sealed class Database
{
private readonly string _connectionString;
public Database(IConfiguration configuration, IWebHostEnvironment environment)
{
var path = configuration["Database:Path"] ?? "data/visitors.db";
var fullPath = Path.GetFullPath(path, environment.ContentRootPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
_connectionString = new SqliteConnectionStringBuilder { DataSource = fullPath }.ToString();
}
public async Task<SqliteConnection> OpenConnectionAsync()
{
var connection = new SqliteConnection(_connectionString);
try
{
await connection.OpenAsync();
return connection;
}
catch
{
await connection.DisposeAsync();
throw;
}
}
public async Task InitializeAsync()
{
await using var connection = await OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
create table if not exists visitors (
id integer primary key autoincrement,
name text not null,
street_address text not null,
postal_code text not null,
city text not null,
birthday text not null,
email text not null,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
create index if not exists visitors_created_at_idx on visitors (created_at desc);
""";
await command.ExecuteNonQueryAsync();
}
}
public sealed class BasicAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var expectedUser = configuration["Admin:Username"] ?? "admin";
var expectedPassword = configuration["Admin:Password"];
if (string.IsNullOrWhiteSpace(expectedPassword))
{
return Task.FromResult(AuthenticateResult.Fail("Admin:Password mangler."));
}
if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeaders))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var authorization = authorizationHeaders.ToString();
if (!authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
try
{
var encoded = authorization["Basic ".Length..].Trim();
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
var separator = decoded.IndexOf(':');
if (separator < 0)
{
return Task.FromResult(AuthenticateResult.Fail("Ugyldige credentials."));
}
var username = decoded[..separator];
var password = decoded[(separator + 1)..];
if (username != expectedUser || password != expectedPassword)
{
return Task.FromResult(AuthenticateResult.Fail("Forkert login."));
}
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, username)], Scheme.Name);
var principal = new ClaimsPrincipal(identity);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
catch
{
return Task.FromResult(AuthenticateResult.Fail("Ugyldige credentials."));
}
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.Headers.WWWAuthenticate = "Basic realm=\"Skydesportens Dag\"";
Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
}

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# Skydesportens Dag besøgsregistrering
Lille standalone ASP.NET Core app til Vestfalster Skytteforening.
## Funktioner
- Offentlig registrering uden login på `/`
- Felter: navn, adresse, fødselsdag og email
- Postby udfyldes automatisk ved indtastning af et kendt dansk postnummer og kan redigeres manuelt
- Adminliste på `/admin`
- Sletning med bekræftelse på adminlisten
- CSV-download og udskrivning via browserens dialog (vælg Gem som PDF for PDF)
- Adminlisten bruger enkel HTTP Basic Auth via konfiguration
- Data gemmes lokalt i SQLite uden databaseserver eller databaselogin
- Frontend serveres direkte fra samme dotnet app
## Opsætning
1. Sæt adminlogin via environment variables eller user secrets (kun nødvendigt for adminlisten):
```powershell
$env:Admin__Username="admin"
$env:Admin__Password="change-me"
```
2. Start appen:
```powershell
dotnet run
```
Appen opretter selv databasefilen `data/visitors.db` og `visitors`-tabellen. Data bevares efter genstart. Stien regnes fra projektmappen og kan ændres med `Database__Path`. Der kræves ingen databaseopsætning. En eventuel gammel `ConnectionStrings__Default` bruges ikke længere.
Tag backup af databasefilen, når appen er stoppet. Databasefiler er udeladt fra Git. Eksisterende data i PostgreSQL flyttes ikke automatisk.
Appen kører typisk på den URL, `dotnet run` skriver i terminalen, fx `http://localhost:5000`.
Postnummerlisten ligger lokalt i `wwwroot/postnumre.json`, så opslag virker uden internet. Data er hentet fra [GeoNames](https://download.geonames.org/export/zip/) den 2. oktober 2026 og bruges under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Ukendte postnumre kan udfyldes manuelt. Opdater listen med `./scripts/update-postal-codes.ps1` (kræver internet).
## Docker-server uden Git-runner
Overfør `Dockerfile`, `compose.yaml`, `SkydesportensDag.csproj`, `Program.cs`, `appsettings.json`, `.dockerignore`, `.env.example` og mappen `wwwroot` til samme mappe på serveren med SCP/SFTP. Docker bygger appen på serveren; der kræves ingen .NET-installation eller Git-runner dér.
På serveren:
```sh
cp .env.example .env
chmod 600 .env
# Rediger .env og sæt Admin__Password samt ønsket port.
docker compose up -d --build
docker compose logs --tail=100 web
```
Appen lytter som standard på `127.0.0.1:5187` på serveren. En eksisterende reverse proxy kan sende trafik hertil; brug HTTPS ved adgang over internettet, da adminlogin bruger Basic Auth. Ved direkte adgang på det lokale netværk kan `APP_BIND` sættes til serverens LAN-adresse. En proxy i en anden container kræver tilpasning af Docker-netværket.
Databasen oprettes i Docker-volumenet `visitors-data` og bevares ved genstart og nye builds. `docker compose down -v` sletter databasen. Lokale registreringer overføres ikke automatisk; aftal overførsel af databasefilen, hvis de skal med.
Ved opdateringer overføres kodefilerne igen, og `docker compose up -d --build` køres. Bevar serverens `.env` og databasevolumen. Tag databasebackup, mens appen er stoppet.
Adminlisten viser et interaktivt kort med antal deltagere pr. postnummer. Positioner fra GeoNames ligger i wwwroot/postnummer-positioner.json og opdateres sammen med postnummerlisten. Leaflet 1.9.4 ligger lokalt i wwwroot/vendor/leaflet med licens. Baggrundskort fra OpenStreetMap kræver internet; navne og adresser sendes ikke til korttjenesten. Kortet opdateres efter sletning og udelades fra listeudskrift.

11
SkydesportensDag.csproj Normal file
View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
</Project>

9
appsettings.json Normal file
View File

@ -0,0 +1,9 @@
{
"Database": {
"Path": "data/visitors.db"
},
"Admin": {
"Username": "admin",
"Password": ""
}
}

15
compose.yaml Normal file
View File

@ -0,0 +1,15 @@
services:
web:
build: .
restart: unless-stopped
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-5187}:8080"
environment:
Admin__Username: "${Admin__Username:-admin}"
Admin__Password: "${Admin__Password:?Sæt Admin__Password i .env}"
Database__Path: /app/data/visitors.db
volumes:
- visitors-data:/app/data
volumes:
visitors-data:

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

BIN
output/pdf/skydedag-qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,32 @@
$ErrorActionPreference = 'Stop'
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) ('dk-postcodes-' + [guid]::NewGuid() + '.zip')
try {
Invoke-WebRequest 'https://download.geonames.org/export/zip/DK.zip' -OutFile $archivePath
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($archivePath)
try {
$reader = [System.IO.StreamReader]::new($archive.GetEntry('DK.txt').Open())
try { $data = $reader.ReadToEnd() } finally { $reader.Dispose() }
} finally { $archive.Dispose() }
$cities = [ordered]@{}
$locations = [ordered]@{}
foreach ($row in $data.Trim().Split("`n")) {
$fields = $row.TrimEnd("`r").Split("`t")
if ($fields[0] -ne 'DK' -or $fields[1] -notmatch '^\d{4}$' -or !$fields[2]) {
throw 'Ugyldig postnummerdata.'
}
if ($cities.Contains($fields[1]) -and $cities[$fields[1]] -ne $fields[2]) {
throw "Flere postbyer for postnummer $($fields[1])."
}
$cities[$fields[1]] = $fields[2]
$locations[$fields[1]] = @{ city = $fields[2]; lat = [double]::Parse($fields[9], [Globalization.CultureInfo]::InvariantCulture); lng = [double]::Parse($fields[10], [Globalization.CultureInfo]::InvariantCulture) }
}
if ($cities.Count -lt 1000) { throw 'Postnummerlisten er ufuldstændig.' }
$target = Join-Path $PSScriptRoot '../wwwroot/postnumre.json'
$cities | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $target -Encoding utf8
$locations | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $PSScriptRoot '../wwwroot/postnummer-positioner.json') -Encoding utf8
Write-Output "Gemt $($cities.Count) danske postnumre."
} finally {
if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath }
}

BIN
skydedag-qr-plakat.pdf Normal file

Binary file not shown.

57
wwwroot/admin.html Normal file
View File

@ -0,0 +1,57 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Besøgende - Skydesportens Dag</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
</head>
<body>
<main class="shell admin-shell">
<section class="panel wide">
<div class="toolbar">
<div>
<p class="eyebrow">Vestfalster Skytteforening</p>
<h1>Besøgende</h1>
</div>
<span id="count" class="count">Henter...</span>
</div>
<div class="admin-actions no-print">
<button id="export-csv" type="button" disabled>Download CSV</button>
<button id="print-list" type="button" disabled>Udskriv / gem som PDF</button>
</div>
<div id="message" class="notice" hidden></div>
<section class="participant-map no-print" aria-labelledby="map-title">
<h2 id="map-title">Hvor kommer deltagerne fra?</h2>
<p>Fordelt på postnumre. Klik på en cirkel for at se postby og antal.</p>
<div id="participant-map" aria-label="Kort over deltagernes postbyer"></div>
<p id="map-status" role="status">Henter kort...</p>
</section>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Navn</th>
<th>Vejnavn og husnummer</th>
<th>Postnummer</th>
<th>Postby</th>
<th>Fødselsdag</th>
<th>Email</th>
<th>Registreret</th>
<th class="no-print">Handling</th>
</tr>
</thead>
<tbody id="visitors">
<tr><td colspan="8" class="empty">Henter besøgende...</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="/vendor/leaflet/leaflet.js"></script>
<script src="/participant-map.js"></script>
<script src="/admin.js"></script>
</body>
</html>

115
wwwroot/admin.js Normal file
View File

@ -0,0 +1,115 @@
const tableBody = document.querySelector("#visitors");
const count = document.querySelector("#count");
const message = document.querySelector("#message");
const csvButton = document.querySelector("#export-csv");
const printButton = document.querySelector("#print-list");
let visitors = [];
function formatDateTime(value) {
return new Intl.DateTimeFormat("da-DK", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(value));
}
function showError(text) {
message.textContent = text;
message.className = "notice error";
message.hidden = false;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function loadVisitors() {
csvButton.disabled = true;
printButton.disabled = true;
try {
const response = await fetch("/api/visitors");
if (!response.ok) {
showError("Listen kunne ikke hentes. Tjek admin-login og databaseforbindelse.");
count.textContent = "Fejl";
return;
}
visitors = await response.json();
updateParticipantMap(visitors);
message.hidden = true;
csvButton.disabled = false;
printButton.disabled = false;
count.textContent = `${visitors.length} registreret`;
if (visitors.length === 0) {
tableBody.innerHTML = '<tr><td colspan="8" class="empty">Ingen besøgende registreret endnu.</td></tr>';
return;
}
tableBody.innerHTML = visitors.map((visitor) => `
<tr>
<td>${escapeHtml(visitor.name)}</td>
<td>${escapeHtml(visitor.streetAddress)}</td>
<td>${escapeHtml(visitor.postalCode)}</td>
<td>${escapeHtml(visitor.city)}</td>
<td>${escapeHtml(visitor.birthday)}</td>
<td><a href="mailto:${escapeHtml(visitor.email)}">${escapeHtml(visitor.email)}</a></td>
<td>${escapeHtml(formatDateTime(visitor.createdAt))}</td>
<td class="no-print"><button type="button" class="delete-button" data-id="${visitor.id}" aria-label="Slet ${escapeHtml(visitor.name)}">Slet</button></td>
</tr>
`).join("");
} catch {
showError("Der er ikke forbindelse til besøgslisten lige nu.");
count.textContent = "Fejl";
}
}
tableBody.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-id]");
if (!button || button.disabled) return;
const visitor = visitors.find((item) => String(item.id) === button.dataset.id);
if (!visitor || !window.confirm(`Slet registreringen for ${visitor.name}? Dette kan ikke fortrydes.`)) return;
button.disabled = true;
csvButton.disabled = true;
printButton.disabled = true;
try {
const response = await fetch(`/api/visitors/${visitor.id}`, { method: "DELETE" });
if (!response.ok && response.status !== 404) throw new Error("Sletning mislykkedes.");
await loadVisitors();
} catch {
showError("Registreringen kunne ikke slettes. Prøv igen.");
button.disabled = false;
csvButton.disabled = false;
printButton.disabled = false;
}
});
function csvCell(value) {
let text = String(value ?? "");
// Prevent spreadsheet programs from evaluating visitor input as formulas.
if (/^[\s]*[=+@-]/.test(text)) text = "'" + text;
return `"${text.replaceAll('"', '""')}"`;
}
csvButton.addEventListener("click", () => {
const rows = [
["Navn", "Vejnavn og husnummer", "Postnummer", "Postby", "Fødselsdag", "Email", "Registreret"],
...visitors.map((visitor) => [visitor.name, visitor.streetAddress, visitor.postalCode,
visitor.city, visitor.birthday, visitor.email, formatDateTime(visitor.createdAt)])
];
const csv = "\uFEFF" + rows.map((row) => row.map(csvCell).join(";")).join("\r\n") + "\r\n";
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = "skydesportens-dag-besogende.csv";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
printButton.addEventListener("click", () => window.print());
loadVisitors();

70
wwwroot/app.js Normal file
View File

@ -0,0 +1,70 @@
const form = document.querySelector("#registration-form");
const message = document.querySelector("#message");
const postalCodeInput = form.elements.postalCode;
const cityInput = form.elements.city;
const citySuggestions = document.querySelector("#city-suggestions");
const postalCodes = fetch("/postnumre.json")
.then((response) => {
if (!response.ok) throw new Error("Postnummerlisten kunne ikke hentes.");
return response.json();
})
.catch(() => null);
function showMessage(text, type) {
message.textContent = text;
message.className = `notice ${type}`;
message.hidden = false;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = Object.fromEntries(new FormData(form).entries());
const button = form.querySelector("button");
button.disabled = true;
try {
const response = await fetch("/api/visitors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
showMessage("Registreringen kunne ikke gemmes. Tjek felterne og prøv igen.", "error");
return;
}
form.reset();
citySuggestions.replaceChildren();
showMessage("Tak, du er registreret.", "success");
} catch {
showMessage("Der er ikke forbindelse til registreringen lige nu.", "error");
} finally {
button.disabled = false;
}
});
async function updateCity() {
postalCodeInput.value = postalCodeInput.value.replace(/\D/g, "").slice(0, 4);
const postalCode = postalCodeInput.value;
citySuggestions.replaceChildren();
cityInput.value = "";
if (postalCode.length !== 4) return;
const cities = await postalCodes;
if (postalCodeInput.value !== postalCode || !cities) return;
const city = cities[postalCode];
if (!city) return;
const option = document.createElement("option");
option.value = city;
citySuggestions.append(option);
cityInput.value = city;
}
postalCodeInput.addEventListener("input", updateCity);
postalCodeInput.addEventListener("change", updateCity);
if (postalCodeInput.value) updateCity();

6
wwwroot/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#1d5b39"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#fbfcf7" stroke-width="5"/>
<circle cx="32" cy="32" r="7" fill="#fbfcf7"/>
<path d="M32 8v12M32 44v12M8 32h12M44 32h12" stroke="#fbfcf7" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

45
wwwroot/index.html Normal file
View File

@ -0,0 +1,45 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Skydesportens Dag - registrering</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="panel">
<p class="eyebrow">Vestfalster Skytteforening</p>
<h1>Skydesportens Dag</h1>
<p class="intro">Registrer dig som besøgende. Oplysningerne bruges kun i forbindelse med arrangementet.</p>
<div id="message" class="notice" hidden></div>
<form id="registration-form" class="form">
<label>Navn
<input name="name" autocomplete="name" required maxlength="160">
</label>
<label>Vejnavn og husnummer
<input name="streetAddress" autocomplete="street-address" required maxlength="240">
</label>
<div class="address-grid">
<label>Postnummer
<input name="postalCode" inputmode="numeric" pattern="[0-9]{4}" autocomplete="postal-code" required maxlength="4">
</label>
<label>Postby
<input name="city" list="city-suggestions" autocomplete="address-level2" required maxlength="120">
<datalist id="city-suggestions"></datalist>
</label>
</div>
<label>Fødselsdag
<input name="birthday" type="date" required>
</label>
<label>Email
<input name="email" type="email" autocomplete="email" required maxlength="240">
</label>
<button type="submit">Registrer besøgende</button>
</form>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,58 @@
const mapStatus = document.querySelector("#map-status");
const postalLocations = fetch("/postnummer-positioner.json")
.then((response) => {
if (!response.ok) throw new Error("Postnummerpositioner mangler.");
return response.json();
}).catch(() => null);
let participantMap;
let participantMarkers;
let mapRevision = 0;
let tileError = false;
async function updateParticipantMap(visitors) {
const revision = ++mapRevision;
const locations = await postalLocations;
if (revision !== mapRevision) return;
if (!locations || typeof L === "undefined") {
mapStatus.textContent = "Kortet kunne ikke indlæses. Deltagerlisten kan stadig bruges.";
return;
}
if (!participantMap) {
participantMap = L.map("participant-map").setView([56.1, 10.5], 6);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | Postbyer: <a href="https://www.geonames.org/">GeoNames</a>'
}).on("tileerror", () => {
if (!tileError) {
tileError = true;
mapStatus.textContent += " Baggrundskortet kunne ikke hentes; kontrollér internetforbindelsen.";
}
}).addTo(participantMap);
participantMarkers = L.layerGroup().addTo(participantMap);
}
participantMarkers.clearLayers();
const groups = new Map();
let missing = 0;
for (const visitor of visitors) {
const code = String(visitor.postalCode ?? "").trim();
if (!locations[code]) { missing++; continue; }
groups.set(code, (groups.get(code) ?? 0) + 1);
}
const bounds = [];
for (const [code, total] of groups) {
const location = locations[code];
const point = [location.lat, location.lng];
bounds.push(point);
const popup = document.createElement("div");
popup.textContent = `${code} ${location.city}: ${total} ${total === 1 ? "deltager" : "deltagere"}`;
L.circleMarker(point, {
radius: Math.min(32, 7 + Math.sqrt(total) * 3),
color: "#16472d", fillColor: "#1d5b39", fillOpacity: 0.65, weight: 2
}).bindPopup(popup).bindTooltip(`${code}: ${total}`).addTo(participantMarkers);
}
if (bounds.length) participantMap.fitBounds(bounds, { padding: [35, 35], maxZoom: 11 });
else participantMap.setView([56.1, 10.5], 6);
mapStatus.textContent = visitors.length === 0 ? "Ingen deltagere registreret endnu." :
`${visitors.length - missing} deltagere fra ${groups.size} postnumre vist.${missing ? ` ${missing} deltagere har et ukendt postnummer og vises ikke på kortet.` : ""}`;
if (tileError) mapStatus.textContent += " Baggrundskortet kunne ikke hentes; kontrollér internetforbindelsen.";
}

File diff suppressed because it is too large Load Diff

1161
wwwroot/postnumre.json Normal file

File diff suppressed because it is too large Load Diff

274
wwwroot/styles.css Normal file
View File

@ -0,0 +1,274 @@
:root {
color-scheme: light;
--bg: #edf2ef;
--panel: #fbfcf7;
--text: #18221c;
--muted: #617066;
--line: #cad6ce;
--accent: #1d5b39;
--accent-dark: #16472d;
--danger: #8a1f11;
--success: #1f6a3c;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(135deg, rgba(29, 91, 57, 0.12), transparent 34%), var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
}
.shell {
width: min(100% - 32px, 920px);
margin: 0 auto;
padding: 36px 0;
}
.shell.admin-shell {
width: calc(100% - 32px);
max-width: 1600px;
}
.panel {
max-width: 560px;
margin: 0 auto;
padding: 28px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 16px 50px rgba(24, 34, 28, 0.12);
}
.panel.wide {
max-width: none;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 14px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
margin: 0 0 12px;
font-size: 32px;
line-height: 1.15;
}
.intro {
margin: 0 0 24px;
color: var(--muted);
font-size: 16px;
line-height: 1.5;
}
.form {
display: grid;
gap: 16px;
}
.address-grid {
display: grid;
grid-template-columns: minmax(120px, 0.6fr) minmax(180px, 1fr);
gap: 16px;
}
label {
display: grid;
gap: 7px;
color: var(--text);
font-size: 15px;
font-weight: 700;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
color: var(--text);
background: #fff;
font: inherit;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 91, 57, 0.16);
outline: none;
}
button {
margin-top: 8px;
border: 0;
border-radius: 6px;
padding: 13px 18px;
color: #fff;
background: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:hover {
background: var(--accent-dark);
}
button:disabled {
cursor: wait;
opacity: 0.72;
}
.notice {
margin: 0 0 18px;
border-radius: 6px;
padding: 12px 14px;
font-size: 15px;
line-height: 1.45;
}
.notice.error {
color: var(--danger);
background: #fff2ef;
border: 1px solid #efc5bd;
}
.notice.success {
color: var(--success);
background: #edf8f0;
border: 1px solid #bfddc8;
}
.toolbar {
display: flex;
align-items: start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.count {
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 12px;
color: var(--accent);
background: #fff;
font-size: 14px;
font-weight: 700;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
line-height: 1.4;
}
th {
color: var(--muted);
background: #f5f8f6;
font-weight: 700;
}
tr:last-child td {
border-bottom: 0;
}
a {
color: var(--accent);
}
.empty {
color: var(--muted);
text-align: center;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.participant-map { margin: 0 0 24px; }
.participant-map h2 { margin: 0 0 8px; font-size: 22px; }
.participant-map p { color: var(--muted); font-size: 14px; }
#participant-map { height: 380px; border: 1px solid var(--line); border-radius: 8px; z-index: 0; }
.delete-button {
margin: 0;
padding: 8px 12px;
background: var(--danger);
}
.delete-button:hover {
background: #69170d;
}
@media print {
@page { size: A4 landscape; margin: 12mm; }
body { min-height: 0; background: #fff; color: #000; }
.shell { width: 100%; margin: 0; padding: 0; }
.shell.admin-shell { width: 100%; max-width: none; }
.panel { padding: 0; border: 0; box-shadow: none; }
.no-print, .notice { display: none !important; }
.table-wrap { overflow: visible; border: 0; }
table { min-width: 0; width: 100%; table-layout: fixed; }
th, td { padding: 6px; font-size: 10px; overflow-wrap: anywhere; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
a { color: #000; text-decoration: none; }
.count { color: #000; }
}
@media (max-width: 620px) {
.shell {
width: min(100% - 20px, 920px);
padding: 16px 0;
}
.panel {
padding: 20px;
}
h1 {
font-size: 28px;
}
.toolbar {
display: block;
}
.count {
display: inline-block;
margin-top: 6px;
}
.address-grid {
grid-template-columns: 1fr;
}
}

26
wwwroot/vendor/leaflet/LICENSE vendored Normal file
View File

@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

661
wwwroot/vendor/leaflet/leaflet.css vendored Normal file
View File

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
wwwroot/vendor/leaflet/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long