Compare commits

...

4 Commits

7 changed files with 371 additions and 115 deletions

View File

@@ -11,7 +11,7 @@
} }
else else
{ {
<p role="alert">You are not authorized to access this resource.</p> <p role="alert">У вас нет прав доступа к этому ресурсу.</p>
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>

View File

@@ -0,0 +1,21 @@
@implements IDisposable
@inject ContextualActionBarService ContextualActionBarService
@code {
[Parameter] public ContextualActionBarPosition Position { get; set; } = ContextualActionBarPosition.Default;
[Parameter] public RenderFragment? ChildContent { get; set; }
protected override void OnParametersSet()
{
ContextualActionBarService.Content = ChildContent;
ContextualActionBarService.Position = Position;
ContextualActionBarService.ChangeParameters();
}
public void Dispose()
{
ContextualActionBarService.Content = null;
ContextualActionBarService.Position = ContextualActionBarPosition.Default;
ContextualActionBarService.ChangeParameters();
}
}

View File

@@ -1,7 +1,11 @@
@using MudBlazor.Services
@using PlaylistShared.Pwa.Components.Global @using PlaylistShared.Pwa.Components.Global
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject PwaUpdateService PwaUpdateService @inject PwaUpdateService PwaUpdateService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject ContextualActionBarService ContextualActionBarService
@inject IBrowserViewportService BrowserViewportService
@implements IBrowserViewportObserver
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" /> <MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
<MudPopoverProvider /> <MudPopoverProvider />
@@ -9,32 +13,43 @@
<MudSnackbarProvider /> <MudSnackbarProvider />
<MudLayout> <MudLayout>
<MudAppBar Elevation="1"> <MudAppBar Elevation="1" Contextual Bottom = "@_actionBarBottom" Fixed>
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@DrawerToggle" /> <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@DrawerToggle" />
@if (_actionBarContent != null)
{
@_actionBarContent
}
else
{
<MudSpacer /> <MudSpacer />
<LoginDisplay /> <LoginDisplay />
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" Class="ml-2" /> <MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" Class="ml-2" />
<MudLink Href="https://git.frigat.duckdns.org/FrigaT/PlaylistShared" Target="_blank" Color="Color.Inherit" Underline="Underline.None" Class="ml-4"> <MudLink Href="https://git.frigat.duckdns.org/FrigaT/PlaylistShared" Target="_blank" Color="Color.Inherit" Underline="Underline.None" Class="ml-4">
<MudIcon Icon="@Icons.Custom.Brands.GitHub" Size="Size.Small" Class="mr-1" /> Git <MudIcon Icon="@Icons.Custom.Brands.GitHub" Size="Size.Small" Class="mr-1" /> Git
</MudLink> </MudLink>
}
</MudAppBar> </MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2"> <MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
<NavMenu /> <NavMenu />
</MudDrawer> </MudDrawer>
<MudMainContent Class="pt-16 d-flex flex-column" Style="height: 100vh;"> <MudMainContent Class="@("d-flex flex-column" + (_actionBarBottom ? " pt-0 pb-16" : ""))" Style="height: 100dvh;">
<MudItem Class="flex-grow-1 overflow-y-auto">
@Body @Body
</MudItem>
<MudItem>
<AudioPlayer />
</MudItem>
</MudMainContent> </MudMainContent>
</MudLayout> </MudLayout>
@code { @code {
private RenderFragment? _actionBarContent;
private bool _actionBarBottom => _contextualPosition switch
{
ContextualActionBarPosition.Bottom => true,
ContextualActionBarPosition.Top => false,
_ => _isMobile,
};
private bool _isMobile = false;
private ContextualActionBarPosition _contextualPosition = ContextualActionBarPosition.Default;
private bool _drawerOpen = true; private bool _drawerOpen = true;
private bool _isDarkMode = true; private bool _isDarkMode = true;
private MudTheme? _theme; private MudTheme? _theme;
@@ -51,6 +66,8 @@
PaletteDark = _darkPalette, PaletteDark = _darkPalette,
LayoutProperties = new LayoutProperties() LayoutProperties = new LayoutProperties()
}; };
ContextualActionBarService.OnChanged += OnContextualChangedHandler;
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -59,9 +76,17 @@
{ {
_dotNetRef = DotNetObjectReference.Create(PwaUpdateService); _dotNetRef = DotNetObjectReference.Create(PwaUpdateService);
await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef); await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef);
await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
} }
} }
private void OnContextualChangedHandler()
{
_actionBarContent = ContextualActionBarService.Content;
_contextualPosition = ContextualActionBarService.Position;
StateHasChanged();
}
private void DrawerToggle() private void DrawerToggle()
{ {
_drawerOpen = !_drawerOpen; _drawerOpen = !_drawerOpen;
@@ -116,4 +141,21 @@
true => Icons.Material.Rounded.AutoMode, true => Icons.Material.Rounded.AutoMode,
false => Icons.Material.Outlined.DarkMode, false => Icons.Material.Outlined.DarkMode,
}; };
Guid IBrowserViewportObserver.Id { get; } = Guid.NewGuid();
ResizeOptions IBrowserViewportObserver.ResizeOptions { get; } = new()
{
ReportRate = 250,
NotifyOnBreakpointOnly = true
};
Task IBrowserViewportObserver.NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs)
{
_isMobile = browserViewportEventArgs.Breakpoint <= Breakpoint.Sm;
return InvokeAsync(StateHasChanged);
}
} }

View File

@@ -2,6 +2,7 @@
<PageTitle>@_playlist?.Title - Playlist Share</PageTitle> <PageTitle>@_playlist?.Title - Playlist Share</PageTitle>
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Pwa.Components.Global
@using PlaylistShared.Pwa.Components.SharedPlaylist @using PlaylistShared.Pwa.Components.SharedPlaylist
@using PlaylistShared.Pwa.Components.SharedPlaylist.Cards @using PlaylistShared.Pwa.Components.SharedPlaylist.Cards
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@@ -16,20 +17,48 @@
@inject IDialogService DialogService @inject IDialogService DialogService
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pa-1" Style="height: 100%;"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pa-1" Style="height: 100%;">
<MudStack Style="height: 100%;" StretchItems="StretchItems.Start" Spacing="2">
@*Первый элемент растянется на всю высоту*@
<MudItem Style="min-height: 0; height: 100%;">
@* --- ВЕРСИЯ ДЛЯ ПК (сетка) --- *@ @* --- ВЕРСИЯ ДЛЯ ПК (сетка) --- *@
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert> <MudHidden Breakpoint="Breakpoint.MdAndUp" Invert>
<MudGrid Spacing="2" Class="flex-grow-1 pt-2" Style="height: 100%;"> <MudGrid Spacing="2" Class="flex-grow-1 pt-2" Style="height: 100%;">
<MudItem xs="12" md="6" Style="height: 100%;"> <MudItem xs="12" md="6" Style="height: 100%; overflow-y: auto;">
@PlaylistCardContent <MudCard Elevation="4" Class="d-flex flex-column" Style="height: 100%;">
<MudCardHeader Class="pb-0">
<CardHeaderContent>
@PlaylistCardHeaderContent
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@(_isFavorite? Icons.Material.Filled.Star : Icons.Material.Outlined.StarBorder)"
Color="Color.Warning"
OnClick="ToggleFavorite"
Disabled="_favoriteLoading"
Size="Size.Medium" />
@if (_isCreator && _isAuthenticated)
{
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="OpenPermissionsDialog" Size="Size.Medium" />
}
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="@_tracksLoading" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column py-0">
@PlaylistCardBodyContent
</MudCardContent>
</MudCard>
</MudItem> </MudItem>
@if (_canAdd) @if (_canAdd)
{ {
<MudItem xs="12" md="6" Style="height: 100%;"> <MudItem xs="12" md="6" Style="height: 100%; overflow-y: auto;">
@AddTrackCardContent @AddTrackCardContent
</MudItem> </MudItem>
} }
</MudGrid> </MudGrid>
<ContextualBarContent />
</MudHidden> </MudHidden>
@* --- ВЕРСИЯ ДЛЯ МОБИЛОК (вкладки внизу) --- *@ @* --- ВЕРСИЯ ДЛЯ МОБИЛОК (вкладки внизу) --- *@
@@ -40,7 +69,17 @@
<div class="flex-grow-1 relative pa-0" style="min-height: 0;"> <div class="flex-grow-1 relative pa-0" style="min-height: 0;">
<div class="@(_activeMobileTab == 0 ? "d-flex" : "d-none") flex-column" style="height: 100%;"> <div class="@(_activeMobileTab == 0 ? "d-flex" : "d-none") flex-column" style="height: 100%;">
<div class="flex-grow-1 overflow-auto pb-1"> <div class="flex-grow-1 overflow-auto pb-1">
@PlaylistCardContent <MudCard Elevation="0" Class="d-flex flex-column" Style="height: 100%;">
<MudCardHeader Class="pb-0">
<CardHeaderContent>
@PlaylistCardHeaderContent
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column py-0">
@PlaylistCardBodyContent
</MudCardContent>
</MudCard>
</div> </div>
</div> </div>
@@ -55,21 +94,44 @@
</div> </div>
@* Кастомная панель навигации внизу *@ @* Кастомная панель навигации внизу *@
@if (_canAdd) <ContextualBarContent>
{
<MudPaper Elevation="0" Class="py-1">
<MudNavMenu Margin="Margin.None" Class="d-flex flex-row justify-space-around">
<MudIconButton Icon="@Icons.Material.Filled.LibraryMusic" <MudIconButton Icon="@Icons.Material.Filled.LibraryMusic"
Color="@(_activeMobileTab == 0 ? Color.Primary : Color.Default)" Color="@(_activeMobileTab == 0 ? Color.Primary : Color.Default)"
OnClick="() => _activeMobileTab = 0" /> OnClick="() => _activeMobileTab = 0" />
<MudIconButton Icon="@Icons.Material.Filled.AddCircle" <MudIconButton Icon="@Icons.Material.Filled.AddCircle"
Color="@(_activeMobileTab == 1 ? Color.Primary : Color.Default)" Color="@(_activeMobileTab == 1 ? Color.Primary : Color.Default)"
OnClick="() => _activeMobileTab = 1" /> OnClick="() => _activeMobileTab = 1" />
</MudNavMenu>
</MudPaper> <MudSpacer />
<MudMenu Icon="@Icons.Material.Filled.MoreVert"
AnchorOrigin="Origin.TopRight"
TransformOrigin="Origin.TopRight">
<MudMenuItem Icon="@(_isFavorite? Icons.Material.Filled.Star : Icons.Material.Outlined.StarBorder)"
IconColor="Color.Warning"
Label="Избранное"
OnClick="ToggleFavorite"
Disabled="@_favoriteLoading"
/>
@if (_isCreator && _isAuthenticated)
{
<MudMenuItem Icon="@Icons.Material.Filled.Settings"
OnClick="OpenPermissionsDialog"
Label="Настройки" />
} }
</MudMenu>
</ContextualBarContent>
</div> </div>
</MudHidden> </MudHidden>
</MudItem>
@*Второй элемент - плеер. Привязан к нижней части контейнера*@
<MudItem>
<AudioPlayer />
</MudItem>
</MudStack>
</MudContainer> </MudContainer>
<style> <style>
@@ -94,26 +156,33 @@
/// <summary>Токен расшаренного плейлиста</summary> /// <summary>Токен расшаренного плейлиста</summary>
[Parameter] public required string Token { get; set; } [Parameter] public required string Token { get; set; }
private RenderFragment PlaylistCardContent => __builder =>
/// <summary>Элемент: заголовок плейлиста</summary>
private RenderFragment PlaylistCardHeaderContent => __builder =>
{ {
<MudCard Elevation="0" Class="d-flex flex-column" Style="height: 100%;">
<MudCardHeader>
<CardHeaderContent>
@if (_loading) @if (_loading)
{ {
<MudSkeleton Width="200px" Height="32px" SkeletonType="SkeletonType.Text" /> <MudSkeleton Width="200px" Height="32px" SkeletonType="SkeletonType.Text" />
} }
else else
{ {
<PlaylistHeader Playlist="@_playlist" />
}
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="@_tracksLoading" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column"> <MudStack Row AlignItems="AlignItems.Center">
@if (!string.IsNullOrEmpty(_playlist?.CoverUrl))
{
<MudImage Src="@_playlist.CoverUrl.FormatCoverUrl(60, 60)" Height="60" Width="60" Class="rounded shadow-sm" />
}
<MudText Typo="Typo.h6" Color="Color.Primary">
@_playlist?.Title
</MudText>
</MudStack>
}
};
/// <summary>Элемент: треки плейлиста</summary>
private RenderFragment PlaylistCardBodyContent => __builder =>
{
@if (_loading || _tracksLoading) @if (_loading || _tracksLoading)
{ {
<TrackItemSkeleton /> <TrackItemSkeleton />
@@ -143,15 +212,14 @@
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
} }
</MudCardContent>
</MudCard>
}; };
/// <summary>Элемент: блок добавления треков</summary>
private RenderFragment AddTrackCardContent => __builder => private RenderFragment AddTrackCardContent => __builder =>
{ {
<MudCard Class="d-flex flex-column" Elevation="0" Style="height: 100%;"> <MudCard Class="d-flex flex-column" Elevation="0" Style="height: 100%;">
<MudCardHeader> <MudCardHeader Class="pb-0">
<MudStack Style="width: 100%;"> <MudStack Style="width: 100%;" Spacing="0">
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-4">Добавление треков</MudText> <MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-4">Добавление треков</MudText>
<MudTextField @bind-Value="_searchQuery" <MudTextField @bind-Value="_searchQuery"
@bind-Value:after="OnSearchQueryChanged" @bind-Value:after="OnSearchQueryChanged"
@@ -177,7 +245,7 @@
</MudStack> </MudStack>
</MudCardHeader> </MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column"> <MudCardContent Class="flex-grow-1 overflow-auto flex-column py-0">
@if (_isSearching) @if (_isSearching)
{ {
@@ -324,10 +392,17 @@
private bool _isSearching = false; private bool _isSearching = false;
/// <summary>Ссылка на поле ввода</summary> /// <summary>Ссылка на поле ввода</summary>
private MudTextField<string> _searchField; private MudTextField<string> _searchField;
/// <summary>Результат поиска.</summary> /// <summary>Результат поиска.</summary>
private YandexSearchResult? _searchResult = null; private YandexSearchResult? _searchResult = null;
/********************************
* Вкладка добавления треков
*********************************/
/// <summary>Признак, что альбом в фаворитах.</summary>
private bool _isFavorite;
/// <summary>Загрузка признака "фаворит".</summary>
private bool _favoriteLoading;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthProvider.GetAuthenticationStateAsync(); var authState = await AuthProvider.GetAuthenticationStateAsync();
@@ -378,6 +453,7 @@
_activeMobileTab = 0; _activeMobileTab = 0;
await ConfigurePermissions(); await ConfigurePermissions();
await CheckFavoriteStatus();
} }
else else
{ {
@@ -629,4 +705,91 @@
throw new ArgumentException("Unsupported URL pattern"); throw new ArgumentException("Unsupported URL pattern");
} }
#endregion #endregion
#region Избранное / Фаворит
/// <summary>Установка галочки "избранное"</summary>
private async Task CheckFavoriteStatus()
{
if (string.IsNullOrWhiteSpace(Token)) return;
try
{
var response = await Http.GetFromJsonAsync<ApiResponse<bool>>($"/api/favorites/{Token}/check");
if (response?.Success == true)
_isFavorite = response.Data;
}
catch { }
}
private async Task ToggleFavorite()
{
if (string.IsNullOrWhiteSpace(Token)) return;
if (!_isAuthenticated)
{
Snackbar.Add("Добавление в избранное только авторизованным пользователям", Severity.Warning);
return;
}
_favoriteLoading = true;
try
{
if (_isFavorite)
{
var response = await Http.DeleteAsync($"/api/favorites/{Token}");
if (response.IsSuccessStatusCode)
{
_isFavorite = false;
Snackbar.Add("Плейлист удалён из избранного", Severity.Success);
}
else
{
Snackbar.Add("Ошибка удаления из избранного", Severity.Error);
}
}
else
{
var response = await Http.PostAsync($"/api/favorites/{Token}", null);
if (response.IsSuccessStatusCode)
{
_isFavorite = true;
Snackbar.Add("Плейлист добавлен в избранное", Severity.Success);
}
else
{
Snackbar.Add("Ошибка добавления в избранное", Severity.Error);
}
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
}
finally
{
_favoriteLoading = false;
StateHasChanged();
}
}
#endregion
#region Настройка доступов к плейлисту
private async Task OpenPermissionsDialog()
{
if (_playlist == null) return;
var initialPermissions = new UpdatePermissionsDto
{
ViewPermission = _playlist.ViewPermission,
PlayPermission = _playlist.PlayPermission,
AddPermission = _playlist.AddPermission,
RemovePermission = _playlist.RemovePermission
};
var parameters = new DialogParameters
{
{ nameof(PermissionsDialog.ShareToken), Token },
{ nameof(PermissionsDialog.InitialPermissions), initialPermissions }
};
var dialog = await DialogService.ShowAsync<PermissionsDialog>("Настройки доступа", parameters);
var result = await dialog.Result;
}
#endregion
} }

View File

@@ -25,6 +25,7 @@ internal class Program
builder.Services.AddScoped<PlayerStorage>(); builder.Services.AddScoped<PlayerStorage>();
builder.Services.AddScoped<AuthStateProvider>(); builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>()); builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<ContextualActionBarService>();
builder.Services.AddScoped<ApiClient>(); builder.Services.AddScoped<ApiClient>();
builder.Services.AddScoped<IAudioPlayerService, AudioPlayerService>(); builder.Services.AddScoped<IAudioPlayerService, AudioPlayerService>();

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Components;
namespace PlaylistShared.Pwa.Services;
public class ContextualActionBarService
{
// Событие, которое будет вызываться при изменении содержимого панели
public event Action? OnChanged;
public RenderFragment? Content { get; set; } = null;
public ContextualActionBarPosition Position { get; set; } = ContextualActionBarPosition.Default;
public void ChangeParameters()
{
OnChanged?.Invoke();
}
}
public enum ContextualActionBarPosition
{
Default,
Top,
Bottom,
}

View File

@@ -3,11 +3,15 @@
"short_name": "PlaylistShare", "short_name": "PlaylistShare",
"id": "./", "id": "./",
"start_url": "./", "start_url": "./",
"scope": "./", "scope": "/",
"handle_links": "preferred", "handle_links": "preferred",
"display": "standalone", "display": "standalone",
"background_color": "#1a1a27", "background_color": "#1a1a27",
"theme_color": "#7e6fff", "theme_color": "#7e6fff",
"launch_handler": {
"client_mode": "focus-existing"
},
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [
{ {