feat(proj): init
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
public sealed class AuthController(
|
||||
IAccountService accounts,
|
||||
IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
private const string OAuthStateCookie = "ts_oauth_state";
|
||||
|
||||
[HttpGet("providers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Providers()
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
var configured = yandex.IsConfigured;
|
||||
return Ok(new AuthProvidersResponse(
|
||||
new YandexProviderResponse(
|
||||
configured,
|
||||
configured ? yandex.ClientId : null,
|
||||
configured ? yandex.RedirectUri : null)));
|
||||
}
|
||||
|
||||
[HttpGet("yandex/start")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult StartYandex()
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
if (!yandex.IsConfigured)
|
||||
{
|
||||
throw new DomainValidationException("Yandex OAuth is not configured.");
|
||||
}
|
||||
|
||||
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
Response.Cookies.Append(OAuthStateCookie, state, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromMinutes(15),
|
||||
Path = "/"
|
||||
});
|
||||
|
||||
var url =
|
||||
"https://oauth.yandex.ru/authorize" +
|
||||
"?response_type=code" +
|
||||
$"&client_id={Uri.EscapeDataString(yandex.ClientId)}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(yandex.RedirectUri)}" +
|
||||
$"&state={Uri.EscapeDataString(state)}";
|
||||
return Redirect(url);
|
||||
}
|
||||
|
||||
[HttpPost("yandex")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ExchangeYandex([FromBody] YandexCodeRequest request, CancellationToken ct)
|
||||
{
|
||||
var yandex = options.Value.Yandex;
|
||||
if (!yandex.IsConfigured)
|
||||
{
|
||||
throw new DomainValidationException("Yandex OAuth is not configured.");
|
||||
}
|
||||
|
||||
var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri)
|
||||
? yandex.RedirectUri
|
||||
: request.RedirectUri.Trim();
|
||||
if (!IsAllowedRedirect(yandex.RedirectUri, redirectUri))
|
||||
{
|
||||
throw new DomainValidationException("Invalid Yandex redirect_uri.");
|
||||
}
|
||||
|
||||
if (Request.Cookies.TryGetValue(OAuthStateCookie, out var expected) &&
|
||||
!string.IsNullOrEmpty(expected) &&
|
||||
!string.Equals(expected, request.State, StringComparison.Ordinal))
|
||||
{
|
||||
throw new UnauthenticatedException("OAuth state mismatch.");
|
||||
}
|
||||
|
||||
var (me, cabinetToken) = await accounts.SignInFromYandexAsync(request.Code, redirectUri, ct)
|
||||
.ConfigureAwait(false);
|
||||
var identity = new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, me.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, me.Login),
|
||||
new Claim("slug", me.Slug)
|
||||
],
|
||||
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await HttpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30)
|
||||
}).ConfigureAwait(false);
|
||||
Response.Cookies.Delete(OAuthStateCookie);
|
||||
return Ok(new AuthResponse(me, cabinetToken));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static bool IsAllowedRedirect(string configured, string incoming)
|
||||
{
|
||||
static string Normalize(string value) => value.Trim().TrimEnd('/');
|
||||
return string.Equals(Normalize(configured), Normalize(incoming), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record YandexCodeRequest(string Code, string? State, string? RedirectUri);
|
||||
|
||||
public sealed record AuthResponse(MeResponse User, string? CabinetToken);
|
||||
|
||||
public sealed record YandexProviderResponse(bool Enabled, string? ClientId, string? RedirectUri);
|
||||
|
||||
public sealed record AuthProvidersResponse(YandexProviderResponse Yandex);
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[Route("demo")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public sealed class DemoController : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
public IActionResult Index() => View();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/me")]
|
||||
public sealed class MeController(
|
||||
IAccountService accounts,
|
||||
IApiTokenService tokens,
|
||||
IUserStyleService styles,
|
||||
IUsageQuery usage) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken ct)
|
||||
=> Ok(await accounts.GetMeAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("tokens")]
|
||||
public async Task<IActionResult> ListTokens(CancellationToken ct)
|
||||
=> Ok(await tokens.ListAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPost("tokens")]
|
||||
public async Task<IActionResult> CreateToken([FromBody] CreateTokenRequest request, CancellationToken ct)
|
||||
=> Ok(await tokens.CreateAsync(SessionUser.GetId(User), request.Name ?? "Token", ct).ConfigureAwait(false));
|
||||
|
||||
[HttpDelete("tokens/{id:guid}")]
|
||||
public async Task<IActionResult> RevokeToken(Guid id, CancellationToken ct)
|
||||
{
|
||||
await tokens.RevokeAsync(SessionUser.GetId(User), id, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("styles")]
|
||||
public async Task<IActionResult> ListStyles(CancellationToken ct)
|
||||
=> Ok(await styles.ListMineAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("styles/{name}")]
|
||||
public async Task<IActionResult> GetStyle(string name, CancellationToken ct)
|
||||
=> new JsonResult(await styles.GetMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPost("styles")]
|
||||
public async Task<IActionResult> CloneStyle([FromBody] CloneStyleRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.From))
|
||||
{
|
||||
throw new DomainValidationException("Style name and source preset are required.");
|
||||
}
|
||||
|
||||
return Ok(await styles.CloneAsync(SessionUser.GetId(User), request.Name, request.From, ct)
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
[HttpPut("styles/{name}")]
|
||||
public async Task<IActionResult> SaveStyle(string name, [FromBody] JsonObject style, CancellationToken ct)
|
||||
{
|
||||
await styles.SaveMineAsync(SessionUser.GetId(User), name, style, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("styles/{name}")]
|
||||
public async Task<IActionResult> DeleteStyle(string name, CancellationToken ct)
|
||||
{
|
||||
await styles.DeleteMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("usage")]
|
||||
public async Task<IActionResult> GetUsage([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
{
|
||||
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var start = from ?? end.AddDays(-6);
|
||||
if (start > end)
|
||||
{
|
||||
throw new DomainValidationException("'from' must be on or before 'to'.");
|
||||
}
|
||||
|
||||
return Ok(await usage.GetAsync(SessionUser.GetId(User), start, end, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateTokenRequest(string? Name);
|
||||
|
||||
public sealed record CloneStyleRequest(string Name, string From);
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Accounts;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Application.Extracts;
|
||||
using TileServer.Application.Tiles;
|
||||
using TileServer.Domain.Exceptions;
|
||||
using TileServer.Domain.Tiles;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("u/{slug}")]
|
||||
public sealed class PersonalDeliveryController(
|
||||
IApiTokenService tokens,
|
||||
ITileQueryService tiles,
|
||||
IUserStyleService styles,
|
||||
IExtractCatalog extracts,
|
||||
IUsageRecorder usage,
|
||||
IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
[HttpGet("tiles/{source}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
public async Task<IActionResult> GetTile(string slug, string source, int z, int x, int y, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var result = await tiles.GetTileAsync(source, new TileCoordinate(z, x, y), ct).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
usage.RecordTile(access.User.Id, access.Token.Id, 0);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var payload = result.Tile.Uncompressed();
|
||||
usage.RecordTile(access.User.Id, access.Token.Id, payload.Length);
|
||||
Response.Headers.CacheControl = "private, max-age=604800, immutable";
|
||||
return File(payload, "application/vnd.mapbox-vector-tile");
|
||||
}
|
||||
|
||||
[HttpGet("tiles/{source}.json")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> GetTileJson(string slug, string source, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var tilesUrl =
|
||||
$"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{source}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}";
|
||||
return Ok(tiles.GetTileJson(source, tilesUrl));
|
||||
}
|
||||
|
||||
[HttpGet("styles")]
|
||||
public async Task<IActionResult> ListStyles(string slug, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var list = await styles.ListForDeliveryAsync(
|
||||
access.User.Slug,
|
||||
PublicUrl.GetBase(Request, options),
|
||||
token,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpGet("styles/{name}")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> GetStyle(string slug, string name, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
|
||||
var token = AccessToken.Read(Request) ?? string.Empty;
|
||||
var extract = string.IsNullOrWhiteSpace(source)
|
||||
? extracts.GetAll().FirstOrDefault(e => e.Enabled)
|
||||
: extracts.Find(source);
|
||||
if (extract is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Extract", source ?? "(none configured)");
|
||||
}
|
||||
|
||||
var tilesUrl =
|
||||
$"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{extract.Id}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}";
|
||||
var style = await styles.GetForDeliveryAsync(access.User.Slug, name, extract.Id, tilesUrl, ct)
|
||||
.ConfigureAwait(false);
|
||||
var json = style.ToJsonString();
|
||||
usage.RecordStyle(access.User.Id, access.Token.Id, Encoding.UTF8.GetByteCount(json));
|
||||
return new JsonResult(style);
|
||||
}
|
||||
|
||||
private Task<ResolvedAccess> ResolveAsync(string slug, CancellationToken ct)
|
||||
=> tokens.ResolveAsync(slug, AccessToken.Read(Request), bearer: null, ct);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Api.Http;
|
||||
using TileServer.Application.Configuration;
|
||||
using TileServer.Application.Extracts;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/sources")]
|
||||
public sealed class SourcesController(IExtractService extracts, IOptions<TileServerOptions> options) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExtractResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(CancellationToken ct)
|
||||
=> Ok(await extracts.ListAsync(PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken ct)
|
||||
=> Ok(await extracts.GetAsync(id, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Upsert(string id, [FromBody] CreateExtractRequest request, CancellationToken ct)
|
||||
{
|
||||
var payload = request with { Id = string.IsNullOrWhiteSpace(request.Id) ? id : request.Id };
|
||||
return Ok(await extracts.AddOrUpdateAsync(payload, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken ct)
|
||||
{
|
||||
await extracts.RemoveAsync(id, ct).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/styles")]
|
||||
public sealed class StylesController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult List() => Closed();
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public IActionResult Get(string name, [FromQuery] string? source) => Closed();
|
||||
|
||||
[HttpPut("{name}")]
|
||||
public IActionResult Put(string name, [FromBody] JsonObject style) => Closed();
|
||||
|
||||
private static IActionResult Closed()
|
||||
=> PublicDelivery.Closed("Public styles are disabled. Use /u/{slug}/styles/{name}?token= or POST /api/v1/me/styles while signed in.");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Application.Sync;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/sync")]
|
||||
public sealed class SyncController(IMapSyncService sync, ILogger<SyncController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
[ProducesResponseType(typeof(SyncJobStatus), StatusCodes.Status200OK)]
|
||||
public IActionResult Status() => Ok(sync.GetStatus());
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public IActionResult RunAll()
|
||||
=> Start(() => sync.RunAllAsync(CancellationToken.None), "Synchronization started for all extracts.");
|
||||
|
||||
[HttpPost("{extractId}")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public IActionResult RunOne(string extractId)
|
||||
=> Start(() => sync.RunOneAsync(extractId, CancellationToken.None), $"Synchronization started for '{extractId}'.");
|
||||
|
||||
private IActionResult Start(Func<Task> work, string message)
|
||||
{
|
||||
if (sync.GetStatus().IsRunning)
|
||||
{
|
||||
return Conflict(new { message = "A synchronization job is already running." });
|
||||
}
|
||||
|
||||
_ = RunInBackground(work);
|
||||
return Accepted(new { message });
|
||||
}
|
||||
|
||||
private async Task RunInBackground(Func<Task> work)
|
||||
{
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(false);
|
||||
}
|
||||
catch (ResourceConflictException)
|
||||
{
|
||||
logger.LogInformation("Sync request ignored because another job is running.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Background synchronization failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Api.Http;
|
||||
|
||||
namespace TileServer.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/tiles")]
|
||||
public sealed class TilesController : ControllerBase
|
||||
{
|
||||
[HttpGet("{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
[HttpGet("/tiles/{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
|
||||
public IActionResult GetTile(string extractId, int z, int x, int y)
|
||||
=> Closed();
|
||||
|
||||
[HttpGet("{extractId}.json")]
|
||||
[HttpGet("/tiles/{extractId}.json")]
|
||||
public IActionResult GetTileJson(string extractId)
|
||||
=> Closed();
|
||||
|
||||
private static IActionResult Closed()
|
||||
=> PublicDelivery.Closed("Public tiles are disabled. Use /u/{slug}/tiles/{source}/{z}/{x}/{y}.pbf?token=");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class AccessToken
|
||||
{
|
||||
public static string? Read(HttpRequest request)
|
||||
{
|
||||
if (request.Query.TryGetValue("token", out var query) && !string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return query.ToString();
|
||||
}
|
||||
|
||||
var header = request.Headers.Authorization.ToString();
|
||||
const string bearer = "Bearer ";
|
||||
return header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
internal static class PublicDelivery
|
||||
{
|
||||
public static IActionResult Closed(string detail)
|
||||
=> new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = detail
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TileServer.Application.Configuration;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class PublicUrl
|
||||
{
|
||||
public static string GetBase(HttpRequest request, IOptions<TileServerOptions> options)
|
||||
=> GetBase(request, options.Value.PublicBaseUrl);
|
||||
|
||||
public static string GetBase(HttpRequest request, string? publicBaseUrl)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||||
{
|
||||
return publicBaseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
var scheme = FirstForwarded(request.Headers["X-Forwarded-Proto"]) ?? request.Scheme;
|
||||
var host = FirstForwarded(request.Headers["X-Forwarded-Host"]) ?? request.Host.Value;
|
||||
var pathBase = request.PathBase.HasValue
|
||||
? request.PathBase.Value!.TrimEnd('/')
|
||||
: string.Empty;
|
||||
|
||||
return $"{scheme}://{host}{pathBase}";
|
||||
}
|
||||
|
||||
private static string? FirstForwarded(string? header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = header.Split(',')[0].Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace TileServer.Api.Http;
|
||||
|
||||
public static class SessionUser
|
||||
{
|
||||
public static Guid GetId(ClaimsPrincipal user)
|
||||
{
|
||||
var raw = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!Guid.TryParse(raw, out var id))
|
||||
{
|
||||
throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TileServer.Domain.Exceptions;
|
||||
|
||||
namespace TileServer.Api.Middleware;
|
||||
|
||||
public sealed class DomainExceptionHandler(ILogger<DomainExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, title) = exception switch
|
||||
{
|
||||
ResourceNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
|
||||
DomainValidationException => (HttpStatusCode.BadRequest, "Validation failed"),
|
||||
ResourceConflictException => (HttpStatusCode.Conflict, "Conflict"),
|
||||
UnauthenticatedException => (HttpStatusCode.Unauthorized, "Unauthorized"),
|
||||
_ => (HttpStatusCode.InternalServerError, "Unexpected error")
|
||||
};
|
||||
|
||||
if (status == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
logger.LogError(exception, "Unhandled exception");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(exception, "Domain exception mapped to {Status}", (int)status);
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = (int)status,
|
||||
Title = title,
|
||||
Detail = status == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : exception.Message,
|
||||
Instance = httpContext.Request.Path
|
||||
};
|
||||
|
||||
httpContext.Response.StatusCode = (int)status;
|
||||
if (status == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
}
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using TileServer.Api.Middleware;
|
||||
using TileServer.Application;
|
||||
using TileServer.Infrastructure;
|
||||
using TileServer.Infrastructure.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services
|
||||
.AddApplication()
|
||||
.AddInfrastructure(builder.Configuration);
|
||||
|
||||
var dataDirectory = builder.Configuration["TileServer:DataDirectory"] ?? "data";
|
||||
var keyPath = Path.Combine(dataDirectory, "aspnet-keys");
|
||||
Directory.CreateDirectory(keyPath);
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.SetApplicationName("TileServer")
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
|
||||
| ForwardedHeaders.XForwardedProto
|
||||
| ForwardedHeaders.XForwardedHost;
|
||||
// Docker / nginx source IPs are not in the default known-proxy list.
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
builder.Services.AddResponseCaching();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "Tile Server API",
|
||||
Version = "v1",
|
||||
Description = "Vector tiles, MapLibre styles and OSM extract synchronization."
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.WithMethods("GET", "HEAD", "PUT", "POST", "DELETE"));
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = "ts_session";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRedirectToAccessDenied = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services
|
||||
.AddControllersWithViews()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
var spaRoot = Path.Combine(app.Environment.WebRootPath, "app");
|
||||
if (Directory.Exists(spaRoot))
|
||||
{
|
||||
var spaFiles = new PhysicalFileProvider(spaRoot);
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = spaFiles });
|
||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = spaFiles });
|
||||
}
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseResponseCaching();
|
||||
app.UseCors();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health/live");
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
if (File.Exists(Path.Combine(spaRoot, "index.html")))
|
||||
{
|
||||
app.MapFallbackToFile("app/index.html");
|
||||
}
|
||||
else
|
||||
{
|
||||
app.MapGet("/", () => Results.Redirect("/demo"));
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "",
|
||||
"applicationUrl": "http://localhost:5088",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TileServer.Application\TileServer.Application.csproj" />
|
||||
<ProjectReference Include="..\TileServer.Infrastructure\TileServer.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\TileServer.Infrastructure\Seed\styles\*.json" Link="Seed\styles\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@TileServer.Api_HostAddress = http://localhost:5005
|
||||
|
||||
GET {{TileServer.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tile Server</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
|
||||
<link rel="stylesheet" href="~/css/demo.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map" role="application" aria-label="Карта"></div>
|
||||
|
||||
<header class="hud hud--top">
|
||||
<div class="brand">
|
||||
<span class="brand__mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.95"/>
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.28"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand__text">
|
||||
<strong>Tile Server</strong>
|
||||
<span>OSM · MapLibre · Geofabrik</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="field field--inline">
|
||||
<span>Источник</span>
|
||||
<select id="source-select"></select>
|
||||
</label>
|
||||
<label class="field field--inline">
|
||||
<span>Стиль</span>
|
||||
<select id="style-select"></select>
|
||||
</label>
|
||||
<span class="pill" id="status-pill">—</span>
|
||||
<button type="button" class="btn btn--accent" id="sync-button">Синхронизировать</button>
|
||||
<a class="link" href="/swagger" target="_blank" rel="noreferrer">OpenAPI</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="hud hud--goto" id="goto-form">
|
||||
<div class="goto__head">
|
||||
<div>
|
||||
<h1>Камера</h1>
|
||||
<p class="coords" id="live-coords">—</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="presets" role="group" aria-label="Быстрый переход">
|
||||
<button type="button" class="preset" data-lat="55.7558" data-lon="37.6173" data-zoom="14">Москва</button>
|
||||
<button type="button" class="preset" data-lat="54.2255" data-lon="38.4692" data-zoom="6">ЦФО</button>
|
||||
<button type="button" class="preset preset--ghost" id="fit-bounds">По выгрузке</button>
|
||||
</div>
|
||||
<div class="goto__row">
|
||||
<label class="field">
|
||||
<span>Широта</span>
|
||||
<input id="goto-lat" name="lat" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="55.7558" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Долгота</span>
|
||||
<input id="goto-lon" name="lon" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="37.6173" required />
|
||||
</label>
|
||||
<label class="field field--zoom">
|
||||
<span>Зум</span>
|
||||
<input id="goto-zoom" name="zoom" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="14" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--accent btn--block">Перейти</button>
|
||||
<p class="error" id="goto-error" hidden></p>
|
||||
</form>
|
||||
|
||||
<aside class="hud hud--status" id="worker-panel">
|
||||
<details>
|
||||
<summary>
|
||||
<span class="worker__pulse" aria-hidden="true"></span>
|
||||
Воркер
|
||||
</summary>
|
||||
<dl>
|
||||
<div><dt>Задача</dt><dd id="sync-running">—</dd></div>
|
||||
<div><dt>Источник</dt><dd id="source-state">—</dd></div>
|
||||
<div><dt>Скачан</dt><dd id="source-downloaded">—</dd></div>
|
||||
<div><dt>Собран</dt><dd id="source-built">—</dd></div>
|
||||
<div><dt>Следующий запуск</dt><dd id="sync-next">—</dd></div>
|
||||
</dl>
|
||||
<p class="error" id="sync-error" hidden></p>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
|
||||
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
|
||||
<script src="~/js/demo.js" asp-append-version="true"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Accounts": "Host=127.0.0.1;Port=5433;Database=tile_server;Username=tiles;Password=tiles"
|
||||
},
|
||||
"TileServer": {
|
||||
"DataDirectory": "../../data",
|
||||
"JavaPath": "java",
|
||||
"JvmMaxHeap": "8g",
|
||||
"PlanetilerJarUrl": "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar",
|
||||
"PlanetilerHttpTimeout": "15m",
|
||||
"DownloadStallTimeoutSeconds": 45,
|
||||
"NaturalEarthUrls": [
|
||||
"https://naciscdn.org/naturalearth/packages/natural_earth_vector.sqlite.zip",
|
||||
"https://naturalearth.s3.amazonaws.com/packages/natural_earth_vector.sqlite.zip"
|
||||
],
|
||||
"LakeCenterlinesUrls": [
|
||||
"https://github.com/acalcutt/osm-lakelines/releases/download/latest/lake_centerline.shp.zip",
|
||||
"https://acalcutt.github.io/osm-lakelines/lake_centerline.shp.zip"
|
||||
],
|
||||
"WaterPolygonsUrls": [
|
||||
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip"
|
||||
],
|
||||
"DefaultMinZoom": 0,
|
||||
"DefaultMaxZoom": 15,
|
||||
"OverzoomMaxZoom": 18,
|
||||
"GlyphsUrl": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"Yandex": {
|
||||
"RedirectUri": "https://tile-server.ru"
|
||||
},
|
||||
"Sync": {
|
||||
"Cron": "0 3 * * *",
|
||||
"TimeZone": "UTC",
|
||||
"RunOnStartup": true,
|
||||
"RebuildIfTilesMissing": true
|
||||
},
|
||||
"Extracts": [
|
||||
{
|
||||
"Id": "central-fed-district",
|
||||
"Name": "Центральный федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf",
|
||||
"Center": [ 37.6173, 55.7558 ],
|
||||
"MinZoom": 0,
|
||||
"MaxZoom": 15,
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Id": "volga-fed-district",
|
||||
"Name": "Волго-Вятский федеральный округ",
|
||||
"Url": "https://download.geofabrik.de/russia/volga-fed-district-latest.osm.pbf",
|
||||
"Center": [ 45.0000, 57.0000 ],
|
||||
"MinZoom": 0,
|
||||
"MaxZoom": 15,
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: rgba(14, 18, 26, 0.78);
|
||||
--panel-strong: rgba(10, 13, 18, 0.92);
|
||||
--text: #f4f1ea;
|
||||
--muted: #9aa6b4;
|
||||
--line: rgba(244, 241, 234, 0.1);
|
||||
--line-strong: rgba(244, 241, 234, 0.16);
|
||||
--accent: #e8923d;
|
||||
--accent-press: #f3a85a;
|
||||
--accent-ink: #1a1208;
|
||||
--danger: #ff8a7a;
|
||||
--ok: #7dcea0;
|
||||
--warn: #f5c16c;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
||||
--hud-z: 3;
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
||||
--space: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html, body, #map {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 42%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(22px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.35);
|
||||
}
|
||||
|
||||
.hud--top {
|
||||
top: var(--space);
|
||||
left: var(--space);
|
||||
max-width: min(70rem, calc(100vw - 4.5rem));
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.85rem 1rem;
|
||||
padding: 0.7rem 0.8rem 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem 0.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 11px;
|
||||
color: var(--accent);
|
||||
background: rgba(232, 146, 61, 0.12);
|
||||
border: 1px solid rgba(232, 146, 61, 0.28);
|
||||
}
|
||||
|
||||
.brand__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.brand__text span {
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.55rem 0.65rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 10.5rem; }
|
||||
|
||||
.field--zoom { min-width: 0; }
|
||||
|
||||
select, button, input {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
color: var(--text);
|
||||
padding: 0.48rem 0.7rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
select, input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
|
||||
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% - 3px),
|
||||
calc(100% - 11px) calc(50% - 3px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 1.7rem;
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.btn--accent,
|
||||
#sync-button,
|
||||
.hud--goto button[type="submit"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: 0 8px 20px rgba(232, 146, 61, 0.22);
|
||||
}
|
||||
|
||||
.btn--accent:hover,
|
||||
#sync-button:hover,
|
||||
.hud--goto button[type="submit"]:hover {
|
||||
background: var(--accent-press);
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
.preset,
|
||||
.hud--status summary,
|
||||
.preset--ghost {
|
||||
background: rgba(8, 11, 16, 0.55);
|
||||
color: var(--text);
|
||||
font-weight: 550;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.preset:hover,
|
||||
.preset--ghost:hover,
|
||||
.hud--status summary:hover {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
align-self: center;
|
||||
padding: 0.45rem 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover { color: var(--accent); }
|
||||
|
||||
.pill {
|
||||
align-self: end;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.42rem 0.75rem 0.42rem 0.65rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill::before {
|
||||
content: "";
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 4px rgba(154, 166, 180, 0.12);
|
||||
}
|
||||
|
||||
.pill--ok { color: var(--ok); border-color: rgba(125, 206, 160, 0.32); }
|
||||
.pill--ok::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(125, 206, 160, 0.16); }
|
||||
.pill--work { color: var(--warn); border-color: rgba(245, 193, 108, 0.32); }
|
||||
.pill--work::before { background: var(--warn); box-shadow: 0 0 0 4px rgba(245, 193, 108, 0.16); }
|
||||
.pill--fail { color: var(--danger); border-color: rgba(255, 138, 122, 0.4); }
|
||||
.pill--fail::before { background: var(--danger); box-shadow: 0 0 0 4px rgba(255, 138, 122, 0.16); }
|
||||
|
||||
.hud--goto {
|
||||
left: var(--space);
|
||||
bottom: 1.75rem;
|
||||
width: min(22.75rem, calc(100vw - 24px));
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.goto__head h1 {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.coords {
|
||||
margin: 0.28rem 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.coords__sep {
|
||||
color: var(--muted);
|
||||
margin: 0 0.35rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.presets button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.goto__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 4.8rem;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 11, 16, 0.45);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.goto__row input {
|
||||
min-height: 2.5rem;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.goto__row input:focus {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
background: rgba(232, 146, 61, 0.06);
|
||||
}
|
||||
|
||||
.hud--status {
|
||||
right: var(--space);
|
||||
bottom: 3.4rem;
|
||||
top: auto;
|
||||
width: min(19.5rem, calc(100vw - 24px));
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud--status summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hud--status summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.worker__pulse {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.hud--status.is-running .worker__pulse {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.7);
|
||||
animation: pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
.hud--status.is-ready .worker__pulse {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.hud--status dl,
|
||||
.hud--status .error {
|
||||
padding: 0 0.9rem 0.85rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
dt { color: var(--muted); }
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--danger);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
left: var(--space);
|
||||
top: 5.4rem;
|
||||
max-width: min(34rem, calc(100vw - 24px));
|
||||
background: rgba(42, 31, 18, 0.92);
|
||||
color: #ffe0c2;
|
||||
border: 1px solid #6a4a22;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
a:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-top-right { top: var(--space); right: var(--space); }
|
||||
.maplibregl-ctrl-bottom-right { right: var(--space); bottom: var(--space); }
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background: transparent;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
background: rgba(10, 13, 18, 0.62) !important; /* MapLibre default is opaque white */
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-scale {
|
||||
background: rgba(10, 13, 18, 0.62);
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.55); }
|
||||
100% { box-shadow: 0 0 0 10px rgba(245, 193, 108, 0); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hud--top {
|
||||
right: 3.6rem;
|
||||
max-width: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 8.5rem; flex: 1; }
|
||||
.hud--status { display: none; }
|
||||
.banner { top: auto; bottom: 13.5rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
scroll-behavior: auto;
|
||||
animation: none !important; /* third-party MapLibre fade + our pulse */
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
const sourceSelect = document.getElementById("source-select");
|
||||
const styleSelect = document.getElementById("style-select");
|
||||
const syncButton = document.getElementById("sync-button");
|
||||
const gotoForm = document.getElementById("goto-form");
|
||||
const gotoError = document.getElementById("goto-error");
|
||||
const latInput = document.getElementById("goto-lat");
|
||||
const lonInput = document.getElementById("goto-lon");
|
||||
const zoomInput = document.getElementById("goto-zoom");
|
||||
const liveCoords = document.getElementById("live-coords");
|
||||
const banner = document.getElementById("banner");
|
||||
const statusPill = document.getElementById("status-pill");
|
||||
|
||||
let map;
|
||||
let sources = [];
|
||||
let styles = [];
|
||||
let activeStyleKey = null;
|
||||
let suppressGotoSync = false;
|
||||
|
||||
function formatStatus(value) {
|
||||
const labels = {
|
||||
Pending: "ожидание",
|
||||
Downloading: "скачивание PBF",
|
||||
PreparingSources: "данные Planetiler",
|
||||
Building: "сборка тайлов",
|
||||
Ready: "готово",
|
||||
Failed: "ошибка"
|
||||
};
|
||||
return labels[value] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "нет";
|
||||
}
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function showBanner(text, visible) {
|
||||
banner.hidden = !visible;
|
||||
banner.textContent = text ?? "";
|
||||
}
|
||||
|
||||
function showGotoError(text) {
|
||||
gotoError.hidden = !text;
|
||||
gotoError.textContent = text ?? "";
|
||||
}
|
||||
|
||||
function parseNumber(value) {
|
||||
const parsed = Number(String(value).trim().replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function cameraFromHash() {
|
||||
const match = location.hash.match(/^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const zoom = Number(match[1]);
|
||||
const lat = Number(match[2]);
|
||||
const lon = Number(match[3]);
|
||||
if (![zoom, lat, lon].every(Number.isFinite)) {
|
||||
return null;
|
||||
}
|
||||
return { zoom, lat, lon };
|
||||
}
|
||||
|
||||
function isGotoInputFocused() {
|
||||
const el = document.activeElement;
|
||||
return el === latInput || el === lonInput || el === zoomInput;
|
||||
}
|
||||
|
||||
function isUninitializedCamera(center, zoom) {
|
||||
return zoom < 2 && Math.abs(center.lat) < 1 && Math.abs(center.lng) < 1;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function currentSource() {
|
||||
return sources.find((item) => item.id === sourceSelect.value) ?? sources[0];
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
liveCoords.innerHTML = `${center.lat.toFixed(5)}°<span class="coords__sep">·</span>${center.lng.toFixed(5)}°<span class="coords__sep">z</span>${zoom.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function syncGotoFromMap() {
|
||||
if (!map || suppressGotoSync || isGotoInputFocused()) {
|
||||
return;
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
if (isUninitializedCamera(center, zoom)) {
|
||||
return;
|
||||
}
|
||||
latInput.value = center.lat.toFixed(5);
|
||||
lonInput.value = center.lng.toFixed(5);
|
||||
zoomInput.value = zoom.toFixed(2);
|
||||
}
|
||||
|
||||
function flyTo(lat, lon, zoom) {
|
||||
showGotoError("");
|
||||
suppressGotoSync = true;
|
||||
map.flyTo({ center: [lon, lat], zoom });
|
||||
map.once("moveend", () => {
|
||||
suppressGotoSync = false;
|
||||
updateHud();
|
||||
syncGotoFromMap();
|
||||
});
|
||||
}
|
||||
|
||||
function readGoto() {
|
||||
const lat = parseNumber(latInput.value);
|
||||
const lon = parseNumber(lonInput.value);
|
||||
const zoom = parseNumber(zoomInput.value);
|
||||
const maxZoom = map.getMaxZoom();
|
||||
|
||||
if (lat === null || lat < -90 || lat > 90) {
|
||||
return { error: "Широта: число от -90 до 90." };
|
||||
}
|
||||
if (lon === null || lon < -180 || lon > 180) {
|
||||
return { error: "Долгота: число от -180 до 180." };
|
||||
}
|
||||
if (zoom === null || zoom < 0 || zoom > maxZoom) {
|
||||
return { error: `Зум: число от 0 до ${maxZoom}.` };
|
||||
}
|
||||
return { lat, lon, zoom };
|
||||
}
|
||||
|
||||
function bindMapEvents() {
|
||||
map.on("move", updateHud);
|
||||
map.on("moveend", syncGotoFromMap);
|
||||
map.on("load", () => {
|
||||
map.resize();
|
||||
updateHud();
|
||||
syncGotoFromMap();
|
||||
});
|
||||
map.on("error", (event) => {
|
||||
const message = event?.error?.message;
|
||||
if (message) {
|
||||
showBanner(message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStyle() {
|
||||
const source = currentSource();
|
||||
const styleName = styleSelect.value;
|
||||
if (!source || !styleName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleKey = `${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`;
|
||||
if (map && activeStyleKey === styleKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = await fetchJson(`/api/v1/styles/${encodeURIComponent(styleName)}?source=${encodeURIComponent(source.id)}`);
|
||||
const hashed = cameraFromHash();
|
||||
const center = hashed
|
||||
? [hashed.lon, hashed.lat]
|
||||
: (source.center ?? [37.6173, 55.7558]);
|
||||
const zoom = hashed ? hashed.zoom : 6;
|
||||
|
||||
if (!map) {
|
||||
map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
maxZoom: 18,
|
||||
hash: true,
|
||||
attributionControl: true,
|
||||
transformRequest: (url) => ({
|
||||
url: new URL(url, window.location.origin).href
|
||||
})
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
|
||||
bindMapEvents();
|
||||
} else {
|
||||
map.setStyle(style, { diff: false });
|
||||
}
|
||||
|
||||
activeStyleKey = styleKey;
|
||||
const ready = source.status === "Ready";
|
||||
showBanner(
|
||||
ready
|
||||
? ""
|
||||
: `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.`,
|
||||
!ready
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const source = currentSource();
|
||||
const status = source?.status;
|
||||
document.getElementById("source-state").textContent = formatStatus(status);
|
||||
document.getElementById("source-downloaded").textContent = formatDate(source?.downloadedAt);
|
||||
document.getElementById("source-built").textContent = formatDate(source?.builtAt);
|
||||
|
||||
statusPill.textContent = formatStatus(status);
|
||||
statusPill.className = "pill";
|
||||
if (status === "Ready") {
|
||||
statusPill.classList.add("pill--ok");
|
||||
} else if (status === "Failed") {
|
||||
statusPill.classList.add("pill--fail");
|
||||
} else {
|
||||
statusPill.classList.add("pill--work");
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [sourceList, styleList, sync] = await Promise.all([
|
||||
fetchJson("/api/v1/sources"),
|
||||
fetchJson("/api/v1/styles"),
|
||||
fetchJson("/api/v1/sync/status")
|
||||
]);
|
||||
|
||||
sources = sourceList;
|
||||
styles = styleList;
|
||||
|
||||
const selectedSource = sourceSelect.value;
|
||||
const selectedStyle = styleSelect.value;
|
||||
sourceSelect.innerHTML = sources.map((item) => `<option value="${item.id}">${item.name}</option>`).join("");
|
||||
styleSelect.innerHTML = styles.map((item) => `<option value="${item.name}">${item.name}</option>`).join("");
|
||||
if (sources.some((item) => item.id === selectedSource)) {
|
||||
sourceSelect.value = selectedSource;
|
||||
}
|
||||
if (styles.some((item) => item.name === selectedStyle)) {
|
||||
styleSelect.value = selectedStyle;
|
||||
} else if (styles.some((item) => item.name === "osm-bright")) {
|
||||
styleSelect.value = "osm-bright";
|
||||
}
|
||||
|
||||
document.getElementById("sync-running").textContent = sync.isRunning ? "идёт синхронизация" : "ожидание";
|
||||
document.getElementById("sync-next").textContent = formatDate(sync.nextScheduledAt);
|
||||
const error = document.getElementById("sync-error");
|
||||
error.hidden = !sync.lastError;
|
||||
error.textContent = sync.lastError ?? "";
|
||||
const workerPanel = document.getElementById("worker-panel");
|
||||
workerPanel.classList.toggle("is-running", Boolean(sync.isRunning));
|
||||
workerPanel.classList.toggle("is-ready", !sync.isRunning && currentSource()?.status === "Ready");
|
||||
renderStatus();
|
||||
await loadStyle();
|
||||
}
|
||||
|
||||
sourceSelect.addEventListener("change", async () => {
|
||||
renderStatus();
|
||||
await loadStyle();
|
||||
const source = currentSource();
|
||||
if (map && source?.center) {
|
||||
flyTo(source.center[1], source.center[0], Math.max(map.getZoom(), 6));
|
||||
}
|
||||
});
|
||||
styleSelect.addEventListener("change", () => loadStyle());
|
||||
gotoForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
if (!map) {
|
||||
showGotoError("Карта ещё не загружена.");
|
||||
return;
|
||||
}
|
||||
const result = readGoto();
|
||||
if (result.error) {
|
||||
showGotoError(result.error);
|
||||
return;
|
||||
}
|
||||
flyTo(result.lat, result.lon, result.zoom);
|
||||
});
|
||||
document.querySelectorAll(".preset").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
flyTo(Number(button.dataset.lat), Number(button.dataset.lon), Number(button.dataset.zoom));
|
||||
});
|
||||
});
|
||||
document.getElementById("fit-bounds").addEventListener("click", () => {
|
||||
const bounds = currentSource()?.bounds;
|
||||
if (!map || !bounds) {
|
||||
showGotoError("У источника ещё нет bounds.");
|
||||
return;
|
||||
}
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds.minLon, bounds.minLat],
|
||||
[bounds.maxLon, bounds.maxLat]
|
||||
],
|
||||
{ padding: 48, maxZoom: 10 }
|
||||
);
|
||||
});
|
||||
syncButton.addEventListener("click", async () => {
|
||||
const source = currentSource();
|
||||
syncButton.disabled = true;
|
||||
try {
|
||||
const url = source ? `/api/v1/sync/${encodeURIComponent(source.id)}` : "/api/v1/sync";
|
||||
await fetchJson(url, { method: "POST" });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showBanner(error.message, true);
|
||||
} finally {
|
||||
syncButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
refresh().catch((error) => showBanner(error.message, true));
|
||||
setInterval(() => {
|
||||
refresh().catch(() => undefined);
|
||||
}, 15000);
|
||||
Reference in New Issue
Block a user