From efe1c3c2dd70f3a4057af4082e1361c0e6c070ec Mon Sep 17 00:00:00 2001 From: FrigaT Date: Fri, 22 May 2026 00:07:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=BE=D1=81=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BC=D0=BE=D0=B9=20=D0=BF=D0=B5=D1=81=D0=BD?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/OgController.cs | 67 ++++++++++++ PlaylistShared.Api/Program.cs | 10 ++ .../Services/Yandex/YandexAuthService.cs | 19 ++-- PlaylistShared.Api/ruvector.db | Bin 0 -> 1589248 bytes .../Components/Common/ShareButton.razor | 2 +- .../Common/TrackCoverWithPlay.razor | 13 ++- .../Components/Common/TrackItem.razor | 17 ++- .../Components/Global/AudioPlayer.razor | 33 +++++- PlaylistShared.Pwa/DarkModeToggle())` | 0 PlaylistShared.Pwa/Layout/MainLayout.razor | 36 ++++++- PlaylistShared.Pwa/Pages/Favorites.razor | 3 +- PlaylistShared.Pwa/Pages/Home.razor | 10 +- PlaylistShared.Pwa/Pages/MyPlaylists.razor | 8 +- PlaylistShared.Pwa/Pages/Profile.razor | 1 - PlaylistShared.Pwa/Pages/Register.razor | 10 +- .../Pages/SharedPlaylistView.razor | 12 ++- PlaylistShared.Pwa/nginx.conf | 37 ++++++- PlaylistShared.Pwa/wwwroot/css/app.css | 97 +++++++++++++++++- PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js | 34 +++++- .../Profile/SetYandexTokenRequest.cs | 2 +- .../SharedPlaylist/AddTrackByLinkRequest.cs | 2 +- .../SharedPlaylist/SharePlaylistDto.cs | 2 +- .../SharedPlaylist/SharePlaylistRequest.cs | 4 +- 23 files changed, 362 insertions(+), 57 deletions(-) create mode 100644 PlaylistShared.Api/Controllers/OgController.cs create mode 100644 PlaylistShared.Api/ruvector.db create mode 100644 PlaylistShared.Pwa/DarkModeToggle())` diff --git a/PlaylistShared.Api/Controllers/OgController.cs b/PlaylistShared.Api/Controllers/OgController.cs new file mode 100644 index 0000000..801ef25 --- /dev/null +++ b/PlaylistShared.Api/Controllers/OgController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Services; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("shared")] +public class OgController : ControllerBase +{ + private readonly SharedPlaylistService _sharedService; + private readonly string _clientBaseUrl; + + public OgController(SharedPlaylistService sharedService, IConfiguration configuration) + { + _sharedService = sharedService; + _clientBaseUrl = configuration["Client:BaseUrl"]?.TrimEnd('/') ?? ""; + } + + [HttpGet("{token}")] + [Produces("text/html")] + public async Task GetOgPage(string token) + { + var entity = await _sharedService.GetEntityByTokenAsync(token); + var pwaUrl = $"{_clientBaseUrl}/shared/{token}"; + + string title = entity?.Title ?? "Playlist Share"; + string description = entity != null + ? $"Слушайте плейлист «{entity.Title}» на Playlist Share" + : "Совместные плейлисты Яндекс.Музыки"; + string imageUrl = !string.IsNullOrEmpty(entity?.CoverUrl) ? entity.CoverUrl : ""; + + var html = $""" + + + + + + {HtmlEncode(title)} + + + + + + {(string.IsNullOrEmpty(imageUrl) ? "" : $"""""")} + + + + {(string.IsNullOrEmpty(imageUrl) ? "" : $"""""")} + + + + + +

Перенаправление на {HtmlEncode(title)}

+ + + """; + + return Content(html, "text/html; charset=utf-8"); + } + + private static string HtmlEncode(string s) => + System.Web.HttpUtility.HtmlAttributeEncode(s); + + private static string JsEncode(string s) => + s.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\""); +} diff --git a/PlaylistShared.Api/Program.cs b/PlaylistShared.Api/Program.cs index c8151bd..1098a2d 100644 --- a/PlaylistShared.Api/Program.cs +++ b/PlaylistShared.Api/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -51,6 +52,14 @@ public class Program options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.MaxAge = TimeSpan.FromDays(30); // persistent across browser restarts + }); + + builder.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownNetworks.Clear(); // trust all proxies in Docker network + options.KnownProxies.Clear(); }); // JWT @@ -129,6 +138,7 @@ public class Program app.MapOpenApi(); + app.UseForwardedHeaders(); app.UseCors("Production"); if (!app.Environment.IsDevelopment()) diff --git a/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs b/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs index 8e3ef42..26f3301 100644 --- a/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs +++ b/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs @@ -44,10 +44,10 @@ public class YandexAuthService internal async Task GenerateQrAsync(ApplicationUser user) { var qr = await Api.Passport.GetAuthQRLinkAsync(); - var trackId = Service.Client.AuthStorage.AuthToken.TrackId; - var csrfToken = Service.Client.AuthStorage.AuthToken.CsfrToken; - var headerProcessUuid = Service.Client.AuthStorage.HeaderToken.ProcessUuid; - var headerCsrfToken = Service.Client.AuthStorage.HeaderToken.CsfrToken; + var trackId = Service.Client.AuthStorage.AuthToken?.TrackId; + var csrfToken = Service.Client.AuthStorage.AuthToken?.CsfrToken; + var headerProcessUuid = Service.Client.AuthStorage.HeaderToken?.ProcessUuid; + var headerCsrfToken = Service.Client.AuthStorage.HeaderToken?.CsfrToken; if (string.IsNullOrEmpty(qr)) throw new Exception("Не удалось получить QR-ссылку"); @@ -93,6 +93,8 @@ public class YandexAuthService Service.Client.AuthStorage.AuthToken.CsfrToken = session.CsrfToken ?? ""; Service.Client.AuthStorage.AuthToken.TrackId = session.TrackId ?? ""; + if (Service.Client.AuthStorage.HeaderToken is null) + Service.Client.AuthStorage.HeaderToken = new(); Service.Client.AuthStorage.HeaderToken.CsfrToken = session.HeaderCsrfToken ?? ""; Service.Client.AuthStorage.HeaderToken.ProcessUuid = session.HeaderProcessId ?? ""; @@ -131,15 +133,16 @@ public class YandexAuthService private void RestoreCookies(CookieContainer container, string serializedCookies) { var cookies = JsonSerializer.Deserialize>(serializedCookies); + if (cookies == null) return; foreach (var c in cookies) container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain)); } private class CookieData { - public string Name { get; set; } - public string Value { get; set; } - public string Domain { get; set; } - public string Path { get; set; } + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; } } diff --git a/PlaylistShared.Api/ruvector.db b/PlaylistShared.Api/ruvector.db new file mode 100644 index 0000000000000000000000000000000000000000..f374a05fb18bcfda77304bbc0ffe28591a53ab7f GIT binary patch literal 1589248 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 zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C74j}NMsCzx48u9QT zKOX7Lcej_fYCHKs(LGP}Z8&mn+=devu9f4B+i(KIwQ{_18%|)jR*pAr!wC%6%JDXw NTTEXp1PJUy;J+{2GQ$7> literal 0 HcmV?d00001 diff --git a/PlaylistShared.Pwa/Components/Common/ShareButton.razor b/PlaylistShared.Pwa/Components/Common/ShareButton.razor index eee5d15..7115315 100644 --- a/PlaylistShared.Pwa/Components/Common/ShareButton.razor +++ b/PlaylistShared.Pwa/Components/Common/ShareButton.razor @@ -14,7 +14,7 @@ Paper="true"> Ссылка для приглашения: - + - + - - @if (CanPlay && (_isHovered || IsCurrentTrackPlaying)) + + @if (CanPlay) { - - + - + } diff --git a/PlaylistShared.Pwa/Components/Common/TrackItem.razor b/PlaylistShared.Pwa/Components/Common/TrackItem.razor index 3c97eee..8ca2f07 100644 --- a/PlaylistShared.Pwa/Components/Common/TrackItem.razor +++ b/PlaylistShared.Pwa/Components/Common/TrackItem.razor @@ -3,7 +3,7 @@ @using PlaylistShared.Pwa.Extensions @using PlaylistShared.Shared.Yandex - + - @Track.DurationMs.FormatDuration() + @if (IsCurrentTrack) + { + + } + @Track.DurationMs.FormatDuration() + + @code { [Parameter] public YandexTrack Track { get; set; } = null!; [Parameter] public string PlaylistShareToken { get; set; } = string.Empty; [Parameter] public bool CanPlay { get; set; } = true; [Parameter] public string? AddedByName { get; set; } + [Parameter] public bool IsCurrentTrack { get; set; } } \ No newline at end of file diff --git a/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor b/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor index 0f3c98a..c8de414 100644 --- a/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor +++ b/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor @@ -9,7 +9,7 @@ @implements IDisposable @implements IAsyncDisposable - + - @if (!string.IsNullOrEmpty(AudioPlayerService.CurrentTrack?.CoverUri)) @@ -125,7 +123,6 @@ private double _volumeBeforeMute; private double _bufferSecond; - private bool _isPlayHovered; protected override async Task OnInitializedAsync() { @@ -179,6 +176,29 @@ { _bufferSecond = second; } + + [JSInvokable] + public async Task OnKeyboardTogglePlay() + { + if (AudioPlayerService.IsPlaying) + await AudioPlayerService.PauseAsync(); + else + await AudioPlayerService.PlayAsync(); + } + + [JSInvokable] + public async Task OnKeyboardSeek(double delta) + { + var newTime = Math.Max(0, AudioPlayerService.CurrentTime + delta); + await AudioPlayerService.SeekToAsync(newTime); + } + + [JSInvokable] + public async Task OnKeyboardVolumeChange(double delta) + { + var newVol = Math.Clamp(AudioPlayerService.CurrentVolume + delta, 0, 100); + await AudioPlayerService.SetVolumeAsync(newVol); + } #endregion #region Обработка сервиса @@ -307,7 +327,10 @@ AudioPlayerService.OnStateChanged -= OnServiceStateChanged; if (_audioElement != null) + { + try { await _audioElement.InvokeVoidAsync("destroy"); } catch { } await _audioElement.DisposeAsync(); + } if (_audioModule != null) await _audioModule.DisposeAsync(); } diff --git a/PlaylistShared.Pwa/DarkModeToggle())` b/PlaylistShared.Pwa/DarkModeToggle())` new file mode 100644 index 0000000..e69de29 diff --git a/PlaylistShared.Pwa/Layout/MainLayout.razor b/PlaylistShared.Pwa/Layout/MainLayout.razor index 3e7b50d..cee5c45 100644 --- a/PlaylistShared.Pwa/Layout/MainLayout.razor +++ b/PlaylistShared.Pwa/Layout/MainLayout.razor @@ -3,9 +3,11 @@ @inherits LayoutComponentBase @inject PwaUpdateService PwaUpdateService @inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager @inject ContextualActionBarService ContextualActionBarService @inject IBrowserViewportService BrowserViewportService @implements IBrowserViewportObserver +@implements IDisposable @@ -23,7 +25,7 @@ { - + Git @@ -68,6 +70,22 @@ }; ContextualActionBarService.OnChanged += OnContextualChangedHandler; + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + { + if (_isMobile) + { + _drawerOpen = false; + InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + ContextualActionBarService.OnChanged -= OnContextualChangedHandler; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -77,6 +95,13 @@ _dotNetRef = DotNetObjectReference.Create(PwaUpdateService); await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef); await BrowserViewportService.SubscribeAsync(this, fireImmediately: true); + + var savedTheme = await JSRuntime.InvokeAsync("localStorage.getItem", "theme"); + if (savedTheme != null) + { + _isDarkMode = savedTheme != "light"; + StateHasChanged(); + } } } @@ -92,9 +117,10 @@ _drawerOpen = !_drawerOpen; } - private void DarkModeToggle() + private async Task DarkModeToggle() { _isDarkMode = !_isDarkMode; + await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", _isDarkMode ? "dark" : "light"); } private readonly PaletteLight _lightPalette = new() @@ -154,8 +180,14 @@ Task IBrowserViewportObserver.NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs) { + var wasMobile = _isMobile; _isMobile = browserViewportEventArgs.Breakpoint <= Breakpoint.Sm; + if (!wasMobile && _isMobile) + _drawerOpen = false; + else if (wasMobile && !_isMobile) + _drawerOpen = true; + return InvokeAsync(StateHasChanged); } } diff --git a/PlaylistShared.Pwa/Pages/Favorites.razor b/PlaylistShared.Pwa/Pages/Favorites.razor index 5481499..60cbc8c 100644 --- a/PlaylistShared.Pwa/Pages/Favorites.razor +++ b/PlaylistShared.Pwa/Pages/Favorites.razor @@ -47,8 +47,7 @@ + OnClick="() => RemoveFromFavorites(context)" /> diff --git a/PlaylistShared.Pwa/Pages/Home.razor b/PlaylistShared.Pwa/Pages/Home.razor index 67f436e..277c1d9 100644 --- a/PlaylistShared.Pwa/Pages/Home.razor +++ b/PlaylistShared.Pwa/Pages/Home.razor @@ -3,7 +3,7 @@ @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthProvider - + @@ -26,7 +26,7 @@
- + Чтобы расшаривать плейлисты, необходимо зарегистрироваться @@ -45,19 +45,19 @@ - + Создавайте ссылки-приглашения - + Совместное управление треками - + Гибкие настройки доступа diff --git a/PlaylistShared.Pwa/Pages/MyPlaylists.razor b/PlaylistShared.Pwa/Pages/MyPlaylists.razor index 6c73326..7b4642f 100644 --- a/PlaylistShared.Pwa/Pages/MyPlaylists.razor +++ b/PlaylistShared.Pwa/Pages/MyPlaylists.razor @@ -17,7 +17,7 @@ - + @@ -71,11 +71,11 @@ @code { - private List _playlists; + private List _playlists = new(); private bool _loading = true; private bool _showOnlyShared = false; - private List FilteredPlaylists => _showOnlyShared ? _playlists?.Where(p => p.IsShared).ToList() : _playlists; + private List FilteredPlaylists => _showOnlyShared ? _playlists.Where(p => p.IsShared).ToList() : _playlists; protected override async Task OnInitializedAsync() { @@ -89,7 +89,7 @@ { var response = await Http.GetFromJsonAsync>>("/api/playlists"); if (response?.Success == true) - _playlists = response.Data; + _playlists = response.Data ?? new(); else Snackbar.Add("Ошибка загрузки плейлистов", Severity.Error); } diff --git a/PlaylistShared.Pwa/Pages/Profile.razor b/PlaylistShared.Pwa/Pages/Profile.razor index d7e5da3..0ec378c 100644 --- a/PlaylistShared.Pwa/Pages/Profile.razor +++ b/PlaylistShared.Pwa/Pages/Profile.razor @@ -48,7 +48,6 @@ @code { - private string _email = "user@example.com"; private string _statusText = "Загрузка..."; private bool _hasToken; diff --git a/PlaylistShared.Pwa/Pages/Register.razor b/PlaylistShared.Pwa/Pages/Register.razor index cc1d90b..555ca8d 100644 --- a/PlaylistShared.Pwa/Pages/Register.razor +++ b/PlaylistShared.Pwa/Pages/Register.razor @@ -43,7 +43,7 @@ var result = await response.Content.ReadFromJsonAsync>(); if (result?.Success == true) { - await AuthProvider.MarkUserAsAuthenticated(result.Data.Token, result.Data.RefreshToken); + await AuthProvider.MarkUserAsAuthenticated(result.Data!.Token, result.Data.RefreshToken); Navigation.NavigateTo("/"); } else @@ -60,9 +60,9 @@ public class RegisterModel { - public string Username { get; set; } - public string Email { get; set; } - public string Password { get; set; } - public string ConfirmPassword { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor index eccefb5..9fc1bcf 100644 --- a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor +++ b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor @@ -249,14 +249,16 @@ } else { - @if (_canRemove) @@ -508,7 +510,7 @@ /// Состояние: Происходит поиск. private bool _isSearching = false; /// Ссылка на поле ввода - private MudTextField _searchField; + private MudTextField _searchField = null!; /// Результат поиска. private YandexSearchResult? _searchResult = null; /// Текст фильтра для результатов поиска diff --git a/PlaylistShared.Pwa/nginx.conf b/PlaylistShared.Pwa/nginx.conf index b0507ec..cc0d615 100644 --- a/PlaylistShared.Pwa/nginx.conf +++ b/PlaylistShared.Pwa/nginx.conf @@ -5,7 +5,7 @@ events { http { include /etc/nginx/mime.types; default_type application/octet-stream; - + # Не раскрывайть версию Nginx в ответах. server_tokens off; @@ -18,6 +18,29 @@ http { gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/wasm application/json; + # Бэкенд API (для OG-тегов) + upstream api_backend { + server playlistshared_api:80; + } + + # Определяем, является ли запрос ботом-краулером + map $http_user_agent $is_crawler { + default 0; + ~*Slackbot 1; + ~*facebookexternalhit 1; + ~*Twitterbot 1; + ~*TelegramBot 1; + ~*WhatsApp 1; + ~*LinkedInBot 1; + ~*vkShare 1; + ~*Pinterest 1; + ~*Googlebot 1; + ~*YandexBot 1; + ~*Discordbot 1; + ~*Applebot 1; + ~*DuckDuckBot 1; + } + server { listen 80; server_name localhost; @@ -54,6 +77,18 @@ http { try_files $uri =404; } + # OG-теги: краулеры получают HTML с meta-тегами от API + location ~ ^/shared/(.+)$ { + if ($is_crawler) { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_pass http://api_backend; + } + try_files $uri $uri/ /index.html?$args; + } + # Основной SPA fallback: все неизвестные пути отдаём через index.html location / { try_files $uri $uri/ /index.html?$args; diff --git a/PlaylistShared.Pwa/wwwroot/css/app.css b/PlaylistShared.Pwa/wwwroot/css/app.css index 516cfba..7b9dbd2 100644 --- a/PlaylistShared.Pwa/wwwroot/css/app.css +++ b/PlaylistShared.Pwa/wwwroot/css/app.css @@ -113,15 +113,108 @@ code { .horizontal-scroll { overflow-x: auto; scroll-snap-type: x mandatory; - overflow-y: hidden; /* отключаем вертикальный скролл */ + overflow-y: hidden; cursor: grab; } .horizontal-scroll:active { cursor: grabbing; } -/* Для WebKit (Chrome, Edge, Safari) можно включить горизонтальный скролл мышью */ + .horizontal-scroll { scrollbar-width: thin; -webkit-overflow-scrolling: touch; +} + +/* ===== Animations ===== */ + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUpFade { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes equalizerPulse { + 0%, 100% { transform: scaleY(0.5) translateY(3px); } + 50% { transform: scaleY(1) translateY(0); } +} + +@keyframes playerSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Fade-in for content that loads */ +.content-fade-in { + animation: fadeIn 0.3s ease-out; +} + +/* Track items slide up when list loads */ +.tracks-slide-in { + animation: slideUpFade 0.25s ease-out; +} + +/* Equalizer icon bounce */ +.eq-animate { + display: inline-block; + transform-origin: bottom center; + animation: equalizerPulse 0.55s ease-in-out infinite alternate; +} + +/* Feature cards on home page */ +.feature-card { + transition: transform 0.2s ease, box-shadow 0.2s ease !important; + cursor: default; +} + +.feature-card:hover { + transform: translateY(-3px) !important; + box-shadow: 0 6px 20px rgba(126, 111, 255, 0.18) !important; +} + +/* Audio player entrance */ +.player-enter { + animation: playerSlideUp 0.3s ease-out; +} + +/* Play overlay — opacity-based show/hide */ +.play-overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0; + transition: opacity 0.2s ease; + cursor: pointer; +} + +.play-overlay.play-overlay--visible { + opacity: 1; +} + +/* Touch devices: play overlay always visible */ +@media (hover: none) { + .play-overlay { + opacity: 1; + } +} + +/* Current track — smooth highlight transition */ +.current-track { + transition: background 0.35s ease; +} + +/* Mobile padding tightening */ +@media (max-width: 599px) { + .mud-container { + padding-left: 6px !important; + padding-right: 6px !important; + } } \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js index 9f97393..81a006f 100644 --- a/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js +++ b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js @@ -58,6 +58,36 @@ } }); - // Возвращаем все методы, которые будут вызываться из C# - return { loadAndPlay, play, pause, stop, setVolume, setCurrentTime }; + const handleKeyDown = (e) => { + const tag = e.target.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return; + + switch (e.key) { + case ' ': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('OnKeyboardTogglePlay'); + break; + case 'ArrowLeft': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('OnKeyboardSeek', -10); + break; + case 'ArrowRight': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('OnKeyboardSeek', 10); + break; + case 'ArrowUp': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('OnKeyboardVolumeChange', 5); + break; + case 'ArrowDown': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('OnKeyboardVolumeChange', -5); + break; + } + }; + window.addEventListener('keydown', handleKeyDown); + + const destroy = () => window.removeEventListener('keydown', handleKeyDown); + + return { loadAndPlay, play, pause, stop, setVolume, setCurrentTime, destroy }; } \ No newline at end of file diff --git a/PlaylistShared.Shared/Profile/SetYandexTokenRequest.cs b/PlaylistShared.Shared/Profile/SetYandexTokenRequest.cs index 8e0facb..637b1c9 100644 --- a/PlaylistShared.Shared/Profile/SetYandexTokenRequest.cs +++ b/PlaylistShared.Shared/Profile/SetYandexTokenRequest.cs @@ -2,5 +2,5 @@ public class SetYandexTokenRequest { - public string Token { get; set; } + public string Token { get; set; } = string.Empty; } diff --git a/PlaylistShared.Shared/SharedPlaylist/AddTrackByLinkRequest.cs b/PlaylistShared.Shared/SharedPlaylist/AddTrackByLinkRequest.cs index dd5adc1..caf2d03 100644 --- a/PlaylistShared.Shared/SharedPlaylist/AddTrackByLinkRequest.cs +++ b/PlaylistShared.Shared/SharedPlaylist/AddTrackByLinkRequest.cs @@ -2,5 +2,5 @@ public class AddTrackByLinkRequest { - public string Link { get; set; } + public string Link { get; set; } = string.Empty; } \ No newline at end of file diff --git a/PlaylistShared.Shared/SharedPlaylist/SharePlaylistDto.cs b/PlaylistShared.Shared/SharedPlaylist/SharePlaylistDto.cs index 758bdd0..31bd413 100644 --- a/PlaylistShared.Shared/SharedPlaylist/SharePlaylistDto.cs +++ b/PlaylistShared.Shared/SharedPlaylist/SharePlaylistDto.cs @@ -36,7 +36,7 @@ public class SharePlaylistDto /// Токен для расшаривания плейлиста. [JsonPropertyName("shareToken")] - public string ShareToken { get; set; } + public string ShareToken { get; set; } = string.Empty; /// Права на просмотр. [JsonPropertyName("viewPermission")] diff --git a/PlaylistShared.Shared/SharedPlaylist/SharePlaylistRequest.cs b/PlaylistShared.Shared/SharedPlaylist/SharePlaylistRequest.cs index 6f440b2..6c69e8e 100644 --- a/PlaylistShared.Shared/SharedPlaylist/SharePlaylistRequest.cs +++ b/PlaylistShared.Shared/SharedPlaylist/SharePlaylistRequest.cs @@ -2,6 +2,6 @@ public class SharePlaylistRequest { - public string Kind { get; set; } - public string OwnerUid { get; set; } + public string Kind { get; set; } = string.Empty; + public string OwnerUid { get; set; } = string.Empty; } \ No newline at end of file