Compare commits

...
Sign in to create a new pull request.

8 commits

14 changed files with 201 additions and 48 deletions

View file

@ -1102,14 +1102,21 @@ public class OpdsController : BaseApiController
Response.AddCacheHeader(content);
// Save progress for the user
await _readerService.SaveReadingProgress(new ProgressDto()
var userAgent = Request.Headers["User-Agent"].ToString();
Console.WriteLine("User Agent: " + userAgent);
if (!userAgent.Contains("panels", StringComparison.InvariantCultureIgnoreCase))
{
ChapterId = chapterId,
PageNum = pageNumber,
SeriesId = seriesId,
VolumeId = volumeId,
LibraryId =libraryId
}, await GetUser(apiKey));
await _readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = chapterId,
PageNum = pageNumber,
SeriesId = seriesId,
VolumeId = volumeId,
LibraryId =libraryId
}, await GetUser(apiKey));
}
return File(content, MimeTypeMap.GetMimeType(format));
}

View file

@ -0,0 +1,33 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// For the Panels app explicitly
/// </summary>
[AllowAnonymous]
public class PanelsController : BaseApiController
{
private readonly IReaderService _readerService;
private readonly IUnitOfWork _unitOfWork;
public PanelsController(IReaderService readerService, IUnitOfWork unitOfWork)
{
_readerService = readerService;
_unitOfWork = unitOfWork;
}
[HttpPost("save-progress")]
public async Task<ActionResult> SaveProgress(ProgressDto dto, [FromQuery] string apiKey)
{
if (string.IsNullOrEmpty(apiKey)) return Unauthorized("ApiKey is required");
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
await _readerService.SaveReadingProgress(dto, userId);
return Ok();
}
}

View file

@ -72,7 +72,7 @@ public static class SmartFilterHelper
private static string EncodeSortOptions(SortOptions sortOptions)
{
return Uri.EscapeDataString($"sortField={(int) sortOptions.SortField}&isAscending={sortOptions.IsAscending}");
return Uri.EscapeDataString($"sortField={(int) sortOptions.SortField},isAscending={sortOptions.IsAscending}");
}
private static string EncodeFilterStatementDtos(ICollection<FilterStatementDto> statements)

View file

@ -4,7 +4,7 @@
<a id="content"></a>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
<div class="container-fluid" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}">
<div class="container-fluid fadein" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}">
<div style="padding: 20px 0 0;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false}">
<router-outlet></router-outlet>

View file

@ -38,3 +38,17 @@
width: auto;
}
}
@keyframes fadeInUp {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.fadein {
animation: 2s fadeInUp;
}

View file

@ -12,17 +12,30 @@ import {ThemeService} from "./_services/theme.service";
import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.component';
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {animate, state, style, transition, trigger} from "@angular/animations";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: true,
animations: [
trigger('fadeIn', [
// the "out" style determines the "resting" state of the element when it is visible.
state('out', style({opacity: 0})),
// in state
state('in', style({opacity: 1})),
transition('out => in', animate('700ms ease-in')),
transition('in => out', animate('700ms ease-in')),
])
],
imports: [NgClass, NgIf, SideNavComponent, RouterOutlet, AsyncPipe, NavHeaderComponent]
})
export class AppComponent implements OnInit {
transitionState$!: Observable<boolean>;
fade = 'out';
private readonly destroyRef = inject(DestroyRef);
private readonly offcanvas = inject(NgbOffcanvas);

View file

@ -57,6 +57,7 @@
.btn {
text-decoration: none;
text-shadow: 1.3px 0.5px 1px rgba(255, 255, 255, 0.4); // TODO: perfect this
color: hsla(0,0%,100%,.7);
height: 25px;
text-align: center;

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'series-info-cards'">
<div class="row g-0 mt-3">
<div class="row justify-content-between g-0 mt-3">
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('release-date-title')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-year-tooltip')">

View file

@ -24,7 +24,7 @@
</ng-container>
<ng-template #filterSection>
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded && filterV2">
<div class="filter-section mx-auto pb-3 slide-down" *ngIf="fullyLoaded && filterV2" [@slideFromTop]="isOpen">
<div class="row justify-content-center g-0">
<app-metadata-builder [filter]="filterV2"
[availableFilterFields]="allFilterFields"

View file

@ -38,6 +38,9 @@ import {
Select2UpdateValue
} from "ng-select2-component";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {animate, state, style, transition, trigger} from "@angular/animations";
const ANIMATION_SPEED = 200;
@Component({
selector: 'app-metadata-filter',
@ -46,7 +49,29 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter";
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf, Select2Module, NgClass]
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf, Select2Module, NgClass],
animations: [
trigger('slideFromTop', [
state('in', style({ transform: 'translateY(0)' })),
transition('void => *', [
style({ transform: 'translateY(-100%)' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ transform: 'translateY(-100%)' })),
])
]),
trigger('slideFromBottom', [
state('in', style({ transform: 'translateY(0)' })),
transition('void => *', [
style({ transform: 'translateY(100%)' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ transform: 'translateY(100%)' })),
])
])
],
})
export class MetadataFilterComponent implements OnInit {
@ -89,6 +114,8 @@ export class MetadataFilterComponent implements OnInit {
smartFilters!: Array<Select2Option>;
isOpen = false;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
@ -114,44 +141,47 @@ export class MetadataFilterComponent implements OnInit {
this.filterOpen.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(openState => {
this.filteringCollapsed = !openState;
this.toggleService.set(!this.filteringCollapsed);
this.isOpen = openState;
this.cdRef.markForCheck();
});
}
this.loadFromPresetsAndSetup();
}
loadSavedFilter(event: Select2UpdateEvent<any>) {
// Load the filter from the backend and update the screen
if (event.value === undefined || typeof(event.value) === 'string') return;
const smartFilter = event.value as SmartFilter;
this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter);
this.cdRef.markForCheck();
console.log('update event: ', event);
}
createFilterValue(event: Select2AutoCreateEvent<any>) {
// Create a new name and filter
if (!this.filterV2) return;
this.filterV2.name = event.value;
this.filterService.saveFilter(this.filterV2).subscribe(() => {
const item = {
value: {
filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!),
name: event.value,
} as SmartFilter,
label: event.value
};
this.smartFilters.push(item);
this.sortGroup.get('name')?.setValue(item);
this.cdRef.markForCheck();
this.toastr.success(translate('toasts.smart-filter-updated'));
this.apply();
});
console.log('create event: ', event);
}
// loadSavedFilter(event: Select2UpdateEvent<any>) {
// // Load the filter from the backend and update the screen
// if (event.value === undefined || typeof(event.value) === 'string') return;
// const smartFilter = event.value as SmartFilter;
// this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter);
// this.cdRef.markForCheck();
// console.log('update event: ', event);
// }
//
// createFilterValue(event: Select2AutoCreateEvent<any>) {
// // Create a new name and filter
// if (!this.filterV2) return;
// this.filterV2.name = event.value;
// this.filterService.saveFilter(this.filterV2).subscribe(() => {
//
// const item = {
// value: {
// filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!),
// name: event.value,
// } as SmartFilter,
// label: event.value
// };
// this.smartFilters.push(item);
// this.sortGroup.get('name')?.setValue(item);
// this.cdRef.markForCheck();
// this.toastr.success(translate('toasts.smart-filter-updated'));
// this.apply();
// });
//
// console.log('create event: ', event);
// }
close() {

View file

@ -15,6 +15,17 @@
</div>
<div class="dropdown" *ngIf="hasFocus">
<ul class="list-group" role="listbox" id="dropdown">
<ng-container *ngIf="groupedData.series.length === 0 && !hasData">
<li class="list-group-item section-header"><h6>Stumped on what to Type?</h6></li>
<ul class="list-group results" role="group">
<li class="list-group-item" style="font-size: 14px">
<i>Try batman, 2014, Ecchi</i> <!-- Or maybe it should be Search by x, y, z -->
</li>
</ul>
</ng-container>
<ng-container *ngIf="seriesTemplate !== undefined && groupedData.series.length > 0">
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
<ul class="list-group results" role="group" aria-describedby="series-group">

View file

@ -209,9 +209,9 @@ export class FilterUtilitiesService {
}
decodeFilterStatements(encodedStatements: string): FilterStatement[] {
const statementStrings = decodeURIComponent(encodedStatements).split(',');
const statementStrings = decodeURIComponent(encodedStatements).split(',').map(s => decodeURIComponent(s));
return statementStrings.map(statementString => {
const parts = statementString.split('&');
const parts = statementString.split(',');
if (parts === null || parts.length < 3) return null;
const comparisonStartToken = parts.find(part => part.startsWith('comparison='));

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'side-nav-companion-bar'">
<div class="mt-0 d-flex justify-content-between align-items-center">
<div class="mt-0 d-flex justify-content-between align-items-center shadow-sm">
<div>
<ng-content select="[title]"></ng-content>
<ng-content select="[subtitle]"></ng-content>
@ -12,7 +12,7 @@
<i class="fa-solid fa-sliders" aria-hidden="true"></i>
<span class="visually-hidden">{{t('page-settings-title')}}</span>
</button>
<button *ngIf="hasFilter" class="btn btn-{{filterActive ? 'primary' : 'secondary'}} btn-small" (click)="toggleService.toggle()"
<button *ngIf="hasFilter" class="btn btn-{{filterActive ? 'primary' : 'info'}} btn-small" (click)="toggleService.toggle()"
[attr.aria-expanded]="filterOpen" placement="left"
id="filter-btn--komf"
ngbTooltip="{{filterOpen ? t('open-filter-and-sort') : t('close-filter-and-sort')}}"

View file

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.9.4"
"version": "0.7.10.0"
},
"servers": [
{
@ -4168,6 +4168,46 @@
}
}
},
"/api/Panels/save-progress": {
"post": {
"tags": [
"Panels"
],
"parameters": [
{
"name": "apiKey",
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProgressDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProgressDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/ProgressDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Plugin/authenticate": {
"post": {
"tags": [
@ -19687,6 +19727,10 @@
"name": "Image",
"description": "Responsible for servicing up images stored in Kavita for entities"
},
{
"name": "Panels",
"description": "For the Panels app explicitly"
},
{
"name": "Rating",
"description": "Responsible for providing external ratings for Series"