Add visitor registration app with SQLite, admin tools and Docker deployment
This commit is contained in:
commit
150e0a00b5
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
5
.env.example
Normal 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
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.pdf binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
249
Program.cs
Normal 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
60
README.md
Normal 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
11
SkydesportensDag.csproj
Normal 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
9
appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Database": {
|
||||||
|
"Path": "data/visitors.db"
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
15
compose.yaml
Normal file
15
compose.yaml
Normal 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:
|
||||||
BIN
output/pdf/skydedag-qr-plakat-preview.png
Normal file
BIN
output/pdf/skydedag-qr-plakat-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
output/pdf/skydedag-qr-plakat.pdf
Normal file
BIN
output/pdf/skydedag-qr-plakat.pdf
Normal file
Binary file not shown.
BIN
output/pdf/skydedag-qr.png
Normal file
BIN
output/pdf/skydedag-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
32
scripts/update-postal-codes.ps1
Normal file
32
scripts/update-postal-codes.ps1
Normal 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
BIN
skydedag-qr-plakat.pdf
Normal file
Binary file not shown.
57
wwwroot/admin.html
Normal file
57
wwwroot/admin.html
Normal 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
115
wwwroot/admin.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
70
wwwroot/app.js
Normal 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
6
wwwroot/favicon.svg
Normal 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
45
wwwroot/index.html
Normal 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>
|
||||||
58
wwwroot/participant-map.js
Normal file
58
wwwroot/participant-map.js
Normal 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: '© <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.";
|
||||||
|
}
|
||||||
5797
wwwroot/postnummer-positioner.json
Normal file
5797
wwwroot/postnummer-positioner.json
Normal file
File diff suppressed because it is too large
Load Diff
1161
wwwroot/postnumre.json
Normal file
1161
wwwroot/postnumre.json
Normal file
File diff suppressed because it is too large
Load Diff
274
wwwroot/styles.css
Normal file
274
wwwroot/styles.css
Normal 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
26
wwwroot/vendor/leaflet/LICENSE
vendored
Normal 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
661
wwwroot/vendor/leaflet/leaflet.css
vendored
Normal 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
6
wwwroot/vendor/leaflet/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user