From 4f57a9838d3ff7f76393748270344b9d83497720 Mon Sep 17 00:00:00 2001 From: NeoGe0 Date: Sun, 20 Sep 2026 14:24:57 +0200 Subject: [PATCH] Reworked UI: library and items, first run wizard, and misc --- ...e8_add_library_item_poster_generated_at.py | 14 + .../affiche/api/routers/library.py | 10 +- .../affiche/api/schemas/library.py | 3 + .../affiche/api/schemas/service_schemas.py | 6 + .../affiche/app/auth/service/auth_service.py | 4 +- .../connector/alchemy_library_connector.py | 10 + .../library/connector/library_item_entity.py | 1 + .../mediaserver/library/model/library_item.py | 1 + .../library/model/library_item_search.py | 10 +- .../library/model/library_item_stats.py | 1 + .../library/service/library_repository.py | 3 + .../library/service/library_service.py | 3 + .../service/media_server_poster_service.py | 2 + .../mediaserver/service/poster_resetter.py | 1 + .../affiche/external/poster/poster_service.py | 16 +- .../external/poster/provider/base_provider.py | 16 + .../external/poster/provider/fanart.py | 17 +- .../external/poster/provider/mediux.py | 7 +- .../affiche/external/poster/provider/shoko.py | 5 +- .../affiche/external/poster/provider/tmdb.py | 23 +- .../affiche/external/poster/provider/tvdb.py | 18 +- .../external/poster/provider/tvmaze.py | 4 +- affiche-backend/tests/test_auth.py | 5 + .../tests/test_library_item_counts.py | 19 + .../tests/test_poster_generated_at.py | 86 +++ .../tests/test_poster_image_metadata.py | 158 ++++++ affiche-backend/tests/test_status_counts.py | 34 ++ affiche-frontend/src/App.tsx | 49 +- affiche-frontend/src/api/libraries.ts | 21 + .../collections/CollectionCard.module.css | 8 +- .../collections/CollectionDetail.module.css | 29 +- .../collections/CollectionDetail.tsx | 2 +- .../collections/ItemPickerModal.module.css | 26 +- .../collections/ItemPickerModal.tsx | 1 + .../collections/TitlePromptModal.tsx | 1 + .../components/common/ConfirmModal.module.css | 18 +- .../common/LanguageOrderList.module.css | 12 +- .../components/common/LanguageOrderList.tsx | 7 +- .../src/components/common/Modal.module.css | 21 +- .../components/common/OverflowMenu.module.css | 12 +- .../components/common/OverflowMenu.test.tsx | 49 ++ .../src/components/common/OverflowMenu.tsx | 7 + .../common/PasswordRules.module.css | 18 + .../src/components/common/PasswordRules.tsx | 18 + .../common/ReorderableList.module.css | 10 +- .../common/TaskProgressBar.module.css | 2 +- .../src/components/common/activation.ts | 10 +- .../src/components/common/index.ts | 2 + .../src/components/common/menuKeys.test.ts | 28 + .../src/components/common/menuKeys.ts | 25 + .../common/passwordRequirements.test.ts | 15 + .../components/common/passwordRequirements.ts | 13 + .../dashboard/ProviderHistoryChart.module.css | 20 +- .../image/PosterCompareSlider.module.css | 22 +- .../components/image/PosterFade.module.css | 70 +++ .../src/components/image/PosterFade.tsx | 75 +++ .../components/image/PosterPreview.module.css | 6 +- .../src/components/image/PosterPreview.tsx | 2 +- .../image/PosterStyleControls.module.css | 80 ++- .../image/PosterStyleControls.test.tsx | 30 + .../components/image/PosterStyleControls.tsx | 379 ++++++++----- .../src/components/image/index.ts | 1 + .../src/components/layout/Header.module.css | 95 +++- .../src/components/layout/Header.tsx | 26 +- .../src/components/layout/Layout.module.css | 8 +- .../src/components/layout/Layout.test.tsx | 94 ++++ .../src/components/layout/Layout.tsx | 34 +- .../src/components/layout/Sidebar.module.css | 59 +- .../src/components/layout/Sidebar.test.tsx | 33 ++ .../src/components/layout/Sidebar.tsx | 43 +- .../src/components/layout/headerTask.ts | 1 + .../library/AlphabetIndex.module.css | 30 +- .../src/components/library/AlphabetIndex.tsx | 1 + .../library/CoverageMeter.module.css | 84 +++ .../src/components/library/CoverageMeter.tsx | 28 + .../components/library/EpisodeList.module.css | 6 +- .../src/components/library/EpisodeList.tsx | 6 +- .../components/library/FilterMenu.module.css | 30 +- .../library/ItemArtworkStrip.module.css | 86 +++ .../components/library/ItemArtworkStrip.tsx | 56 ++ .../components/library/ItemCard.module.css | 138 +++-- .../src/components/library/ItemCard.test.tsx | 9 +- .../src/components/library/ItemCard.tsx | 63 ++- .../components/library/ItemDetail.module.css | 388 ++++++++----- .../components/library/ItemDetail.test.tsx | 145 +++-- .../src/components/library/ItemDetail.tsx | 519 ++++++++++-------- .../components/library/ItemGrid.module.css | 21 +- .../src/components/library/ItemGrid.tsx | 19 +- .../src/components/library/ItemRow.module.css | 8 +- .../src/components/library/ItemRow.tsx | 11 +- .../components/library/ItemTable.module.css | 23 +- .../src/components/library/ItemTable.test.tsx | 8 + .../src/components/library/ItemTable.tsx | 43 +- .../src/components/library/LibraryRows.tsx | 3 +- .../library/LibraryStage.module.css | 184 +++++++ .../src/components/library/LibraryStage.tsx | 111 ++++ .../library/PosterBrowserModal.module.css | 52 +- .../library/PosterBrowserModal.test.tsx | 150 ++++- .../components/library/PosterBrowserModal.tsx | 145 +++-- .../library/PosterBrowserToolbar.module.css | 68 ++- .../library/PosterBrowserToolbar.tsx | 167 +++--- .../library/PosterCandidateGrid.module.css | 93 +++- .../library/PosterCandidateGrid.test.tsx | 14 + .../library/PosterCandidateGrid.tsx | 90 ++- .../library/PosterCompareModal.module.css | 6 +- .../library/PosterEditPanel.module.css | 139 ++--- .../components/library/PosterEditPanel.tsx | 56 +- .../library/PosterPreviewPane.module.css | 12 +- .../components/library/PosterPreviewPane.tsx | 2 +- .../library/SelectionBar.module.css | 23 +- .../src/components/library/SelectionBar.tsx | 43 +- .../src/components/library/coverage.test.ts | 25 + .../src/components/library/coverage.ts | 18 + .../src/components/library/format.test.ts | 22 +- .../src/components/library/format.ts | 9 +- .../src/components/library/index.ts | 3 + .../components/library/listingFilters.test.ts | 4 +- .../src/components/library/listingFilters.ts | 6 +- .../library/posterCandidateDetails.test.ts | 34 ++ .../library/posterCandidateDetails.ts | 28 + .../components/library/posterState.test.ts | 54 ++ .../src/components/library/posterState.ts | 46 ++ .../src/components/library/selection.test.ts | 32 +- .../src/components/library/selection.ts | 31 ++ .../components/library/stagePoster.test.ts | 44 ++ .../src/components/library/stagePoster.ts | 29 + .../search/GlobalSearchModal.module.css | 19 +- .../settings/AddLibrariesModal.module.css | 32 +- .../components/settings/AddLibrariesModal.tsx | 14 +- .../settings/AddMediaServerPanel.module.css | 22 +- .../settings/AddMediaServerPanel.tsx | 18 +- .../settings/AddProviderPanel.module.css | 14 +- .../components/settings/AddProviderPanel.tsx | 4 +- .../components/settings/ConfigForm.module.css | 49 +- .../components/settings/ConfigForm.test.tsx | 6 +- .../src/components/settings/ConfigForm.tsx | 8 +- .../settings/GeneralSettings.module.css | 10 +- .../components/settings/GeneralSettings.tsx | 2 +- .../settings/LanguageOrderPanel.module.css | 4 +- .../components/settings/LibraryRow.module.css | 18 +- .../src/components/settings/LibraryRow.tsx | 6 +- .../settings/LibraryStyleModal.module.css | 36 +- .../components/settings/LibraryStyleModal.tsx | 2 +- .../settings/MediaServerCard.module.css | 12 +- .../components/settings/MediaServerCard.tsx | 6 +- .../settings/MediaServersSettings.module.css | 2 +- .../settings/MediaServersSettings.test.tsx | 30 +- .../settings/MediaServersSettings.tsx | 10 +- .../NotificationTargetModal.module.css | 18 +- .../settings/NotificationTargetModal.tsx | 2 +- .../settings/NotificationsSettings.module.css | 10 +- .../settings/PosterApisSettings.test.tsx | 6 +- .../settings/PosterApisSettings.tsx | 4 +- .../settings/PosterFallbackPanel.module.css | 6 +- .../settings/PosterProviderCard.module.css | 6 +- .../settings/PreviewSubjectModal.module.css | 34 +- .../settings/PreviewSubjectModal.tsx | 6 +- .../settings/ProviderOrderForm.module.css | 28 +- .../components/settings/ProviderOrderForm.tsx | 2 +- .../settings/ServerTokenPanel.module.css | 10 +- .../components/settings/ServerTokenPanel.tsx | 2 +- .../settings/SettingsSection.module.css | 66 +-- .../settings/StyleProfileModal.module.css | 30 +- .../settings/StyleProfilesPanel.module.css | 8 +- .../settings/StyleProfilesPanel.tsx | 4 +- .../settings/StyleSettings.module.css | 47 +- .../src/components/settings/StyleSettings.tsx | 43 +- .../settings/UserAccountModal.module.css | 12 +- .../settings/UsersSettings.module.css | 22 +- .../src/components/settings/UsersSettings.tsx | 4 +- .../settings/WebhookPanel.module.css | 8 +- .../components/settings/mediaServerHelpers.ts | 4 +- .../components/welcome/ConnectServerStep.tsx | 181 ++++++ .../components/welcome/FirstPostersStep.tsx | 79 +++ .../components/welcome/PosterSourcesStep.tsx | 135 +++++ .../src/components/welcome/SetupStep.tsx | 43 ++ .../src/components/welcome/Welcome.module.css | 314 +++++++++++ .../src/components/welcome/index.ts | 5 + .../components/welcome/welcomeSteps.test.ts | 34 ++ .../src/components/welcome/welcomeSteps.ts | 26 + .../src/constants/platform.test.ts | 22 + affiche-frontend/src/constants/platform.ts | 7 + .../src/context/ToastContext.module.css | 39 +- .../src/context/ToastContext.test.tsx | 43 ++ affiche-frontend/src/context/ToastContext.tsx | 83 ++- affiche-frontend/src/hooks/index.ts | 9 + .../src/hooks/useAlphabetIndex.ts | 3 +- affiche-frontend/src/hooks/useInView.ts | 20 + .../src/hooks/useItemArtwork.test.tsx | 51 ++ affiche-frontend/src/hooks/useItemArtwork.ts | 62 +++ .../src/hooks/useItemLock.test.tsx | 36 ++ affiche-frontend/src/hooks/useItemLock.ts | 7 + .../src/hooks/useItemNeighbours.test.tsx | 59 ++ .../src/hooks/useItemNeighbours.ts | 63 +++ .../src/hooks/useItemSelection.ts | 45 +- .../src/hooks/useLibraryCoverage.ts | 33 ++ .../src/hooks/useLibraryItemCounts.ts | 2 + affiche-frontend/src/hooks/useLibraryRows.ts | 20 +- .../src/hooks/useLibraryUploadSetting.ts | 33 ++ affiche-frontend/src/hooks/useMediaQuery.ts | 12 + affiche-frontend/src/hooks/useMenuKeyboard.ts | 37 ++ .../src/hooks/usePosterBrowser.ts | 6 +- .../src/hooks/usePosterStyleDrafts.test.ts | 24 + .../src/hooks/usePosterStyleDrafts.ts | 8 +- affiche-frontend/src/hooks/useStagePoster.ts | 76 +++ .../src/hooks/useTaskTracking.test.ts | 2 +- affiche-frontend/src/hooks/useTaskTracking.ts | 14 +- .../src/hooks/useUnsavedChangesGuard.test.tsx | 76 +++ .../src/hooks/useUnsavedChangesGuard.ts | 23 + affiche-frontend/src/index.css | 127 ++++- affiche-frontend/src/motion.ts | 3 + .../src/pages/AuthPage.module.css | 35 +- .../src/pages/ChangePasswordPage.test.tsx | 15 +- .../src/pages/ChangePasswordPage.tsx | 6 +- .../src/pages/CollectionsPage.module.css | 26 +- .../src/pages/CollectionsPage.test.tsx | 34 +- .../src/pages/CollectionsPage.tsx | 10 +- .../src/pages/DashboardPage.module.css | 50 +- affiche-frontend/src/pages/DashboardPage.tsx | 12 +- .../src/pages/LibraryPage.module.css | 16 +- .../src/pages/LibraryPage.test.tsx | 199 ++++++- affiche-frontend/src/pages/LibraryPage.tsx | 151 ++++- affiche-frontend/src/pages/SettingsPage.tsx | 2 +- affiche-frontend/src/pages/SetupPage.tsx | 6 +- .../src/pages/WelcomePage.module.css | 53 ++ .../src/pages/WelcomePage.test.tsx | 97 ++++ affiche-frontend/src/pages/WelcomePage.tsx | 100 ++++ .../src/pages/dashboardStats.test.ts | 13 +- affiche-frontend/src/pages/dashboardStats.ts | 18 +- affiche-frontend/src/pages/index.ts | 1 + .../src/pages/libraryActions.test.ts | 2 +- affiche-frontend/src/pages/libraryActions.ts | 1 + .../src/pages/libraryConfirmations.test.ts | 65 ++- .../src/pages/libraryConfirmations.ts | 81 +-- .../src/pages/taskSummary.test.ts | 41 ++ affiche-frontend/src/pages/taskSummary.ts | 38 ++ affiche-frontend/src/routes.test.ts | 2 +- affiche-frontend/src/types/index.ts | 17 +- 238 files changed, 7175 insertions(+), 1908 deletions(-) create mode 100644 affiche-backend/affiche/alembic/versions/b7d9f1a3c5e8_add_library_item_poster_generated_at.py create mode 100644 affiche-backend/tests/test_poster_generated_at.py create mode 100644 affiche-backend/tests/test_poster_image_metadata.py create mode 100644 affiche-frontend/src/components/common/OverflowMenu.test.tsx create mode 100644 affiche-frontend/src/components/common/PasswordRules.module.css create mode 100644 affiche-frontend/src/components/common/PasswordRules.tsx create mode 100644 affiche-frontend/src/components/common/menuKeys.test.ts create mode 100644 affiche-frontend/src/components/common/menuKeys.ts create mode 100644 affiche-frontend/src/components/common/passwordRequirements.test.ts create mode 100644 affiche-frontend/src/components/common/passwordRequirements.ts create mode 100644 affiche-frontend/src/components/image/PosterFade.module.css create mode 100644 affiche-frontend/src/components/image/PosterFade.tsx create mode 100644 affiche-frontend/src/components/layout/Layout.test.tsx create mode 100644 affiche-frontend/src/components/library/CoverageMeter.module.css create mode 100644 affiche-frontend/src/components/library/CoverageMeter.tsx create mode 100644 affiche-frontend/src/components/library/ItemArtworkStrip.module.css create mode 100644 affiche-frontend/src/components/library/ItemArtworkStrip.tsx create mode 100644 affiche-frontend/src/components/library/LibraryStage.module.css create mode 100644 affiche-frontend/src/components/library/LibraryStage.tsx create mode 100644 affiche-frontend/src/components/library/coverage.test.ts create mode 100644 affiche-frontend/src/components/library/coverage.ts create mode 100644 affiche-frontend/src/components/library/posterCandidateDetails.test.ts create mode 100644 affiche-frontend/src/components/library/posterCandidateDetails.ts create mode 100644 affiche-frontend/src/components/library/posterState.test.ts create mode 100644 affiche-frontend/src/components/library/posterState.ts create mode 100644 affiche-frontend/src/components/library/stagePoster.test.ts create mode 100644 affiche-frontend/src/components/library/stagePoster.ts create mode 100644 affiche-frontend/src/components/welcome/ConnectServerStep.tsx create mode 100644 affiche-frontend/src/components/welcome/FirstPostersStep.tsx create mode 100644 affiche-frontend/src/components/welcome/PosterSourcesStep.tsx create mode 100644 affiche-frontend/src/components/welcome/SetupStep.tsx create mode 100644 affiche-frontend/src/components/welcome/Welcome.module.css create mode 100644 affiche-frontend/src/components/welcome/index.ts create mode 100644 affiche-frontend/src/components/welcome/welcomeSteps.test.ts create mode 100644 affiche-frontend/src/components/welcome/welcomeSteps.ts create mode 100644 affiche-frontend/src/constants/platform.test.ts create mode 100644 affiche-frontend/src/constants/platform.ts create mode 100644 affiche-frontend/src/context/ToastContext.test.tsx create mode 100644 affiche-frontend/src/hooks/useInView.ts create mode 100644 affiche-frontend/src/hooks/useItemArtwork.test.tsx create mode 100644 affiche-frontend/src/hooks/useItemArtwork.ts create mode 100644 affiche-frontend/src/hooks/useItemLock.test.tsx create mode 100644 affiche-frontend/src/hooks/useItemNeighbours.test.tsx create mode 100644 affiche-frontend/src/hooks/useItemNeighbours.ts create mode 100644 affiche-frontend/src/hooks/useLibraryCoverage.ts create mode 100644 affiche-frontend/src/hooks/useLibraryUploadSetting.ts create mode 100644 affiche-frontend/src/hooks/useMediaQuery.ts create mode 100644 affiche-frontend/src/hooks/useMenuKeyboard.ts create mode 100644 affiche-frontend/src/hooks/useStagePoster.ts create mode 100644 affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx create mode 100644 affiche-frontend/src/hooks/useUnsavedChangesGuard.ts create mode 100644 affiche-frontend/src/motion.ts create mode 100644 affiche-frontend/src/pages/WelcomePage.module.css create mode 100644 affiche-frontend/src/pages/WelcomePage.test.tsx create mode 100644 affiche-frontend/src/pages/WelcomePage.tsx create mode 100644 affiche-frontend/src/pages/taskSummary.test.ts create mode 100644 affiche-frontend/src/pages/taskSummary.ts diff --git a/affiche-backend/affiche/alembic/versions/b7d9f1a3c5e8_add_library_item_poster_generated_at.py b/affiche-backend/affiche/alembic/versions/b7d9f1a3c5e8_add_library_item_poster_generated_at.py new file mode 100644 index 0000000..f796a19 --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/b7d9f1a3c5e8_add_library_item_poster_generated_at.py @@ -0,0 +1,14 @@ +from alembic import op +import sqlalchemy as sa + +revision = 'b7d9f1a3c5e8' +down_revision = 'e1a3c5d7f9b2' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('library_item', sa.Column('poster_generated_at', sa.DateTime(), nullable=True)) + +def downgrade() -> None: + with op.batch_alter_table('library_item') as batch_op: + batch_op.drop_column('poster_generated_at') diff --git a/affiche-backend/affiche/api/routers/library.py b/affiche-backend/affiche/api/routers/library.py index bf19e58..bbe626b 100644 --- a/affiche-backend/affiche/api/routers/library.py +++ b/affiche-backend/affiche/api/routers/library.py @@ -208,10 +208,18 @@ def get_library_item_counts(media_server_id: int, providers = service.count_items_by_provider(query.to_domain(library_id, provider=None)) return LibraryItemCounts( total=stats.total, unprocessed=stats.unprocessed, - errors=stats.errors, locked=stats.locked, + errors=stats.errors, locked=stats.locked, ready=stats.ready, uploaded=stats.uploaded, providers={(provider or NO_PROVIDER): count for provider, count in providers.items()}, ) +@router.get("/{library_id}/items/ids", response_model=List[int]) +def get_library_item_ids(media_server_id: int, + library_id: int, + query: Annotated[LibraryItemQuery, Query()], + service: LibraryService = Depends(get_library_service)) -> List[int]: + service.get_library(media_server_id, library_id) + return service.find_item_ids(query.to_domain(library_id, page_size=None)) + @router.get("/{library_id}/items/alpha-index", response_model=List[AlphaIndexEntry]) def get_library_alpha_index(media_server_id: int, library_id: int, diff --git a/affiche-backend/affiche/api/schemas/library.py b/affiche-backend/affiche/api/schemas/library.py index ebfb187..32db380 100644 --- a/affiche-backend/affiche/api/schemas/library.py +++ b/affiche-backend/affiche/api/schemas/library.py @@ -61,6 +61,7 @@ class LibraryItemResponse(BaseModel): updated_at: Optional[datetime] = None last_seen_at: Optional[datetime] = None poster_uploaded_at: Optional[datetime] = None + poster_generated_at: Optional[datetime] = None imdb_id: Optional[str] = None tmdb_id: Optional[int] = None tvdb_id: Optional[int] = None @@ -199,6 +200,8 @@ class LibraryItemCounts(BaseModel): unprocessed: int errors: int locked: int + ready: int + uploaded: int providers: Dict[str, int] = {} class LibraryStyleStaleness(BaseModel): diff --git a/affiche-backend/affiche/api/schemas/service_schemas.py b/affiche-backend/affiche/api/schemas/service_schemas.py index e29888d..92b3f4f 100644 --- a/affiche-backend/affiche/api/schemas/service_schemas.py +++ b/affiche-backend/affiche/api/schemas/service_schemas.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel class ProviderTestRequest(BaseModel): @@ -9,3 +11,7 @@ class PosterCandidate(BaseModel): provider: str rank: int = 0 rank_score: float = 1.0 + language: Optional[str] = None + textless: Optional[bool] = None + width: Optional[int] = None + height: Optional[int] = None diff --git a/affiche-backend/affiche/app/auth/service/auth_service.py b/affiche-backend/affiche/app/auth/service/auth_service.py index 6dbfe89..7ae828f 100644 --- a/affiche-backend/affiche/app/auth/service/auth_service.py +++ b/affiche-backend/affiche/app/auth/service/auth_service.py @@ -51,8 +51,8 @@ def create_admin(self, username: str, password: str) -> User: username = (username or "").strip() if not username: raise AuthError("Username is required") - if not password: - raise AuthError("Password is required") + if len(password or "") < MIN_PASSWORD_LENGTH: + raise AuthError(f"Password must be at least {MIN_PASSWORD_LENGTH} characters") if self.has_admin(): raise AuthError("An admin account already exists") return self._repository.create(username, _hash_password(password)) diff --git a/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py b/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py index cc5661e..e5573fe 100644 --- a/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py +++ b/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py @@ -32,6 +32,7 @@ def bucket_letter(title: Optional[str]) -> str: 'year': LibraryItemEntity.year, 'release_date': LibraryItemEntity.release_date, 'added_at': LibraryItemEntity.added_at, + 'poster_generated_at': LibraryItemEntity.poster_generated_at, 'resolution': LibraryItemEntity.media_height, 'codec': LibraryItemEntity.video_codec, 'size': LibraryItemEntity.media_size_bytes, @@ -46,6 +47,10 @@ def _pending_clause(): def _error_clause(): return LibraryItemEntity.error_message.is_not(None) +def _ready_clause(): + return and_(LibraryItemEntity.processed.is_(True), LibraryItemEntity.error_message.is_(None), + LibraryItemEntity.poster_uploaded_at.is_(None)) + def _attempted_clause(): return or_(LibraryItemEntity.processed.is_(True), _error_clause()) @@ -67,6 +72,7 @@ def _bucket_sums(): 'errors': func.sum(case((_error_clause(), 1), else_=0)), 'locked': func.sum(case((LibraryItemEntity.locked.is_(True), 1), else_=0)), 'uploaded': func.sum(case((LibraryItemEntity.poster_uploaded_at.is_not(None), 1), else_=0)), + 'ready': func.sum(case((_ready_clause(), 1), else_=0)), } def _stats_from_row(row) -> LibraryItemStats: @@ -216,6 +222,10 @@ def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]: query = self._paged(self._ordered(self._search_query(search), search), search) return [LibraryItem.model_validate(item) for item in query.all()] + def find_item_ids(self, search: LibraryItemSearch) -> List[int]: + query = self._ordered(self._search_query(search), search).with_entities(LibraryItemEntity.id) + return [row[0] for row in query.all()] + def count_items(self, search: LibraryItemSearch) -> int: return self._search_query(search).count() diff --git a/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py b/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py index d17059f..c1a87ea 100644 --- a/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py +++ b/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py @@ -28,6 +28,7 @@ class LibraryItemEntity(Base): updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) poster_uploaded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + poster_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) imdb_id: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item.py index dbec864..271831d 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item.py @@ -17,6 +17,7 @@ class LibraryItem(BaseModel): updated_at: Optional[datetime] = None last_seen_at: Optional[datetime] = None poster_uploaded_at: Optional[datetime] = None + poster_generated_at: Optional[datetime] = None deleted_at: Optional[datetime] = None imdb_id: Optional[str] = None diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py index c994a7e..b66276b 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py @@ -10,6 +10,8 @@ class ItemStatusFilter(str, Enum): UNPROCESSED = "unprocessed" ERRORS = "errors" LOCKED = "locked" + READY = "ready" + UPLOADED = "uploaded" NO_PROVIDER = "none" @@ -34,12 +36,16 @@ class LibraryItemSearch(SearchCriteria): @model_validator(mode='after') def _expand_and_check(self) -> 'LibraryItemSearch': if self.status is not None: - if self.processed is not None or self.has_error is not None or self.locked is not None: - raise ValueError("pass either `status` or `processed`/`has_error`/`locked`, not both") + if (self.processed is not None or self.has_error is not None + or self.locked is not None or self.uploaded is not None): + raise ValueError( + "pass either `status` or `processed`/`has_error`/`locked`/`uploaded`, not both") expanded = { ItemStatusFilter.UNPROCESSED: {'processed': False, 'has_error': False}, ItemStatusFilter.ERRORS: {'has_error': True}, ItemStatusFilter.LOCKED: {'locked': True}, + ItemStatusFilter.READY: {'processed': True, 'has_error': False, 'uploaded': False}, + ItemStatusFilter.UPLOADED: {'uploaded': True}, }[self.status] for field, value in expanded.items(): object.__setattr__(self, field, value) diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py index 6137a21..8167f53 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py @@ -7,6 +7,7 @@ class LibraryItemStats(BaseModel): errors: int = 0 locked: int = 0 uploaded: int = 0 + ready: int = 0 def __add__(self, other: 'LibraryItemStats') -> 'LibraryItemStats': return LibraryItemStats(**{ diff --git a/affiche-backend/affiche/app/mediaserver/library/service/library_repository.py b/affiche-backend/affiche/app/mediaserver/library/service/library_repository.py index b29de11..d99c974 100644 --- a/affiche-backend/affiche/app/mediaserver/library/service/library_repository.py +++ b/affiche-backend/affiche/app/mediaserver/library/service/library_repository.py @@ -45,6 +45,9 @@ def create_or_update_items_batch(self, items: List[LibraryItem]) -> None: def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]: return self._connector.find_items(search) + def find_item_ids(self, search: LibraryItemSearch) -> List[int]: + return self._connector.find_item_ids(search) + def count_items(self, search: LibraryItemSearch) -> int: return self._connector.count_items(search) diff --git a/affiche-backend/affiche/app/mediaserver/library/service/library_service.py b/affiche-backend/affiche/app/mediaserver/library/service/library_service.py index be0466c..d324d57 100644 --- a/affiche-backend/affiche/app/mediaserver/library/service/library_service.py +++ b/affiche-backend/affiche/app/mediaserver/library/service/library_service.py @@ -63,6 +63,9 @@ def find_libraries(self, search: LibrarySearch) -> List[Library]: def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]: return self.library_repo.find_items(search) + def find_item_ids(self, search: LibraryItemSearch) -> List[int]: + return self.library_repo.find_item_ids(search) + def count_items(self, search: LibraryItemSearch) -> int: return self.library_repo.count_items(search) diff --git a/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py b/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py index f436615..b5eb78d 100644 --- a/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py +++ b/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime, timezone from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Optional, Callable, List, NamedTuple @@ -452,6 +453,7 @@ def _process_item_poster(self, uploaded = upload and self._uploader.upload_if_changed(item, stored.path, stored.digest, connector) item.processed = True + item.poster_generated_at = datetime.now(timezone.utc) item.poster_provider = provider item.style_hash = self._decorator.style_fingerprint(overlay_options, text_options, apply_style) if not uploaded: diff --git a/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py b/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py index 891db96..2d52839 100644 --- a/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py +++ b/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py @@ -47,6 +47,7 @@ def reset_poster(self, item.poster_hash = None item.poster_provider = None item.style_hash = None + item.poster_generated_at = None item.error_message = None self._cache_source_poster(item, connector, poster_url=reset.poster_url) repo.create_or_update_item(item) diff --git a/affiche-backend/affiche/external/poster/poster_service.py b/affiche-backend/affiche/external/poster/poster_service.py index 1eeb469..d24e044 100644 --- a/affiche-backend/affiche/external/poster/poster_service.py +++ b/affiche-backend/affiche/external/poster/poster_service.py @@ -19,6 +19,10 @@ class ProviderPoster(NamedTuple): provider: str rank: int = 0 rank_score: float = 1.0 + language: Optional[str] = None + textless: Optional[bool] = None + width: Optional[int] = None + height: Optional[int] = None MIN_AGREEING_MEMBERS = 2 @@ -70,7 +74,7 @@ def lookup(p=provider): result = self._ask(provider, lookup, None) if result: - return ProviderPoster(result, provider.name) + return ProviderPoster(str(result), provider.name) logger.warning(f"Could not find any poster for {title}") return None @@ -91,7 +95,7 @@ def find_best_season_poster(self, language=language ), None) if result: - return ProviderPoster(result, provider.name) + return ProviderPoster(str(result), provider.name) logger.warning(f"No season poster found for {title} season {season_number}") return None @@ -201,8 +205,12 @@ def _tag(self, provider: ExternalProvider, urls = self._ask(provider, call, []) or [] last = len(urls) - 1 return [ - ProviderPoster(url, provider.name, rank=index, - rank_score=1.0 if last <= 0 else 1.0 - index / last) + ProviderPoster(str(url), provider.name, rank=index, + rank_score=1.0 if last <= 0 else 1.0 - index / last, + language=getattr(url, "language", None), + textless=getattr(url, "textless", None), + width=getattr(url, "width", None), + height=getattr(url, "height", None)) for index, url in enumerate(urls) ] diff --git a/affiche-backend/affiche/external/poster/provider/base_provider.py b/affiche-backend/affiche/external/poster/provider/base_provider.py index 77eda1c..19632e1 100644 --- a/affiche-backend/affiche/external/poster/provider/base_provider.py +++ b/affiche-backend/affiche/external/poster/provider/base_provider.py @@ -10,6 +10,22 @@ class BaseUrlMode(str, Enum): FIXED = "fixed" USER = "user" +class PosterImage(str): + + language: Optional[str] + textless: Optional[bool] + width: Optional[int] + height: Optional[int] + + def __new__(cls, url: str, *, language: Optional[str] = None, textless: Optional[bool] = None, + width: Optional[int] = None, height: Optional[int] = None): + image = super().__new__(cls, url) + image.language = language.lower() if language else None + image.textless = textless + image.width = width or None + image.height = height or None + return image + class ExternalProvider(ABC): requires_api_key: bool = True diff --git a/affiche-backend/affiche/external/poster/provider/fanart.py b/affiche-backend/affiche/external/poster/provider/fanart.py index e111514..df50d25 100644 --- a/affiche-backend/affiche/external/poster/provider/fanart.py +++ b/affiche-backend/affiche/external/poster/provider/fanart.py @@ -4,7 +4,7 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import ExternalProvider +from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -93,14 +93,14 @@ def _fetch_movie_posters(self, tmdb_id: int) -> List[str]: movie_posters = data.get("movieposter", []) poster_urls.extend([ - poster["url"] + self._image(poster) for poster in movie_posters if poster.get("url") and poster.get("lang") == '00' ]) movie_art = data.get("movieart", []) poster_urls.extend([ - art["url"] + self._image(art) for art in movie_art if art.get("url") ]) @@ -121,7 +121,7 @@ def _fetch_tv_posters(self, tvdb_id: int) -> List[str]: tv_posters = data.get("tvposter", []) poster_urls.extend([ - poster["url"] + self._image(poster) for poster in tv_posters if poster.get("url") ]) @@ -131,6 +131,13 @@ def _fetch_tv_posters(self, tvdb_id: int) -> List[str]: logger.error(f"Error fetching Fanart TV posters for {tvdb_id}: {e}") return [] + @staticmethod + def _image(entry: dict) -> PosterImage: + lang = entry.get("lang") or None + if lang == "00": + return PosterImage(entry["url"], textless=True) + return PosterImage(entry["url"], language=lang, textless=False if lang else None) + def _fetch_season_poster(self, tvdb_id: int, season_number: int) -> Optional[str]: try: endpoint = f"{self.base_url}/tv/{tvdb_id}" @@ -160,7 +167,7 @@ def _fetch_all_season_poster(self, tvdb_id: int, season_number: int) -> List[str posters = [] for poster in season_posters: if poster.get("season") == str(season_number) and poster.get("url"): - posters.append(poster["url"]) + posters.append(self._image(poster)) return posters except requests.RequestException as e: diff --git a/affiche-backend/affiche/external/poster/provider/mediux.py b/affiche-backend/affiche/external/poster/provider/mediux.py index 1c55c77..13dff80 100644 --- a/affiche-backend/affiche/external/poster/provider/mediux.py +++ b/affiche-backend/affiche/external/poster/provider/mediux.py @@ -6,7 +6,7 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import ExternalProvider +from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -293,13 +293,14 @@ def _matches_language(asset: dict, language: Optional[str]) -> bool: return False return ((asset.get("language") or {}).get("iso_639_1")) == language - def _asset_url(self, asset: dict) -> Optional[str]: + def _asset_url(self, asset: dict) -> Optional[PosterImage]: asset_id = (asset or {}).get("id") if not asset_id: return None url = f"{self.image_base}/{asset_id}" version = self._format_modified(asset.get("modified_on")) - return f"{url}?v={version}" if version else url + return PosterImage(f"{url}?v={version}" if version else url, + language=(asset.get("language") or {}).get("iso_639_1")) @staticmethod def _format_modified(value: Optional[str]) -> Optional[str]: diff --git a/affiche-backend/affiche/external/poster/provider/shoko.py b/affiche-backend/affiche/external/poster/provider/shoko.py index 7b4d35a..a8d0b5b 100644 --- a/affiche-backend/affiche/external/poster/provider/shoko.py +++ b/affiche-backend/affiche/external/poster/provider/shoko.py @@ -5,7 +5,7 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider +from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -143,7 +143,8 @@ def _poster_urls(self, images, language: Optional[str]) -> List[str]: width = image.get("Width") or 0 if width and width < self.MIN_POSTER_WIDTH: continue - candidates.append((width, url)) + candidates.append((width, PosterImage(url, language=image.get("LanguageCode"), + width=width, height=image.get("Height")))) candidates.sort(key=lambda candidate: candidate[0], reverse=True) return [url for _, url in candidates] diff --git a/affiche-backend/affiche/external/poster/provider/tmdb.py b/affiche-backend/affiche/external/poster/provider/tmdb.py index 9efa149..2917902 100644 --- a/affiche-backend/affiche/external/poster/provider/tmdb.py +++ b/affiche-backend/affiche/external/poster/provider/tmdb.py @@ -4,7 +4,7 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import ExternalProvider +from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -167,11 +167,7 @@ def _fetch_all_posters( posters = data.get("posters", []) posters.sort(key=lambda x: x.get("vote_average", 0), reverse=True) - return [ - f"{self.IMAGE_BASE_URL}{poster['file_path']}" - for poster in posters - if poster.get("file_path") - ] + return [self._image(poster) for poster in posters if poster.get("file_path")] except requests.RequestException as e: logger.error(f"Error fetching TMDB posters for {tmdb_id}: {e}") return [] @@ -192,14 +188,19 @@ def _fetch_season_posters( posters = data.get("posters", []) posters.sort(key=lambda x: x.get("vote_average", 0), reverse=True) - return [ - f"{self.IMAGE_BASE_URL}{poster['file_path']}" - for poster in posters - if poster.get("file_path") - ] + return [self._image(poster) for poster in posters if poster.get("file_path")] except requests.RequestException as e: logger.error(f"Error fetching TMDB season posters for {tmdb_id} S{season_number}: {e}") return [] + def _image(self, poster: dict) -> PosterImage: + code = poster.get("iso_639_1") + no_language = code in (None, "xx") + return PosterImage(f"{self.IMAGE_BASE_URL}{poster['file_path']}", + language=None if no_language else code, + textless=no_language, + width=poster.get("width"), + height=poster.get("height")) + def _get_provider_media_type(self, media_type: str) -> str: return "movie" if media_type == "movie" else "tv" diff --git a/affiche-backend/affiche/external/poster/provider/tvdb.py b/affiche-backend/affiche/external/poster/provider/tvdb.py index 5e96d62..f088d7e 100644 --- a/affiche-backend/affiche/external/poster/provider/tvdb.py +++ b/affiche-backend/affiche/external/poster/provider/tvdb.py @@ -5,7 +5,7 @@ import requests from tvdb_v4_official import TVDB -from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider +from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -15,6 +15,7 @@ "en": "eng", "fr": "fra", "de": "deu", "es": "spa", "it": "ita", "pt": "por", "nl": "nld", "ja": "jpn", "ko": "kor", "zh": "zho", } +LANG_3_TO_2 = {three: two for two, three in LANG_2_TO_3.items()} class TVDBClient(ExternalProvider): @@ -154,7 +155,7 @@ def _fetch_all_movie_posters(self, tvdb_id: int) -> List[str]: artworks.sort(key=lambda x: (x.get("language") is not None, -x.get("score", 0))) poster_urls = [ - {"url": artwork["image"], "score": artwork.get("score", 0)} + {"url": self._image(artwork), "score": artwork.get("score", 0)} for artwork in artworks if artwork.get("image") ] @@ -176,7 +177,7 @@ def _fetch_all_series_posters( return [] poster_urls = [ - {"url": artwork["image"], "score": artwork.get("score", 0)} + {"url": self._image(artwork), "score": artwork.get("score", 0)} for artwork in artworks.get('artworks', []) if artwork.get("image") ] @@ -209,7 +210,7 @@ def _fetch_all_season_posters( return [] poster_urls = [ - {"url": artwork["image"], "score": artwork.get("score", 0)} + {"url": self._image(artwork), "score": artwork.get("score", 0)} for artwork in artworks if artwork.get("image") ] @@ -220,5 +221,14 @@ def _fetch_all_season_posters( logger.error(f"Error fetching TVDB season posters for {tvdb_id} S{season_number}: {e}") return [] + @staticmethod + def _image(artwork: dict) -> PosterImage: + code = artwork.get("language") + return PosterImage(artwork["image"], + language=LANG_3_TO_2.get(code, code) if code else None, + textless=code is None, + width=artwork.get("width"), + height=artwork.get("height")) + def _get_provider_media_type(self, media_type: str) -> str: return "movie" if media_type == "movie" else "series" diff --git a/affiche-backend/affiche/external/poster/provider/tvmaze.py b/affiche-backend/affiche/external/poster/provider/tvmaze.py index 012815b..066a3a1 100644 --- a/affiche-backend/affiche/external/poster/provider/tvmaze.py +++ b/affiche-backend/affiche/external/poster/provider/tvmaze.py @@ -6,7 +6,7 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import ExternalProvider +from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage logger = logging.getLogger(__name__) @@ -124,7 +124,7 @@ def _poster_urls(self, images) -> List[str]: width = original.get("width") or 0 if width and width < self.MIN_POSTER_WIDTH: continue - candidates.append((width, url)) + candidates.append((width, PosterImage(url, width=width, height=original.get("height")))) candidates.sort(key=lambda candidate: candidate[0], reverse=True) return [url for _, url in candidates] diff --git a/affiche-backend/tests/test_auth.py b/affiche-backend/tests/test_auth.py index 976da5e..20d28e9 100644 --- a/affiche-backend/tests/test_auth.py +++ b/affiche-backend/tests/test_auth.py @@ -40,6 +40,11 @@ def test_create_admin_validates_input(clean_session): with pytest.raises(AuthError): svc.create_admin("admin", "") +def test_create_admin_holds_the_same_minimum_as_every_other_password(clean_session): + svc = _service(clean_session) + with pytest.raises(AuthError, match="at least 8"): + svc.create_admin("admin", "short") + def test_token_round_trip(clean_session): svc = _service(clean_session) user = svc.create_admin("admin", "s3cret-pass") diff --git a/affiche-backend/tests/test_library_item_counts.py b/affiche-backend/tests/test_library_item_counts.py index a732ef9..852b855 100644 --- a/affiche-backend/tests/test_library_item_counts.py +++ b/affiche-backend/tests/test_library_item_counts.py @@ -108,3 +108,22 @@ def test_the_listing_can_be_filtered_to_items_with_no_provenance(authenticated_a f"/items?provider=none") assert [item["title"] for item in resp.json()["items"]] == ["B"] + +def test_item_ids_route_returns_every_match_unpaged(authenticated_app): + with TestClient(authenticated_app) as client: + server_id, library_id = _seed(["A", "B", "C"], deleted_titles=["Gone"]) + + resp = client.get(f"/affiche/media-servers/{server_id}/libraries/{library_id}/items/ids", + params={"page_size": 1, "search": ""}) + + assert resp.status_code == 200 + assert len(resp.json()) == 3 + +def test_counts_route_reports_ready_and_uploaded(authenticated_app): + with TestClient(authenticated_app) as client: + server_id, library_id = _seed(["A"]) + + body = client.get(f"/affiche/media-servers/{server_id}/libraries/{library_id}/items/counts").json() + + assert body["ready"] == 0 + assert body["uploaded"] == 0 diff --git a/affiche-backend/tests/test_poster_generated_at.py b/affiche-backend/tests/test_poster_generated_at.py new file mode 100644 index 0000000..79b5171 --- /dev/null +++ b/affiche-backend/tests/test_poster_generated_at.py @@ -0,0 +1,86 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import affiche.app.mediaserver.service.media_server_poster_service as poster_module +import affiche.app.mediaserver.service.poster_resetter as resetter_module +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import ( + Library, LibraryItem, LibraryItemSearch, LibrarySearch, SortDir, +) +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.app.mediaserver.service.media_server_connector_protocol import ResetResult +from affiche.app.mediaserver.service.media_server_poster_service import LibraryPosterService +from affiche.app.mediaserver.service.poster_resetter import PosterResetter +from affiche.app.mediaserver.service.poster_uploader import PosterUploader +from affiche.config import Base + +def _service(): + svc = object.__new__(LibraryPosterService) + svc._decorator = MagicMock() + svc._decorator.decorate_poster.return_value = b"poster-bytes" + svc._file_store = MagicMock() + svc._file_store.save.return_value = "/tmp/poster.jpg" + svc._uploader = PosterUploader(file_store=svc._file_store, session_factory=MagicMock()) + svc._resetter = PosterResetter(file_store=svc._file_store, session_factory=MagicMock()) + return svc + +def test_generating_a_poster_stamps_when(monkeypatch): + monkeypatch.setattr(poster_module, "event_manager", MagicMock()) + repo = MagicMock() + item = LibraryItem(id=1, library_id=1, external_id="x", title="T", type="movie") + before = datetime.now(timezone.utc) + + _service()._process_item_poster(repo, MagicMock(), item, "http://poster", MagicMock(), upload=False) + + assert repo.create_or_update_item.call_args.args[0].poster_generated_at >= before + +def test_a_reset_clears_it(monkeypatch): + monkeypatch.setattr(resetter_module, "event_manager", MagicMock()) + repo = MagicMock() + connector = MagicMock() + connector.reset_poster.return_value = ResetResult(True) + item = LibraryItem(id=1, library_id=1, external_id="x", title="T", type="movie", + processed=True, poster_generated_at=datetime.now(timezone.utc)) + + _service()._resetter.reset_poster(repo, item, connector) + + assert repo.create_or_update_item.call_args.args[0].poster_generated_at is None + +@pytest.fixture +def db(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + +def test_the_listing_sorts_newest_poster_first_with_the_unknown_last(db): + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t")) + db.flush() + LibraryService(db).create(Library(media_server_id=server.id, external_id="l", name="Films", + type="movie", language="en", enabled=True)) + db.commit() + library_id = LibraryService(db).find_libraries(LibrarySearch(media_server_id=server.id))[0].id + LibraryService(db).create_or_update_items_batch([ + LibraryItem(library_id=library_id, external_id=f"e{i}", title=title, type="movie") + for i, title in enumerate(["Alien", "Brazil", "Heat"]) + ]) + db.commit() + items = {i.title: i for i in LibraryService(db).find_items(LibraryItemSearch(library_id=library_id))} + now = datetime.now(timezone.utc) + for title, generated in [("Alien", now - timedelta(days=2)), ("Heat", now)]: + items[title].poster_generated_at = generated + LibraryRepository(db).create_or_update_item(items[title]) + + listed = LibraryService(db).find_items(LibraryItemSearch( + library_id=library_id, sort_by="poster_generated_at", sort_dir=SortDir.DESC)) + + assert [i.title for i in listed] == ["Heat", "Alien", "Brazil"] diff --git a/affiche-backend/tests/test_poster_image_metadata.py b/affiche-backend/tests/test_poster_image_metadata.py new file mode 100644 index 0000000..be8e891 --- /dev/null +++ b/affiche-backend/tests/test_poster_image_metadata.py @@ -0,0 +1,158 @@ +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +from affiche.config.dependencies import get_poster_aggregator +from affiche.external.poster.poster_service import PosterAggregatorService, ProviderPoster +from affiche.external.poster.provider.base_provider import PosterImage +from affiche.external.poster.provider.fanart import FanartClient +from affiche.external.poster.provider.mediux import MediuxClient +from affiche.external.poster.provider.shoko import ShokoClient +from affiche.external.poster.provider.tmdb import TMDBClient +from affiche.external.poster.provider.tvdb import TVDBClient +from affiche.external.poster.provider.tvmaze import TVmazeClient + +def _meta(image): + return (str(image), image.language, image.textless, image.width, image.height) + +def _json_session(payload): + session = MagicMock() + session.get.return_value.json.return_value = payload + return session + +def test_a_poster_image_still_compares_as_its_url(): + image = PosterImage("http://x/a.jpg", language="EN", width=0) + + assert image == "http://x/a.jpg" + assert (image.language, image.width) == ("en", None) + +def test_tmdb_reads_a_null_or_xx_code_as_textless(): + client = TMDBClient("key") + client.session = _json_session({"posters": [ + {"file_path": "/a.jpg", "iso_639_1": None, "width": 2000, "height": 3000, "vote_average": 9}, + {"file_path": "/b.jpg", "iso_639_1": "xx", "width": 1000, "height": 1500, "vote_average": 8}, + {"file_path": "/c.jpg", "iso_639_1": "fr", "width": 1400, "height": 2100, "vote_average": 7}, + ]}) + + base = TMDBClient.IMAGE_BASE_URL + assert [_meta(p) for p in client.get_all_posters("movie", tmdb_id=1)] == [ + (f"{base}/a.jpg", None, True, 2000, 3000), + (f"{base}/b.jpg", None, True, 1000, 1500), + (f"{base}/c.jpg", "fr", False, 1400, 2100), + ] + +def test_fanart_reads_00_as_textless_and_publishes_no_size(): + client = FanartClient("key") + client.session = _json_session({"tvposter": [ + {"url": "http://fanart/a.jpg", "lang": "00"}, + {"url": "http://fanart/b.jpg", "lang": "en"}, + {"url": "http://fanart/c.jpg", "lang": ""}, + ]}) + + assert [_meta(p) for p in client.get_all_posters("show", tvdb_id=1)] == [ + ("http://fanart/a.jpg", None, True, None, None), + ("http://fanart/b.jpg", "en", False, None, None), + ("http://fanart/c.jpg", None, None, None, None), + ] + +def test_tvdb_maps_its_three_letter_codes_and_reads_null_as_textless(): + client = TVDBClient("key") + client._tvdb = MagicMock() + client._tvdb.get_series_artworks.return_value = {"artworks": [ + {"image": "http://tvdb/a.jpg", "language": None, "width": 680, "height": 1000, "score": 3}, + {"image": "http://tvdb/b.jpg", "language": "fra", "width": 680, "height": 1000, "score": 2}, + {"image": "http://tvdb/c.jpg", "language": "heb", "width": 680, "height": 1000, "score": 1}, + ]} + + assert [_meta(p) for p in client.get_all_posters("show", tvdb_id=1)] == [ + ("http://tvdb/a.jpg", None, True, 680, 1000), + ("http://tvdb/b.jpg", "fr", False, 680, 1000), + ("http://tvdb/c.jpg", "heb", False, 680, 1000), + ] + +def test_mediux_passes_its_language_on_but_never_claims_textless(): + client = MediuxClient(api_key="k", base_url="https://api.mediux.io") + sets = {"movies_by_id": {"movie_sets": [{"movie_poster": [ + {"id": "a1", "language": {"iso_639_1": "en"}}, + {"id": "b2", "language": None}, + ]}]}} + + with patch.object(client, "_query", return_value=sets): + posters = client.get_all_posters("movie", tmdb_id=1) + + assert [(p.language, p.textless, p.width) for p in posters] == [("en", None, None), + (None, None, None)] + +def test_shoko_carries_its_language_and_size(): + client = ShokoClient(api_key="k", base_url="http://shoko:8111") + payloads = { + "/api/v3/Tmdb/": [{"IDs": {"ID": 7}}], + "/api/v3/Series/7/Images": {"Posters": [ + {"UID": "a", "Source": "AniDB", "Width": 600, "Height": 850, "LanguageCode": "ja"}, + ]}, + } + + def _get(path, params=None): + return next((v for k, v in payloads.items() if path.startswith(k)), None) + + with patch.object(client, "_get", side_effect=_get): + [poster] = client.get_all_posters("show", tmdb_id=1) + + assert (poster.language, poster.textless, poster.width, poster.height) == ("ja", None, 600, 850) + +def test_tvmaze_carries_the_size_and_no_language(): + client = TVmazeClient() + client._MIN_REQUEST_INTERVAL = 0 + payloads = { + "/lookup/shows": {"id": 3}, + "/shows/3/images": [{"type": "poster", "resolutions": { + "original": {"url": "http://maze/a.jpg", "width": 680, "height": 1000}}}], + } + + def _get(path, params=None): + return payloads.get(path) + + with patch.object(client, "_get", side_effect=_get): + [poster] = client.get_all_posters("show", tvdb_id=1) + + assert _meta(poster) == ("http://maze/a.jpg", None, None, 680, 1000) + +class _ImageProvider(TMDBClient): + def __init__(self, posters): + super().__init__("key") + self._posters = posters + + def get_all_posters(self, media_type, tmdb_id=None, tvdb_id=None, language=None): + return self._posters + +def test_the_aggregator_copies_the_metadata_onto_each_candidate(): + aggregator = PosterAggregatorService([_ImageProvider([ + PosterImage("http://tmdb/a.jpg", textless=True, width=2000, height=3000), + "http://tmdb/plain.jpg", + ])]) + + candidates = aggregator.get_all_posters(media_type="movie", tmdb_id=1) + + assert candidates[0] == ProviderPoster("http://tmdb/a.jpg", "tmdb", rank=0, rank_score=1.0, + textless=True, width=2000, height=3000) + assert (candidates[1].language, candidates[1].textless, candidates[1].width) == (None, None, None) + assert type(candidates[0].url) is str + +def test_the_browse_endpoint_sends_it_to_the_grid(authenticated_app): + stub = MagicMock() + stub.get_all_posters.return_value = [ + ProviderPoster("http://tmdb/a.jpg", "tmdb", language="fr", textless=False, + width=1400, height=2100), + ] + authenticated_app.dependency_overrides[get_poster_aggregator] = lambda: stub + try: + with TestClient(authenticated_app) as client: + resp = client.get("/affiche/service/posters?tmdb_id=550&media_type=movie") + finally: + authenticated_app.dependency_overrides.pop(get_poster_aggregator, None) + + [candidate] = resp.json() + assert {k: candidate[k] for k in ("language", "textless", "width", "height")} == { + "language": "fr", "textless": False, "width": 1400, "height": 2100, + } diff --git a/affiche-backend/tests/test_status_counts.py b/affiche-backend/tests/test_status_counts.py index fe4db6a..fa9b926 100644 --- a/affiche-backend/tests/test_status_counts.py +++ b/affiche-backend/tests/test_status_counts.py @@ -58,6 +58,7 @@ def _seed(db, library_id: int, items: list[dict]) -> None: entity.processed = item.get("processed", False) entity.error_message = item.get("error_message") entity.poster_provider = item.get("poster_provider") + entity.poster_uploaded_at = item.get("poster_uploaded_at") repo.create_or_update_item(entity) db.commit() @@ -238,3 +239,36 @@ def test_the_poster_breakdown_counts_seasons_as_posters_of_their_own(db, library counts = LibraryService(db).count_posters_by_provider(LibraryItemSearch(library_id=library_id)) assert counts == {"tmdb": 1, "mediux": 2} + +def test_ready_and_uploaded_buckets_agree_with_their_filters(db, library_id): + from datetime import datetime + uploaded_at = datetime(2026, 9, 1) + _seed(db, library_id, [ + {"title": "Pending"}, + {"title": "Ready One", "processed": True}, + {"title": "Ready Two", "processed": True}, + {"title": "On Server", "processed": True, "poster_uploaded_at": uploaded_at}, + {"title": "Failed Again", "processed": True, "error_message": "boom", + "poster_uploaded_at": uploaded_at}, + {"title": "Failed", "error_message": "boom"}, + ]) + + stats = LibraryService(db).count_status_buckets(LibraryItemSearch(library_id=library_id)) + + assert stats.ready == 2 == _listing_count(db, library_id, ItemStatusFilter.READY) + assert stats.uploaded == 2 == _listing_count(db, library_id, ItemStatusFilter.UPLOADED) + +def test_status_and_uploaded_cannot_both_be_passed(library_id): + with pytest.raises(ValueError): + LibraryItemSearch(library_id=library_id, status=ItemStatusFilter.READY, uploaded=True) + +def test_item_ids_cover_every_match_in_listing_order_regardless_of_paging(db, library_id): + _seed(db, library_id, [{"title": t} for t in ["Charlie", "Alpha", "Bravo"]] + + [{"title": "Done", "processed": True}]) + service = LibraryService(db) + + ids = service.find_item_ids(LibraryItemSearch( + library_id=library_id, status=ItemStatusFilter.UNPROCESSED, page=1, page_size=1)) + + titles = {item.id: item.title for item in service.find_items(LibraryItemSearch(library_id=library_id))} + assert [titles[i] for i in ids] == ["Alpha", "Bravo", "Charlie"] diff --git a/affiche-frontend/src/App.tsx b/affiche-frontend/src/App.tsx index 314b5c9..b512fa3 100644 --- a/affiche-frontend/src/App.tsx +++ b/affiche-frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { createBrowserRouter, RouterProvider, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Layout } from './components/layout'; -import { ChangePasswordPage, CollectionsPage, DashboardPage, LibraryPage, SettingsPage, LoginPage, SetupPage } from './pages'; +import { ChangePasswordPage, CollectionsPage, DashboardPage, LibraryPage, SettingsPage, LoginPage, SetupPage, WelcomePage } from './pages'; import { libraryApi, mediaServerApi } from './api'; import { AuthProvider, useAuth } from './context/AuthContext'; import { ToastProvider } from './context/ToastContext'; @@ -78,7 +78,7 @@ function AppContent() { const selectedServerLibraries = selectedServer?.libraries || []; if (isLoading) { - return Loading...; + return Loading…; } return ( @@ -146,12 +146,22 @@ function AppContent() { /> } /> { +} + + } + /> + { } - : No media server configured yet.} + element={} /> @@ -203,20 +213,23 @@ function AppRoutes() { ); } -function App() { +function Root() { return ( - - { -} - - - - - - - - + + + + + + + + ); } +const router = createBrowserRouter([{ path: '*', element: }]); + +function App() { + return ; +} + export default App; diff --git a/affiche-frontend/src/api/libraries.ts b/affiche-frontend/src/api/libraries.ts index 3dd12ed..1e4043a 100644 --- a/affiche-frontend/src/api/libraries.ts +++ b/affiche-frontend/src/api/libraries.ts @@ -66,6 +66,27 @@ export const libraryApi = { ); }, + getLibraryItemIds: ( + mediaServerId: number, + libraryId: number, + options?: { + search?: string; status?: ItemStatusFilter; provider?: string; + + sortBy?: string; sortDir?: 'asc' | 'desc'; + } + ) => { + const params = new URLSearchParams(); + if (options?.search) params.append('search', options.search); + if (options?.status) params.append('status', options.status); + if (options?.provider) params.append('provider', options.provider); + if (options?.sortBy) params.append('sort_by', options.sortBy); + if (options?.sortDir) params.append('sort_dir', options.sortDir); + const queryString = params.toString(); + return api.get( + `/media-servers/${mediaServerId}/libraries/${libraryId}/items/ids${queryString ? `?${queryString}` : ''}` + ); + }, + getLibraryItemCounts: ( mediaServerId: number, libraryId: number, diff --git a/affiche-frontend/src/components/collections/CollectionCard.module.css b/affiche-frontend/src/components/collections/CollectionCard.module.css index cf904e9..b3fc1e7 100644 --- a/affiche-frontend/src/components/collections/CollectionCard.module.css +++ b/affiche-frontend/src/components/collections/CollectionCard.module.css @@ -1,6 +1,6 @@ .card { background-color: var(--bg-card); - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; @@ -47,7 +47,7 @@ left: 8px; display: flex; padding: 4px; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); background-color: var(--bg-tertiary); box-shadow: var(--shadow-sm); @@ -58,7 +58,7 @@ } .title { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); white-space: nowrap; @@ -68,6 +68,6 @@ } .count { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); } diff --git a/affiche-frontend/src/components/collections/CollectionDetail.module.css b/affiche-frontend/src/components/collections/CollectionDetail.module.css index 475b96b..c9c588c 100644 --- a/affiche-frontend/src/components/collections/CollectionDetail.module.css +++ b/affiche-frontend/src/components/collections/CollectionDetail.module.css @@ -7,7 +7,7 @@ align-items: center; gap: 8px; margin-bottom: 20px; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-secondary); background: transparent; transition: color 0.15s; @@ -30,7 +30,7 @@ justify-content: center; width: 52px; height: 52px; - border-radius: 10px; + border-radius: var(--radius-xl); color: var(--text-secondary); background-color: var(--bg-tertiary); flex-shrink: 0; @@ -42,7 +42,7 @@ } .title { - font-size: 1.6rem; + font-size: var(--text-3xl); font-weight: 600; color: var(--text-primary); white-space: nowrap; @@ -52,7 +52,7 @@ .subtitle { margin-top: 2px; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-muted); } @@ -68,8 +68,8 @@ gap: 6px; padding: 8px 14px; border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.85rem; + border-radius: var(--radius-md); + font-size: var(--text-md); color: var(--text-secondary); background-color: var(--bg-card); transition: background-color 0.15s, color 0.15s; @@ -98,9 +98,10 @@ display: block; margin-bottom: 16px; padding: 10px 14px; - border-radius: 8px; - font-size: 0.85rem; + border-radius: var(--radius-lg); + font-size: var(--text-md); color: var(--text-secondary); + border: 1px solid var(--border); background-color: var(--bg-card); } @@ -116,14 +117,14 @@ align-items: center; gap: 12px; padding: 10px 14px; - border-radius: 8px; + border-radius: var(--radius-lg); background-color: var(--bg-card); } .memberTitle { flex: 1; min-width: 0; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); white-space: nowrap; overflow: hidden; @@ -131,14 +132,14 @@ } .memberYear { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } .remove { display: flex; padding: 5px; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-muted); background: transparent; transition: color 0.15s, background-color 0.15s; @@ -146,7 +147,7 @@ .remove:hover:not(:disabled) { color: var(--error); - background-color: var(--bg-hover); + background-color: var(--error-tint); } .remove:disabled { @@ -156,6 +157,6 @@ .empty { padding: 32px 0; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-secondary); } diff --git a/affiche-frontend/src/components/collections/CollectionDetail.tsx b/affiche-frontend/src/components/collections/CollectionDetail.tsx index 2cf2f20..f1e389f 100644 --- a/affiche-frontend/src/components/collections/CollectionDetail.tsx +++ b/affiche-frontend/src/components/collections/CollectionDetail.tsx @@ -112,7 +112,7 @@ export function CollectionDetail({ onClick={() => onSelectPoster(collection)} disabled={isBusy} > - Select poster + Choose artwork {isAddOpen && available.length > 0 && ( -
+
{available.map((lang) => ( @@ -185,6 +194,7 @@ export function Header({ )}
+ )}
{isTrash ? ( @@ -209,9 +219,9 @@ export function Header({ title={runningLabel ?? 'Generate decorated posters'} > {runningLabel - ? + ? : } - {runningLabel ?? 'Generate Posters'} + {runningLabel ?? (pendingCount ? `Generate ${pendingCount.toLocaleString()} posters` : 'Generate posters')} {isLoading && onStopTask ? ( @@ -233,6 +243,12 @@ export function Header({ )}
+ { +} + + {showProgressBar ? `${runningTaskVerb(taskKind)} ${title}` : ''} + + { } {showProgressBar && } diff --git a/affiche-frontend/src/components/layout/Layout.module.css b/affiche-frontend/src/components/layout/Layout.module.css index 10ff031..8e35238 100644 --- a/affiche-frontend/src/components/layout/Layout.module.css +++ b/affiche-frontend/src/components/layout/Layout.module.css @@ -8,7 +8,13 @@ min-width: 0; margin-left: var(--sidebar-width); min-height: 100vh; - transition: margin-left 0.2s ease; +} + +.overlayBackdrop { + position: fixed; + inset: 0; + z-index: var(--z-sidebar-backdrop); + background-color: var(--backdrop); } .mainCollapsed { diff --git a/affiche-frontend/src/components/layout/Layout.test.tsx b/affiche-frontend/src/components/layout/Layout.test.tsx new file mode 100644 index 0000000..73e38cd --- /dev/null +++ b/affiche-frontend/src/components/layout/Layout.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +import { Layout } from './Layout'; + +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ username: 'boss', isAdmin: true, logout: vi.fn() }), +})); + +vi.mock('../../api', async (importOriginal) => ({ + ...(await importOriginal()), + settingsApi: { getSettingsInfo: () => Promise.resolve({ version: '1.0.0' }) }, +})); + +vi.mock('../search', () => ({ GlobalSearchModal: () => null })); + +function pretendWindowIsNarrow(narrow: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: narrow && query.includes('max-width'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +} + +afterEach(() => vi.unstubAllGlobals()); + +function renderLayout() { + render( + + +

Page

+
+
+ ); +} + +const openFromRail = async (user: ReturnType) => + user.click(screen.getAllByRole('button', { name: 'Expand menu' })[0]); + +describe('Layout in a narrow window', () => { + it('shows only the rail until the menu is opened', () => { + pretendWindowIsNarrow(true); + renderLayout(); + + expect(screen.getAllByRole('button', { name: 'Expand menu' }).length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: 'Close menu' })).not.toBeInTheDocument(); + }); + + it('closes the open menu on Escape', async () => { + pretendWindowIsNarrow(true); + const user = userEvent.setup(); + renderLayout(); + + await openFromRail(user); + expect(screen.getByRole('button', { name: 'Close menu' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('button', { name: 'Close menu' })).not.toBeInTheDocument(); + }); + + it('closes the open menu once something in it is picked', async () => { + pretendWindowIsNarrow(true); + const user = userEvent.setup(); + renderLayout(); + + await openFromRail(user); + + await user.click(screen.getByText('Dashboard')); + + expect(screen.queryByRole('button', { name: 'Close menu' })).not.toBeInTheDocument(); + }); +}); + +describe('Layout in a wide window', () => { + it('collapses to the rail and back, with no overlay involved', async () => { + pretendWindowIsNarrow(false); + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole('button', { name: 'Collapse menu' })); + await openFromRail(user); + + expect(screen.getByRole('button', { name: 'Collapse menu' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close menu' })).not.toBeInTheDocument(); + }); +}); diff --git a/affiche-frontend/src/components/layout/Layout.tsx b/affiche-frontend/src/components/layout/Layout.tsx index 315a7e6..5b4ee00 100644 --- a/affiche-frontend/src/components/layout/Layout.tsx +++ b/affiche-frontend/src/components/layout/Layout.tsx @@ -1,9 +1,13 @@ import { useEffect, useState, type ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; import { Sidebar } from './Sidebar'; +import { useMediaQuery } from '../../hooks'; import { GlobalSearchModal } from '../search'; import type { Library, MediaServerResponse, SearchHit } from '../../types'; import styles from './Layout.module.css'; +const NARROW_QUERY = '(max-width: 899px)'; + interface MediaServerWithLibraries { server: MediaServerResponse; libraries: Library[]; @@ -36,6 +40,26 @@ export function Layout({ const [collapsed, setCollapsed] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); + const isNarrow = useMediaQuery(NARROW_QUERY); + const [overlayOpen, setOverlayOpen] = useState(false); + const location = useLocation(); + const [prevLocation, setPrevLocation] = useState(location); + if (location !== prevLocation) { + + setPrevLocation(location); + setOverlayOpen(false); + } + const isOverlayShown = isNarrow && overlayOpen; + + useEffect(() => { + if (!isOverlayShown) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOverlayOpen(false); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isOverlayShown]); + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key.toLowerCase() === 'k' && (event.ctrlKey || event.metaKey)) { @@ -54,14 +78,18 @@ export function Layout({ selectedMediaServerId={selectedMediaServerId} selectedLibraryId={selectedLibraryId} view={view} - collapsed={collapsed} - onToggleCollapse={() => setCollapsed(c => !c)} + collapsed={isNarrow || collapsed} + overlayOpen={isOverlayShown} + onToggleCollapse={() => (isNarrow ? setOverlayOpen((open) => !open) : setCollapsed((c) => !c))} onSelectLibrary={onSelectLibrary} onSelectTrash={onSelectTrash} onSelectCollections={onSelectCollections} onOpenSearch={() => setIsSearchOpen(true)} /> -
{children}
+ {isOverlayShown && ( +
setOverlayOpen(false)} aria-hidden="true" /> + )} +
{children}
{isSearchOpen && ( setIsSearchOpen(false)} diff --git a/affiche-frontend/src/components/layout/Sidebar.module.css b/affiche-frontend/src/components/layout/Sidebar.module.css index 6f01001..b41eb1a 100644 --- a/affiche-frontend/src/components/layout/Sidebar.module.css +++ b/affiche-frontend/src/components/layout/Sidebar.module.css @@ -8,7 +8,7 @@ position: fixed; left: 0; top: 0; - z-index: 100; + z-index: var(--z-sidebar); transition: transform 0.2s ease; } @@ -21,6 +21,10 @@ box-shadow: 2px 0 16px var(--shadow-color); } +.sidebar.overlay { + z-index: var(--z-sidebar-overlay); +} + .rail { position: fixed; left: 0; @@ -34,7 +38,7 @@ align-items: center; gap: 4px; padding: 12px 0; - z-index: 99; + z-index: var(--z-sidebar-rail); } .railLogo { @@ -53,7 +57,7 @@ justify-content: center; width: 40px; height: 40px; - border-radius: 8px; + border-radius: var(--radius-lg); color: var(--text-secondary); transition: all 0.15s; } @@ -85,9 +89,9 @@ gap: 10px; width: 100%; padding: 10px 12px; - border-radius: 8px; + border-radius: var(--radius-lg); color: var(--text-secondary); - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; text-align: left; transition: all 0.15s; @@ -103,7 +107,7 @@ align-items: center; gap: 6px; padding: 6px 12px 2px; - font-size: 0.72rem; + font-size: var(--text-2xs); color: var(--text-muted); } @@ -126,9 +130,9 @@ .betaTag { padding: 1px 6px; border: 1px solid var(--accent); - border-radius: 10px; + border-radius: var(--radius-xl); color: var(--accent); - font-size: 0.65rem; + font-size: var(--text-micro); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; @@ -154,7 +158,7 @@ align-items: center; justify-content: center; padding: 4px; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-secondary); transition: all 0.15s; } @@ -179,7 +183,7 @@ } .logoText { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 600; letter-spacing: 0.24em; text-transform: uppercase; @@ -211,7 +215,7 @@ width: 100%; padding: 10px 20px; color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; text-align: left; transition: background-color 0.15s; @@ -230,10 +234,10 @@ margin-left: auto; padding: 2px 6px; border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--text-muted); font-family: inherit; - font-size: 0.7rem; + font-size: var(--text-2xs); white-space: nowrap; } @@ -248,7 +252,7 @@ width: 100%; padding: 8px 20px 8px 48px; color: var(--text-secondary); - font-size: 0.85rem; + font-size: var(--text-md); text-align: left; transition: all 0.15s; } @@ -270,7 +274,7 @@ width: 100%; padding: 7px 20px 7px 68px; color: var(--text-secondary); - font-size: 0.82rem; + font-size: var(--text-sm); text-align: left; transition: all 0.15s; } @@ -287,11 +291,12 @@ .count { margin-left: auto; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); background-color: var(--bg-primary); padding: 2px 6px; - border-radius: 10px; + border-radius: var(--radius-xl); + font-variant-numeric: tabular-nums; } .serverList { @@ -305,12 +310,12 @@ gap: 8px; padding: 16px 20px; color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-md); } .addLink { color: var(--accent); - font-size: 0.8rem; + font-size: var(--text-sm); text-decoration: none; } @@ -329,7 +334,7 @@ width: 100%; padding: 8px 16px 8px 28px; color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; text-align: left; transition: background-color 0.15s; @@ -352,11 +357,11 @@ } .serverType { - font-size: 0.7rem; + font-size: var(--text-2xs); color: var(--text-muted); background-color: var(--bg-primary); padding: 2px 6px; - border-radius: 4px; + border-radius: var(--radius-sm); text-transform: uppercase; font-weight: 600; } @@ -373,7 +378,7 @@ width: 100%; padding: 6px 16px 6px 48px; color: var(--text-secondary); - font-size: 0.8rem; + font-size: var(--text-sm); text-align: left; transition: all 0.15s; } @@ -395,10 +400,14 @@ white-space: nowrap; } +.libraryItem .count { + flex: none; +} + .emptyLibraries { padding: 8px 16px 8px 48px; color: var(--text-muted); - font-size: 0.75rem; + font-size: var(--text-xs); font-style: italic; } @@ -414,7 +423,7 @@ width: 100%; padding: 6px 16px 6px 64px; color: var(--text-secondary); - font-size: 0.8rem; + font-size: var(--text-sm); text-align: left; transition: all 0.15s; } diff --git a/affiche-frontend/src/components/layout/Sidebar.test.tsx b/affiche-frontend/src/components/layout/Sidebar.test.tsx index b51b61e..f76177d 100644 --- a/affiche-frontend/src/components/layout/Sidebar.test.tsx +++ b/affiche-frontend/src/components/layout/Sidebar.test.tsx @@ -105,3 +105,36 @@ describe('Sidebar server expansion', () => { expect(filmsVisible()).toBe(false); }); }); + +describe('Sidebar for keyboard and screen-reader users', () => { + it('says whether each collapsible section is open', async () => { + const user = userEvent.setup(); + renderSidebar(); + + const servers = screen.getByRole('button', { name: /Media servers/ }); + const ubuntu = screen.getByRole('button', { name: /Ubuntu/ }); + expect(ubuntu).toHaveAttribute('aria-expanded', 'false'); + + await user.click(ubuntu); + expect(ubuntu).toHaveAttribute('aria-expanded', 'true'); + expect(servers).toHaveAttribute('aria-expanded', 'true'); + }); + + it('takes the collapsed menu out of the tab order, leaving the rail', () => { + render( + + + + ); + + expect(document.querySelector('aside')).toHaveAttribute('inert'); + expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument(); + }); +}); diff --git a/affiche-frontend/src/components/layout/Sidebar.tsx b/affiche-frontend/src/components/layout/Sidebar.tsx index 4c9cd86..6e2e080 100644 --- a/affiche-frontend/src/components/layout/Sidebar.tsx +++ b/affiche-frontend/src/components/layout/Sidebar.tsx @@ -5,6 +5,7 @@ import type { Library as LibraryType, MediaServerResponse } from '../../types'; import { useAuth } from '../../context/AuthContext'; import { useAppVersion } from '../../hooks'; import { SOURCE_URL } from '../../constants/app'; +import { searchShortcutLabel } from '../../constants/platform'; import { AfficheLogo, LibraryTypeIcon, MediaServerIcon } from '../common'; import styles from './Sidebar.module.css'; @@ -19,6 +20,8 @@ interface SidebarProps { selectedLibraryId?: number; view?: 'library' | 'trash' | 'collections'; collapsed?: boolean; + + overlayOpen?: boolean; onToggleCollapse?: () => void; onSelectLibrary: (mediaServerId: number, libraryId: number | undefined) => void; onSelectTrash: (mediaServerId: number, libraryId: number | undefined) => void; @@ -32,6 +35,7 @@ export function Sidebar({ selectedLibraryId, view = 'library', collapsed = false, + overlayOpen = false, onToggleCollapse, onSelectLibrary, onSelectTrash, @@ -138,19 +142,19 @@ export function Sidebar({ - +
@@ -199,7 +205,7 @@ export function Sidebar({ Search { } - Ctrl K + {searchShortcutLabel()}
@@ -217,9 +223,10 @@ export function Sidebar({ @@ -245,6 +252,7 @@ export function Sidebar({ {libs.map((lib) => ( @@ -366,6 +376,7 @@ export function Sidebar({ diff --git a/affiche-frontend/src/components/library/CoverageMeter.module.css b/affiche-frontend/src/components/library/CoverageMeter.module.css new file mode 100644 index 0000000..046c4e1 --- /dev/null +++ b/affiche-frontend/src/components/library/CoverageMeter.module.css @@ -0,0 +1,84 @@ +.meter { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.percent { + font-weight: 650; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.bar { + display: flex; + flex-shrink: 0; + height: 6px; + border-radius: var(--radius-xs); + background-color: var(--bg-hover); + overflow: hidden; +} + +.done { + background-color: var(--success); +} + +.failed { + background-color: var(--error); +} + +.line { + min-width: 0; + font-size: var(--text-sm); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +.row { + flex: 1; + margin-left: 12px; +} + +.row .percent { + font-size: var(--text-lg); +} + +.row .bar { + width: 140px; +} + +.stage { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 16px; + row-gap: 6px; + align-items: center; +} + +.stage .percent { + grid-row: 1 / span 2; + font-size: 2.4rem; + line-height: 1; + letter-spacing: -0.02em; +} + +.stage .bar { + width: min(320px, 100%); + height: 8px; + border-radius: var(--radius-sm); +} + +.stage .line { + font-size: var(--text-md); +} + +@media (max-width: 760px) { + .row .line { + display: none; + } +} diff --git a/affiche-frontend/src/components/library/CoverageMeter.tsx b/affiche-frontend/src/components/library/CoverageMeter.tsx new file mode 100644 index 0000000..9f1ca27 --- /dev/null +++ b/affiche-frontend/src/components/library/CoverageMeter.tsx @@ -0,0 +1,28 @@ +import type { ItemStats } from '../../types'; +import { coverageLine, coveragePercent, failedPercent } from './coverage'; +import styles from './CoverageMeter.module.css'; + +interface CoverageMeterProps { + + label: string; + stats: ItemStats; + + variant?: 'row' | 'stage'; +} + +export function CoverageMeter({ label, stats, variant = 'row' }: CoverageMeterProps) { + const percent = coveragePercent(stats); + const line = coverageLine(stats); + return ( +
+ {percent}% + + + {stats.errors > 0 && ( + + )} + + +
+ ); +} diff --git a/affiche-frontend/src/components/library/EpisodeList.module.css b/affiche-frontend/src/components/library/EpisodeList.module.css index 9604418..14eb2b1 100644 --- a/affiche-frontend/src/components/library/EpisodeList.module.css +++ b/affiche-frontend/src/components/library/EpisodeList.module.css @@ -11,10 +11,10 @@ gap: 6px; padding: 8px 12px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: none; color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); cursor: pointer; transition: all 0.15s; } @@ -25,7 +25,7 @@ .title { margin: 0; - font-size: 1.15rem; + font-size: var(--text-xl); } .seasonSuffix { diff --git a/affiche-frontend/src/components/library/EpisodeList.tsx b/affiche-frontend/src/components/library/EpisodeList.tsx index b19dadb..7b906e3 100644 --- a/affiche-frontend/src/components/library/EpisodeList.tsx +++ b/affiche-frontend/src/components/library/EpisodeList.tsx @@ -71,14 +71,14 @@ export function EpisodeList({ {isLoading ? (
-
- Loading episodes... +
+ Loading episodes…
) : episodes.length === 0 ? (

No episodes tracked for this season

- Enable “Track episodes” for this library (Settings → Media Servers) and re-sync to see + Enable “Track episodes” for this library (Settings → Media servers) and re-sync to see per-episode details here.

diff --git a/affiche-frontend/src/components/library/FilterMenu.module.css b/affiche-frontend/src/components/library/FilterMenu.module.css index 1f19839..5333c90 100644 --- a/affiche-frontend/src/components/library/FilterMenu.module.css +++ b/affiche-frontend/src/components/library/FilterMenu.module.css @@ -15,11 +15,11 @@ align-items: center; gap: 7px; padding: 7px 12px; - border-radius: 8px; + border-radius: var(--radius-lg); border: 1px solid var(--border); background-color: var(--bg-tertiary); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; white-space: nowrap; transition: background-color 0.15s, border-color 0.15s; @@ -42,10 +42,10 @@ min-width: 18px; height: 18px; padding: 0 5px; - border-radius: 9px; - background-color: var(--accent); + border-radius: var(--radius-lg); + background-color: var(--accent-fill); color: var(--text-on-fill); - font-size: 0.7rem; + font-size: var(--text-2xs); font-weight: 600; font-variant-numeric: tabular-nums; } @@ -54,13 +54,13 @@ position: absolute; top: calc(100% + 8px); left: 0; - width: 264px; + width: min(264px, calc(100vw - 32px)); padding: 6px; - border-radius: 10px; + border-radius: var(--radius-xl); border: 1px solid var(--border); background-color: var(--bg-secondary); box-shadow: var(--shadow-lg); - z-index: 20; + z-index: var(--z-popover); } .section { @@ -79,7 +79,7 @@ display: block; width: 100%; padding: 4px 10px 6px; - font-size: 0.7rem; + font-size: var(--text-2xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; @@ -96,8 +96,8 @@ align-items: center; gap: 10px; padding: 7px 10px; - border-radius: 6px; - font-size: 0.85rem; + border-radius: var(--radius-md); + font-size: var(--text-md); color: var(--text-primary); cursor: pointer; transition: background-color 0.15s; @@ -127,7 +127,7 @@ .rowCount { flex-shrink: 0; color: var(--text-secondary); - font-size: 0.8rem; + font-size: var(--text-sm); font-variant-numeric: tabular-nums; } @@ -139,17 +139,17 @@ min-width: 0; padding: 5px 8px 5px 10px; - border-radius: 7px; + border-radius: var(--radius-md); border: 1px solid var(--accent); background-color: transparent; color: var(--accent); - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 500; transition: background-color 0.15s, color 0.15s; } .chip:hover { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } diff --git a/affiche-frontend/src/components/library/ItemArtworkStrip.module.css b/affiche-frontend/src/components/library/ItemArtworkStrip.module.css new file mode 100644 index 0000000..ddff69f --- /dev/null +++ b/affiche-frontend/src/components/library/ItemArtworkStrip.module.css @@ -0,0 +1,86 @@ +.section { + margin-top: 36px; +} + +.heading { + font-size: var(--text-base); + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.note { + font-size: var(--text-md); + color: var(--text-muted); + margin-bottom: 12px; +} + +.strip { + display: flex; + gap: 12px; + overflow-x: auto; + padding: 4px 4px 8px; +} + +.tile { + flex: 0 0 96px; + min-width: 0; + text-align: left; +} + +.art { + display: block; + position: relative; + aspect-ratio: 2/3; + border-radius: var(--radius-md); + overflow: hidden; + background-color: var(--bg-tertiary); + transition: transform 0.15s, box-shadow 0.15s; +} + +.art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tile:hover .art { + transform: translateY(-3px); + box-shadow: var(--shadow-md); +} + +.more { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + border: 1px dashed var(--border); + background-color: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + text-align: center; + line-height: 1.3; +} + +.tile:hover .more { + color: var(--text-primary); +} + +.caption { + display: block; + margin-top: 6px; + font-size: var(--text-xs); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.skeleton { + flex: 0 0 96px; + aspect-ratio: 2/3; + border-radius: var(--radius-md); + background-color: var(--bg-tertiary); +} diff --git a/affiche-frontend/src/components/library/ItemArtworkStrip.tsx b/affiche-frontend/src/components/library/ItemArtworkStrip.tsx new file mode 100644 index 0000000..ca52ee0 --- /dev/null +++ b/affiche-frontend/src/components/library/ItemArtworkStrip.tsx @@ -0,0 +1,56 @@ +import { Search } from 'lucide-react'; + +import { providerLabel } from '../../constants/providers'; +import { useInView, useItemArtwork } from '../../hooks'; +import type { PosterTarget } from './posterTarget'; +import styles from './ItemArtworkStrip.module.css'; + +const SHOWN = 7; + +interface ItemArtworkStripProps { + target: PosterTarget; + + onPick: (posterUrl?: string) => void; +} + +export function ItemArtworkStrip({ target, onPick }: ItemArtworkStripProps) { + const [sectionRef, isNear] = useInView(); + const { posters, isLoading, error } = useItemArtwork(target, isNear); + + const shown = posters.slice(0, SHOWN); + + return ( +
+

Other artwork

+ {error &&

{error}

} + {!isLoading && !error && shown.length === 0 && ( +

Your providers have no artwork for this title.

+ )} +
+ {isLoading + ? Array.from({ length: 5 }, (_, i) =>
) + : shown.map(({ url, provider }, index) => ( + + ))} + +
+
+ ); +} diff --git a/affiche-frontend/src/components/library/ItemCard.module.css b/affiche-frontend/src/components/library/ItemCard.module.css index 84e0e38..7dbb932 100644 --- a/affiche-frontend/src/components/library/ItemCard.module.css +++ b/affiche-frontend/src/components/library/ItemCard.module.css @@ -1,11 +1,34 @@ .card { + position: relative; background-color: var(--bg-card); - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; - cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; } +.card.openable { + cursor: pointer; +} + +.open { + position: absolute; + inset: 0; + z-index: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.open:focus-visible { + outline: none; +} + +.card:has(.open:focus-visible) { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + .card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); @@ -44,12 +67,6 @@ opacity: 1; } -.status { - position: absolute; - top: 8px; - right: 8px; -} - .card.selected { outline: 2px solid var(--accent); outline-offset: 1px; @@ -62,7 +79,7 @@ z-index: 1; display: flex; padding: 5px; - border-radius: 6px; + border-radius: var(--radius-md); background-color: var(--scrim-control); opacity: 0; transition: opacity 0.15s; @@ -90,7 +107,9 @@ } .card.trash:hover .poster, -.card.trash:hover .info { +.card.trash:hover .info, +.card.trash:focus-within .poster, +.card.trash:focus-within .info { opacity: 1; filter: none; } @@ -103,8 +122,8 @@ align-items: center; gap: 6px; padding: 6px 10px; - border-radius: 6px; - font-size: 0.8rem; + border-radius: var(--radius-md); + font-size: var(--text-sm); font-weight: 500; color: var(--text-on-fill); background-color: var(--scrim-control); @@ -112,52 +131,14 @@ transition: opacity 0.15s, background-color 0.15s; } -.card.trash:hover .restoreButton { +.card.trash:hover .restoreButton, +.card.trash:focus-within .restoreButton, +.restoreButton:focus-visible { opacity: 1; } .restoreButton:hover { - background-color: var(--accent); -} - -.processed { - color: var(--success); -} - -.pending { - color: var(--text-muted); -} - -.failed { - color: var(--error); -} - -.badges { - position: absolute; - bottom: 8px; - left: 8px; - - max-width: calc(100% - 48px); - display: flex; - flex-direction: column; - align-items: start; - gap: 4px; -} - -.badge { - display: flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 6px; - font-size: 0.72rem; - font-weight: 600; - color: var(--text-on-fill); - box-shadow: var(--shadow-sm); -} - -.failedBadge { - background-color: var(--error); + background-color: var(--accent-fill); } .lockButton { @@ -168,7 +149,7 @@ display: flex; padding: 6px; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-on-fill); background-color: var(--scrim-control); cursor: pointer; @@ -183,7 +164,7 @@ } .lockButton:hover { - background-color: var(--accent); + background-color: var(--accent-fill); } .lockButton:disabled { @@ -192,7 +173,7 @@ } .lockButtonOn { - background-color: var(--accent); + background-color: var(--accent-fill); } .info { @@ -200,7 +181,7 @@ } .title { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); white-space: nowrap; @@ -209,7 +190,46 @@ margin-bottom: 4px; } +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 8px; + min-width: 0; +} + +.state { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: var(--text-xs); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stateDot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--text-muted); +} + +.uploaded .stateDot { background-color: var(--success); } +.ready .stateDot { background-color: var(--accent); } +.pending .stateDot { background-color: transparent; box-shadow: inset 0 0 0 1.5px var(--text-muted); } +.failed .stateDot { background-color: var(--error); } + +.state.failed { + color: var(--error); +} + .year { - font-size: 0.8rem; + margin-left: auto; + flex-shrink: 0; + font-size: var(--text-sm); color: var(--text-muted); } diff --git a/affiche-frontend/src/components/library/ItemCard.test.tsx b/affiche-frontend/src/components/library/ItemCard.test.tsx index 289565f..ec19ecb 100644 --- a/affiche-frontend/src/components/library/ItemCard.test.tsx +++ b/affiche-frontend/src/components/library/ItemCard.test.tsx @@ -107,7 +107,7 @@ describe('ItemCard poster', () => { describe('ItemCard select mode', () => { - const card = () => screen.getByRole('button'); + const card = () => screen.getByRole('button', { name: /^(Open )?Arrival$/ }); it('opens the item on click when select mode is off', () => { const onClick = vi.fn(); @@ -168,6 +168,13 @@ describe('ItemCard badges', () => { expect(screen.getByText('Failed')).toBeInTheDocument(); }); + it('gives the failure to keyboard and screen-reader users, not only to a hover tooltip', () => { + renderCard(makeItem({ error_message: 'No poster found' })); + + expect(screen.getByRole('button', { name: 'Open Arrival' })) + .toHaveAccessibleDescription('No poster found'); + }); + it('shows the failure alongside the lock control', () => { render( diff --git a/affiche-frontend/src/components/library/ItemCard.tsx b/affiche-frontend/src/components/library/ItemCard.tsx index ab8d944..00f9d89 100644 --- a/affiche-frontend/src/components/library/ItemCard.tsx +++ b/affiche-frontend/src/components/library/ItemCard.tsx @@ -1,9 +1,10 @@ -import { CheckCircle, Circle, RotateCcw, AlertTriangle, Lock, Unlock } from 'lucide-react'; +import { useId } from 'react'; +import { RotateCcw, Lock, Unlock } from 'lucide-react'; import { libraryApi } from '../../api'; import { usePosterImage } from '../../hooks'; import type { LibraryItem } from '../../types'; -import { activationProps } from '../common'; import { failureTooltip } from './format'; +import { POSTER_STATE_LABEL, posterState } from './posterState'; import styles from './ItemCard.module.css'; interface ItemCardProps { @@ -13,7 +14,7 @@ interface ItemCardProps { variant?: 'default' | 'trash'; onRestore?: () => void; - onToggleSelect?: () => void; + onToggleSelect?: (extend: boolean) => void; isSelected?: boolean; selectMode?: boolean; @@ -30,6 +31,8 @@ export function ItemCard({ selectMode = false, onToggleLock, isLockPending = false, anchorLetter, }: ItemCardProps) { const isTrash = variant === 'trash'; + const failure = isTrash ? undefined : failureTooltip(item); + const failureId = useId(); const inSelectMode = selectMode && !!onToggleSelect; const activate = inSelectMode ? onToggleSelect : onClick; @@ -47,12 +50,24 @@ export function ItemCard({ return (
activate(e.shiftKey) : undefined} id={anchorLetter ? `alpha-anchor-${anchorLetter}` : undefined} style={anchorLetter ? { scrollMarginTop: 'calc(var(--header-height) + 16px)' } : undefined} > + { +} + {activate && ( + )} - {isTrash ? ( + {isTrash && ( onRestore && ( ) - ) : ( -
- {item.error_message ? ( - - ) : item.processed ? ( - - ) : ( - - )} -
)}

{item.title}

- {item.year && {item.year}} +
+ {!isTrash && ( + + + )} + {failure && ( + {failure} + )} + {item.year && {item.year}} +
); diff --git a/affiche-frontend/src/components/library/ItemDetail.module.css b/affiche-frontend/src/components/library/ItemDetail.module.css index 4e2dcfb..110329d 100644 --- a/affiche-frontend/src/components/library/ItemDetail.module.css +++ b/affiche-frontend/src/components/library/ItemDetail.module.css @@ -4,14 +4,67 @@ margin: 0 auto; } +.topBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 24px; +} + +.topBar .backButton { + margin-bottom: 0; +} + +.stepper { + display: flex; + gap: 6px; + min-width: 0; +} + +.stepButton { + display: flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: var(--text-md); + color: var(--text-secondary); +} + +.stepButton:hover:not(:disabled) { + background-color: var(--bg-hover); + color: var(--text-primary); +} + +.stepButton:disabled { + opacity: 0.4; + cursor: default; +} + +.stepLabel { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 600px) { + .stepLabel { + display: none; + } +} + .backButton { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); - font-size: 0.9rem; + font-size: var(--text-base); padding: 8px 12px; - border-radius: 6px; + border-radius: var(--radius-md); margin-bottom: 24px; transition: all 0.15s; } @@ -31,104 +84,53 @@ align-items: center; gap: 10px; padding: 12px 16px; - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); - border-radius: 8px; + border-radius: var(--radius-lg); margin-bottom: 24px; - font-size: 0.9rem; -} - -.spinner { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + font-size: var(--text-base); } .header { - display: flex; - gap: 32px; - margin-bottom: 40px; -} - -.posterContainer { - flex-shrink: 0; -} - -.posterFrame { position: relative; + display: grid; + grid-template-columns: minmax(240px, 380px) minmax(0, 1fr); + gap: 48px; + align-items: start; + margin-bottom: 40px; } -.expandButton { +.ambient { position: absolute; - top: 8px; - right: 8px; - display: flex; - align-items: center; - justify-content: center; - padding: 6px; - border-radius: 6px; - color: #fff; - background-color: rgb(0 0 0 / 55%); - opacity: 0; - transition: opacity 0.15s, background-color 0.15s; -} - -.posterFrame:hover .expandButton, -.expandButton:focus-visible { - opacity: 1; -} - -.expandButton:hover { - background-color: rgb(0 0 0 / 75%); -} - -@media (hover: none) { - .expandButton { - opacity: 1; - } -} - -.compareToggle { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - width: 100%; - margin-top: 10px; - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.82rem; - color: var(--text-secondary); - background-color: transparent; - transition: background-color 0.15s, color 0.15s; + top: -80px; + left: -120px; + width: 70%; + height: 640px; + object-fit: cover; + filter: blur(80px) saturate(1.1); + opacity: 0.22; + pointer-events: none; + z-index: 0; } -.compareToggle:hover { - color: var(--text-primary); - background-color: var(--bg-hover); +.posterContainer, +.info { + position: relative; + z-index: 1; } -.compareToggle[aria-pressed='true'] { - color: var(--text-primary); - border-color: var(--accent); +.posterContainer { + min-width: 0; } .poster { - width: 200px; + width: 100%; aspect-ratio: 2/3; background-color: var(--bg-tertiary); - border-radius: 12px; + border-radius: var(--radius-2xl); overflow: hidden; position: relative; - box-shadow: var(--shadow-lg); + box-shadow: var(--shadow-xl); } .placeholder { @@ -163,27 +165,136 @@ } .title { - font-size: 2rem; - font-weight: 600; + font-size: clamp(2rem, 3.6vw, 3rem); + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.02em; color: var(--text-primary); - margin-bottom: 4px; + text-wrap: balance; + margin-bottom: 8px; } .year { - font-size: 1.1rem; - color: var(--text-muted); + font-size: var(--text-lg); + color: var(--text-secondary); margin-bottom: 24px; } +.statusRow { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} + +.statusRow:has(+ .statusDetail) { + margin-bottom: 0; +} + +.statusPill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background-color: var(--bg-secondary); + font-size: var(--text-md); + font-weight: 500; + color: var(--text-primary); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--text-muted); +} + +.uploaded .statusDot { background-color: var(--success); } +.ready .statusDot { background-color: var(--accent); } +.pending .statusDot { background-color: transparent; box-shadow: inset 0 0 0 1.5px var(--text-muted); } +.failed .statusDot { background-color: var(--error); } + +.statusDetail { + margin: 8px 0 20px; + font-size: var(--text-base); + color: var(--text-secondary); +} + +.primaryBlock { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-bottom: 14px; +} + +.primaryBlock:empty { + display: none; +} + +.consequence { + max-width: 56ch; + font-size: var(--text-md); + color: var(--text-secondary); +} + +.details { + margin-top: 28px; + max-width: 640px; + border-top: 1px solid var(--border); +} + +.detailsSummary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + padding: 12px 0; + cursor: pointer; + list-style: none; +} + +.detailsSummary::-webkit-details-marker { + display: none; +} + +.detailsHeading { + font-weight: 600; + color: var(--text-primary); +} + +.detailsHeading::after { + content: ' ▸'; + color: var(--text-muted); +} + +.details[open] .detailsHeading::after { + content: ' ▾'; +} + +.detailsLine { + font-size: var(--text-md); + color: var(--text-secondary); +} + +.facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0 32px; + padding-bottom: 12px; +} + .metadata { display: flex; flex-direction: column; - gap: 12px; - margin-bottom: 28px; + gap: 8px; + margin: 0 0 12px; } .metaHeading { - font-size: 0.75rem; + font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; @@ -200,11 +311,12 @@ .metaLabel { color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-base); min-width: 70px; } .metaValue { + margin: 0; color: var(--text-primary); } @@ -239,11 +351,11 @@ gap: 8px; margin-bottom: 16px; padding: 10px 14px; - border-radius: 8px; - border: 1px solid var(--error); - background-color: var(--error-tint); + border-radius: var(--radius-lg); + border: 1px solid var(--notice-error-edge); + background-color: var(--notice-error-tint); color: var(--error); - font-size: 0.9rem; + font-size: var(--text-base); line-height: 1.4; word-break: break-word; } @@ -267,7 +379,7 @@ align-self: flex-start; padding: 0; color: var(--text-secondary); - font-size: 0.85rem; + font-size: var(--text-md); text-align: left; background: none; } @@ -283,7 +395,7 @@ .errorFix { margin-top: 4px; color: var(--text-secondary); - font-size: 0.85rem; + font-size: var(--text-md); } .errorFix ol { @@ -297,16 +409,18 @@ .actions { display: flex; flex-wrap: wrap; - gap: 12px; + align-items: center; + gap: 8px; } .actionButton { display: flex; + border: 1px solid var(--border); align-items: center; gap: 6px; padding: 10px 16px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); background-color: var(--bg-tertiary); @@ -323,12 +437,12 @@ } .actionButton.primary { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } .actionButton.primary:hover:not(:disabled) { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); } .actionButton.danger { @@ -338,7 +452,7 @@ } .actionButton.danger:hover:not(:disabled) { - background-color: var(--error); + background-color: var(--error-fill); color: var(--text-on-fill); } @@ -348,7 +462,7 @@ } .seasonsTitle { - font-size: 1.25rem; + font-size: var(--text-2xl); font-weight: 600; color: var(--text-primary); margin-bottom: 20px; @@ -368,8 +482,9 @@ } .seasonCard { + position: relative; background-color: var(--bg-card); - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; transition: transform 0.15s, box-shadow 0.15s; } @@ -396,7 +511,7 @@ display: flex; align-items: center; justify-content: center; - font-size: 1.5rem; + font-size: var(--text-3xl); font-weight: 700; color: var(--text-muted); } @@ -433,8 +548,8 @@ align-items: center; gap: 4px; padding: 4px 8px; - border-radius: 4px; - font-size: 0.75rem; + border-radius: var(--radius-sm); + font-size: var(--text-xs); color: var(--accent); background-color: var(--bg-tertiary); border: 1px solid var(--accent); @@ -442,7 +557,7 @@ } .seasonSelectButton:hover { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } @@ -454,53 +569,62 @@ } .seasonNumber { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; color: var(--text-primary); } .seasonTitle { - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -@media (max-width: 600px) { +@media (max-width: 860px) { .header { - flex-direction: column; - align-items: center; - text-align: center; + grid-template-columns: 1fr; + gap: 28px; } - .poster { - width: 160px; + .posterContainer { + max-width: 320px; } +} + +@media (max-width: 600px) { .title { - font-size: 1.5rem; + font-size: var(--text-3xl); } - .metadata { - align-items: center; + .seasonsGrid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; } +} - .metaItem { - flex-direction: column; - gap: 4px; - } +.seasonOpen { + position: absolute; + inset: 0; + z-index: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} - .metaLabel { - min-width: auto; - } +.seasonOpen:focus-visible { + outline: none; +} - .actions { - justify-content: center; - } +.seasonCard:has(.seasonOpen:focus-visible) { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} - .seasonsGrid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 12px; - } +.seasonHeader { + position: relative; + z-index: 1; } diff --git a/affiche-frontend/src/components/library/ItemDetail.test.tsx b/affiche-frontend/src/components/library/ItemDetail.test.tsx index 744c157..dc5c85a 100644 --- a/affiche-frontend/src/components/library/ItemDetail.test.tsx +++ b/affiche-frontend/src/components/library/ItemDetail.test.tsx @@ -2,7 +2,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { ItemDetail } from './ItemDetail'; -import { libraryApi } from '../../api'; +import { resetItemArtworkCache } from '../../hooks/useItemArtwork'; +import { libraryApi, postersApi } from '../../api'; import type { ItemSeason, LibraryItem, LibraryItemWithSeasons } from '../../types'; vi.mock('../../api', async (importOriginal) => { @@ -10,6 +11,7 @@ vi.mock('../../api', async (importOriginal) => { return { ...actual, libraryApi: { ...actual.libraryApi, getItemWithSeasons: vi.fn() }, + postersApi: { ...actual.postersApi, getPosters: vi.fn() }, }; }); @@ -37,17 +39,24 @@ const withSeasons = (...numbers: number[]) => const noop = () => {}; -function renderDetail(props: { item?: LibraryItem; imageRefreshKey?: number } = {}) { +function renderDetail( + props: { + item?: LibraryItem; imageRefreshKey?: number; onSelectPoster?: (url?: string) => void; + uploadsAutomatically?: boolean; + } = {} +) { const ui = (p: typeof props) => ( @@ -62,6 +71,8 @@ const findSeason = (n: number) => screen.findAllByText(`Season ${n}`); const seasonShown = (n: number) => screen.queryAllByText(`Season ${n}`).length > 0; beforeEach(() => { + resetItemArtworkCache(); + vi.mocked(postersApi.getPosters).mockResolvedValue([]); getItemWithSeasons.mockReset(); toast.error.mockReset(); }); @@ -144,56 +155,30 @@ describe('ItemDetail seasons', () => { }); }); -describe('ItemDetail before/after compare', () => { +describe('ItemDetail before/after fade', () => { const withSource = (overrides: Partial = {}): LibraryItem => ({ ...show, has_poster: true, poster_version: 'v2', source_poster_version: 'v1', ...overrides }); - const toggle = () => screen.queryByRole('button', { name: /compare with original/i }); + const fader = () => screen.queryByRole('slider', { name: /fade between the original/i }); - it('offers no compare when the server artwork was never kept', async () => { + it('offers no fade when the server artwork was never kept', () => { renderDetail({ item: { ...show, source_poster_version: null } }); - expect(toggle()).not.toBeInTheDocument(); + expect(fader()).not.toBeInTheDocument(); }); - it('offers the compare once a source poster is kept', () => { - renderDetail({ item: withSource() }); - - expect(toggle()).toBeInTheDocument(); - expect(screen.queryByText('Before')).not.toBeInTheDocument(); - }); - - it('swaps the poster for the wipe slider when toggled on', () => { - renderDetail({ item: withSource() }); - expect(screen.queryByRole('slider')).not.toBeInTheDocument(); - - fireEvent.click(toggle()!); - - expect(screen.getByRole('slider')).toBeInTheDocument(); - }); - - it('requests the source variant for the revealed side', () => { + it('fades between the source and the generated poster once a source is kept', () => { renderDetail({ item: withSource() }); - fireEvent.click(toggle()!); + expect(fader()).toBeInTheDocument(); const sources = screen.getAllByRole('img').map((img) => img.getAttribute('src') ?? ''); - expect(sources.some((src) => src.includes('variant=source') && src.includes('v=v1'))).toBe(true); expect(sources.some((src) => !src.includes('variant=') && src.includes('v=v2'))).toBe(true); }); - it('opens the full-size compare from the poster corner', () => { - renderDetail({ item: withSource() }); - - fireEvent.click(screen.getByRole('button', { name: /compare severance full size/i })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByRole('slider')).toBeInTheDocument(); - }); - it('offers a full-size compare per season, never at thumbnail size', async () => { getItemWithSeasons.mockResolvedValue({ @@ -247,3 +232,93 @@ describe('ItemDetail failure banner', () => { expect(screen.queryByRole('button', { name: /IMDb or TVDB/i })).not.toBeInTheDocument(); }); }); + +describe('ItemDetail actions', () => { + const movie = (overrides: Partial = {}): LibraryItem => + ({ ...show, type: 'movie', processed: false, ...overrides }); + + it('fills in upload for a generated poster and says what it replaces', () => { + renderDetail({ item: movie({ processed: true }) }); + + expect(screen.getByRole('button', { name: /upload to plex/i })).toBeInTheDocument(); + expect(screen.getByText(/replaces the poster plex shows for this movie/i)).toBeInTheDocument(); + expect(screen.getByText(/reset puts it back/i)).toBeInTheDocument(); + }); + + it('says where a generated poster goes, now that generating one item does not ask first', () => { + renderDetail({ item: movie(), uploadsAutomatically: true }); + expect(screen.getByText(/goes straight to Plex/)).toBeInTheDocument(); + }); + + it('says a generated poster stays in Affiche for a library that does not upload', () => { + renderDetail({ item: movie(), uploadsAutomatically: false }); + expect(screen.getByText(/stays in Affiche until you upload it/)).toBeInTheDocument(); + }); + + it('offers no upload once the poster is on the server', () => { + renderDetail({ item: movie({ processed: true, poster_uploaded_at: '2026-09-01T10:00:00Z' }) }); + + expect(screen.queryByRole('button', { name: /upload to/i })).not.toBeInTheDocument(); + expect(screen.getByText('On server')).toBeInTheDocument(); + }); + + it('opens the picker on the provider poster picked from the strip', async () => { + vi.mocked(postersApi.getPosters).mockResolvedValue([ + { url: 'https://img.example/alien.jpg', provider: 'tmdb', rank: 0, rank_score: 1 }, + ]); + const onSelectPoster = vi.fn(); + renderDetail({ item: movie({ tmdb_id: '348' }), onSelectPoster }); + + fireEvent.click(await screen.findByRole('button', { name: /use poster 1 from/i })); + expect(onSelectPoster).toHaveBeenCalledWith('https://img.example/alien.jpg'); + + fireEvent.click(screen.getByRole('button', { name: /search, paste a url or pick a file/i })); + expect(onSelectPoster).toHaveBeenLastCalledWith(); + }); +}); + +describe('ItemDetail stepping through the listing', () => { + function renderStepper() { + const onPrevious = vi.fn(); + const onNext = vi.fn(); + render( + + ); + return { onPrevious, onNext }; + } + + it('steps with the buttons and with the arrow keys', () => { + const { onPrevious, onNext } = renderStepper(); + + fireEvent.click(screen.getByRole('button', { name: /Heat/ })); + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + + expect(onNext).toHaveBeenCalledTimes(1); + expect(onPrevious).toHaveBeenCalledTimes(1); + }); + + it('leaves the arrow keys to a slider or field that has focus', () => { + const { onPrevious } = renderStepper(); + const field = document.createElement('input'); + document.body.appendChild(field); + + fireEvent.keyDown(field, { key: 'ArrowLeft' }); + + expect(onPrevious).not.toHaveBeenCalled(); + field.remove(); + }); +}); diff --git a/affiche-frontend/src/components/library/ItemDetail.tsx b/affiche-frontend/src/components/library/ItemDetail.tsx index e430f48..eabb33e 100644 --- a/affiche-frontend/src/components/library/ItemDetail.tsx +++ b/affiche-frontend/src/components/library/ItemDetail.tsx @@ -1,28 +1,41 @@ -import { useState, useEffect, useEffectEvent } from 'react'; -import { ArrowLeft, RefreshCw, Image, RotateCcw, ExternalLink, CheckCircle, Circle, Loader2, Images, Upload, AlertTriangle, Lock, Unlock, Columns2, Maximize2, ChevronDown, ChevronUp } from 'lucide-react'; +import { useState, useEffect, useEffectEvent, type ReactNode } from 'react'; +import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, Image, RotateCcw, ExternalLink, CheckCircle, Circle, Loader2, Images, Upload, AlertTriangle, Lock, Unlock, Columns2, ChevronDown, ChevronUp } from 'lucide-react'; import { errorMessage, libraryApi } from '../../api'; import type { PosterVariant } from '../../api/libraries'; import { useToast } from '../../context/ToastContext'; import { usePosterImage } from '../../hooks'; import type { LibraryItem, LibraryItemWithSeasons, ItemSeason } from '../../types'; -import { activationProps } from '../common'; -import { PosterCompareSlider } from '../image'; +import { OverflowMenu } from '../common'; +import { PosterFade } from '../image'; +import { ItemArtworkStrip } from './ItemArtworkStrip'; import { PosterCompareModal } from './PosterCompareModal'; +import { posterTargetFromItem } from './posterTarget'; import { errorCauseCopy } from './errorCause'; import styles from './ItemDetail.module.css'; import { - formatDateTime, canReset, posterSource, posterStatus, hasQuality, formatFileSize, formatBitrate, + formatDateTime, canReset, posterStatusDetail, hasQuality, formatFileSize, formatBitrate, formatAudio, } from './format'; +import { detailsSummary, POSTER_STATE_LABEL, posterState, primaryPosterAction } from './posterState'; interface ItemDetailProps { item: LibraryItem; mediaServerId?: number; + + mediaServerName?: string; onBack: () => void; + + previousItem?: { title: string }; + nextItem?: { title: string }; + onPrevious?: () => void; + onNext?: () => void; onSync: () => void; onGeneratePoster: () => void; + + uploadsAutomatically?: boolean; onReset: () => void; - onSelectPoster: () => void; + + onSelectPoster: (posterUrl?: string) => void; onUpload: () => void; onToggleLock: () => void; onSeasonSelectPoster?: (season: ItemSeason) => void; @@ -36,9 +49,15 @@ interface ItemDetailProps { export function ItemDetail({ item, mediaServerId, + mediaServerName, onBack, + previousItem, + nextItem, + onPrevious, + onNext, onSync, onGeneratePoster, + uploadsAutomatically, onReset, onSelectPoster, onUpload, @@ -56,10 +75,6 @@ export function ItemDetail({ useState<{ itemId: number; data: LibraryItemWithSeasons | null } | null>(null); const canCompare = item.source_poster_version != null; - const [comparingItemId, setComparingItemId] = useState(null); - const comparing = canCompare && comparingItemId === item.id; - const setComparing = (next: boolean | ((on: boolean) => boolean)) => - setComparingItemId((typeof next === 'function' ? next(comparing) : next) ? item.id : null); const [fullSizeCompare, setFullSizeCompare] = useState<{ title: string; beforeUrl: string; afterUrl: string } | null>(null); @@ -98,232 +113,178 @@ export function ItemDetail({ return () => { cancelled = true; }; }, [item.id, item.library_id, isShow, mediaServerId, imageRefreshKey]); + const stepWithKeys = useEffectEvent((event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const target = event.target as HTMLElement | null; + if (target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return; + if (event.key === 'ArrowLeft' && previousItem && onPrevious) onPrevious(); + if (event.key === 'ArrowRight' && nextItem && onNext) onNext(); + }); + + useEffect(() => { + const listener = (event: KeyboardEvent) => stepWithKeys(event); + window.addEventListener('keydown', listener); + return () => window.removeEventListener('keydown', listener); + }, []); + + const state = posterState(item); + const primary = primaryPosterAction(item); + const statusDetail = posterStatusDetail(item); + const hasPoster = !!item.has_poster || item.poster_version != null; + const serverName = mediaServerName || 'the media server'; + const kind = isShow ? 'show' : 'movie'; + return (
- +
+ + {(previousItem || nextItem) && ( + + )} +
{isLoading && (
- - {statusMessage || 'Processing...'} + + {statusMessage || 'Processing…'}
)}
+ {hasPoster && mediaServerId && ( + + )} +
-
+ {canCompare && mediaServerId ? ( + {item.title.charAt(0)}} + /> + ) : ( - {canCompare && ( - - - )} -
- {canCompare && ( - )}

{item.title}

- {item.year &&

{item.year}

} +

+ {[item.year, isShow ? 'TV show' : 'Movie'].filter(Boolean).join(' · ')} +

+ +
+ + + {item.locked && ( + + + )} +
+ {statusDetail &&

{statusDetail}

} {item.error_message && } -
-
- Status - - {item.error_message ? 'Failed' : item.processed ? 'Processed' : 'Pending'} - -
-
- Type - {item.type === 'show' ? 'TV Show' : 'Movie'} -
- {item.tmdb_id && ( - + {isUploading ? : } + Upload to {mediaServerName || 'media server'} + +

+ Replaces the poster {serverName} shows for this {kind}. The artwork it has now is + kept, so Reset puts it back. +

+ )} - {item.imdb_id && ( - + + Generate poster + +

+ {uploadsAutomatically === undefined + ? "Uses this library's style, and follows its upload setting." + : uploadsAutomatically + ? `Uses this library's style and goes straight to ${serverName}, which keeps its current artwork for Reset.` + : "Uses this library's style. The poster stays in Affiche until you upload it."} +

+ )} - {item.tvdb_id && ( - + {primary === 'choose' && ( + )}
- {} -
- Details -
- Poster - {posterStatus(item)} -
- {item.poster_provider && ( -
- Source - {posterSource(item.poster_provider)} -
+
+ {primary !== 'choose' && ( + )} -
- Added - {formatDateTime(item.added_at)} -
-
- Updated - {formatDateTime(item.updated_at)} -
-
- Last seen - {formatDateTime(item.last_seen_at)} -
- {item.external_id && ( -
- Server ID - {item.external_id} -
+ {primary !== 'generate' && !item.locked && ( + )} -
- - {} - {hasQuality(item) && ( -
- Quality - {(item.media_resolution || item.media_height) && ( -
- Resolution - - {item.media_resolution || `${item.media_height}p`} - {item.media_width && item.media_height ? ` (${item.media_width}×${item.media_height})` : ''} - -
- )} - {item.video_codec && ( -
- Video - {item.video_codec.toUpperCase()} -
- )} - {(item.audio_codec || item.audio_channels) && ( -
- Audio - {formatAudio(item)} -
- )} - {item.media_container && ( -
- Container - {item.media_container.toUpperCase()} -
- )} - {item.media_bitrate != null && ( -
- Bitrate - {formatBitrate(item.media_bitrate)} -
- )} - {item.media_size_bytes != null && ( -
- Size - {formatFileSize(item.media_size_bytes)} -
- )} -
- )} - -
- - - - - + , label: 'Sync metadata', onClick: onSync, disabled: isLoading }, + { + icon: , + label: 'Reset poster', + onClick: onReset, + disabled: isLoading || !canReset(item), + danger: true, + }, + ]} + />
+ + + +
+ + Details + {detailsSummary(item)} + + +
@@ -354,7 +335,7 @@ export function ItemDetail({

Seasons

{isLoadingSeasons ? ( -
Loading seasons...
+
Loading seasons…
) : seasonsFailed ? (
Could not load seasons.
) : seasons.length === 0 ? ( @@ -393,6 +374,81 @@ export function ItemDetail({ ); } +function ItemFacts({ item }: { item: LibraryItem }) { + return ( +
+
+ {item.tmdb_id && ( + + + {item.tmdb_id} + + + )} + {item.imdb_id && ( + + + {item.imdb_id} + + + )} + {item.tvdb_id && ( + + + {item.tvdb_id} + + + )} + {formatDateTime(item.added_at)} + {formatDateTime(item.updated_at)} + {formatDateTime(item.last_seen_at)} + {item.external_id && {item.external_id}} +
+ + {} + {hasQuality(item) && ( +
+ {(item.media_resolution || item.media_height) && ( + + {item.media_resolution || `${item.media_height}p`} + {item.media_width && item.media_height ? ` (${item.media_width}×${item.media_height})` : ''} + + )} + {item.video_codec && {item.video_codec.toUpperCase()}} + {(item.audio_codec || item.audio_channels) && {formatAudio(item)}} + {item.media_container && {item.media_container.toUpperCase()}} + {item.media_bitrate != null && {formatBitrate(item.media_bitrate)}} + {item.media_size_bytes != null && {formatFileSize(item.media_size_bytes)}} +
+ )} +
+ ); +} + +function Fact({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + function FailureBanner({ item }: { item: LibraryItem }) { const [showFix, setShowFix] = useState(false); const cause = errorCauseCopy(item); @@ -437,36 +493,16 @@ interface ItemPosterProps { title: string; hasPoster?: boolean; version?: string | null; - variant?: PosterVariant; - - sourceVersion?: string | null; - comparing?: boolean; } -function ItemPoster({ - mediaServerId, libraryId, itemId, title, hasPoster = false, version, variant = 'generated', - sourceVersion, comparing = false, -}: ItemPosterProps) { +function ItemPoster({ mediaServerId, libraryId, itemId, title, hasPoster = false, version }: ItemPosterProps) { const imageUrl = mediaServerId - ? libraryApi.getItemPosterUrl(libraryId, itemId, version, 'full', variant) + ? libraryApi.getItemPosterUrl(libraryId, itemId, version, 'full', 'generated') : ''; const { isLoaded, isError, imgKey, imgRef, onLoad, onError } = usePosterImage(imageUrl); - if (comparing && mediaServerId) { - return ( -
- {title.charAt(0)}} - /> -
- ); - } - return (
{(!isLoaded || isError) && ( @@ -518,9 +554,16 @@ function SeasonCard({ return (
+ {onCardClick && ( + diff --git a/affiche-frontend/src/components/library/ItemGrid.module.css b/affiche-frontend/src/components/library/ItemGrid.module.css index 25b45e7..d971f33 100644 --- a/affiche-frontend/src/components/library/ItemGrid.module.css +++ b/affiche-frontend/src/components/library/ItemGrid.module.css @@ -9,6 +9,18 @@ padding-right: 44px; } +@media (max-width: 600px) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; + padding: 12px; + } + + .withRail { + padding-right: 48px; + } +} + .loading { display: flex; flex-direction: column; @@ -25,13 +37,6 @@ border: 3px solid var(--bg-tertiary); border-top-color: var(--accent); border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } } .empty { @@ -44,7 +49,7 @@ } .empty p:first-child { - font-size: 1.1rem; + font-size: var(--text-xl); color: var(--text-secondary); margin-bottom: 8px; } diff --git a/affiche-frontend/src/components/library/ItemGrid.tsx b/affiche-frontend/src/components/library/ItemGrid.tsx index b38984e..a54fae1 100644 --- a/affiche-frontend/src/components/library/ItemGrid.tsx +++ b/affiche-frontend/src/components/library/ItemGrid.tsx @@ -17,7 +17,9 @@ interface ItemGridProps { showAnchors?: boolean; - onToggleSelect?: (item: LibraryItem) => void; + onToggleSelect?: (item: LibraryItem, extend: boolean) => void; + + emptyMessage?: { title: string; hint: string }; isSelected?: (item: LibraryItem) => boolean; selectMode?: boolean; @@ -37,6 +39,7 @@ export function ItemGrid({ onRestore, showAnchors = false, onToggleSelect, + emptyMessage, isSelected, selectMode = false, onToggleLock, @@ -47,8 +50,8 @@ export function ItemGrid({ if (isLoading) { return (
-
- Loading items... +
+ Loading items…
); } @@ -63,8 +66,8 @@ export function ItemGrid({ ) : ( <> -

No items found

-

Sync your library to see items here

+

{emptyMessage?.title ?? 'No items yet'}

+

{emptyMessage?.hint ?? 'Sync this library to fetch its items from the media server.'}

)}
@@ -92,7 +95,7 @@ export function ItemGrid({ onClick={onItemClick ? () => onItemClick(item) : undefined} variant={variant} onRestore={onRestore ? () => onRestore(item) : undefined} - onToggleSelect={onToggleSelect ? () => onToggleSelect(item) : undefined} + onToggleSelect={onToggleSelect ? (extend) => onToggleSelect(item, extend) : undefined} isSelected={isSelected?.(item)} selectMode={selectMode} onToggleLock={onToggleLock ? () => onToggleLock(item) : undefined} @@ -107,8 +110,8 @@ export function ItemGrid({
{isLoadingMore && (
-
- Loading more... +
+ Loading more…
)}
diff --git a/affiche-frontend/src/components/library/ItemRow.module.css b/affiche-frontend/src/components/library/ItemRow.module.css index 2fe1f90..7c0cafd 100644 --- a/affiche-frontend/src/components/library/ItemRow.module.css +++ b/affiche-frontend/src/components/library/ItemRow.module.css @@ -53,13 +53,13 @@ } .title { - font-size: 1.05rem; + font-size: var(--text-lg); font-weight: 600; transition: color 0.15s; } .subtitle { - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-muted); } @@ -91,12 +91,12 @@ .placeholder { flex: 0 0 160px; aspect-ratio: 2/3; - border-radius: 12px; + border-radius: var(--radius-2xl); background-color: var(--bg-card); } .empty { padding: 0 24px; color: var(--text-muted); - font-size: 0.88rem; + font-size: var(--text-md); } diff --git a/affiche-frontend/src/components/library/ItemRow.tsx b/affiche-frontend/src/components/library/ItemRow.tsx index 594888b..bdc8354 100644 --- a/affiche-frontend/src/components/library/ItemRow.tsx +++ b/affiche-frontend/src/components/library/ItemRow.tsx @@ -1,6 +1,8 @@ import { useRef, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import type { LibraryItem } from '../../types'; +import { scrollBehavior } from '../../motion'; +import type { ItemStats, LibraryItem } from '../../types'; +import { CoverageMeter } from './CoverageMeter'; import { ItemCard } from './ItemCard'; import styles from './ItemRow.module.css'; @@ -10,6 +12,8 @@ interface ItemRowProps { title: string; subtitle?: string; + + stats?: ItemStats; items: LibraryItem[]; onItemClick: (item: LibraryItem) => void; @@ -20,7 +24,7 @@ interface ItemRowProps { } export function ItemRow({ - title, subtitle, items, onItemClick, onOpenAll, isLoading = false, + title, subtitle, stats, items, onItemClick, onOpenAll, isLoading = false, emptyLabel = 'Nothing here yet.', }: ItemRowProps) { const track = useRef(null); @@ -38,7 +42,7 @@ export function ItemRow({ const el = track.current; if (!el) return; const page = Math.max(el.clientWidth - CARD_STRIDE, CARD_STRIDE); - el.scrollBy({ left: direction * page, behavior: 'smooth' }); + el.scrollBy({ left: direction * page, behavior: scrollBehavior() }); }; const heading = ( @@ -59,6 +63,7 @@ export function ItemRow({ ) : (
{heading}
)} + {stats && } {items.length > 0 && !isLoading && (
@@ -278,8 +287,8 @@ export function ItemTable({
{isLoadingMore && (
-
- Loading more... +
+ Loading more…
)}
diff --git a/affiche-frontend/src/components/library/LibraryRows.tsx b/affiche-frontend/src/components/library/LibraryRows.tsx index 79c5925..788f7c6 100644 --- a/affiche-frontend/src/components/library/LibraryRows.tsx +++ b/affiche-frontend/src/components/library/LibraryRows.tsx @@ -21,11 +21,12 @@ export function LibraryRows({ return (
- {rows.map(({ library, items, total }) => ( + {rows.map(({ library, items, total, stats }) => ( void; + + runPercent?: number | null; + onOpenPoster: (poster: StagePoster) => void; +} + +export function LibraryStage({ + libraryName, poster, stats, autoUpload, serverName, onChangeAutoUpload, runPercent, onOpenPoster, +}: LibraryStageProps) { + const running = runPercent != null; + const url = poster + ? libraryApi.getItemPosterUrl(poster.libraryId, poster.itemId, poster.posterVersion, 'thumb') + : ''; + const { isLoaded, isError, imgKey, imgRef, onLoad, onError } = usePosterImage(url); + + if (!poster && !running && !stats && autoUpload === undefined) return null; + + const caption = poster?.reason === 'generated' ? 'Just generated' : `Latest poster in ${libraryName}`; + + return ( +
+ {poster && !isError && ( + + )} + +
+ {poster && ( + + )} + +
+ {poster && ( + <> + {caption} + + + )} + {stats && ( +
+ +
+ )} + {autoUpload !== undefined && ( +
+ + + {onChangeAutoUpload && ( + + )} +
+ )} + {running && ( +
+ Generating posters · {runPercent}% + + + +
+ )} +
+
+
+ ); +} diff --git a/affiche-frontend/src/components/library/PosterBrowserModal.module.css b/affiche-frontend/src/components/library/PosterBrowserModal.module.css index 707407e..5238c4a 100644 --- a/affiche-frontend/src/components/library/PosterBrowserModal.module.css +++ b/affiche-frontend/src/components/library/PosterBrowserModal.module.css @@ -7,7 +7,7 @@ } .title { - font-size: 1.25rem; + font-size: var(--text-2xl); font-weight: 600; color: var(--text-primary); @@ -33,8 +33,9 @@ text-align: center; padding: 12px 24px; color: var(--error); - background-color: var(--error-tint); - border-radius: 8px; + border: 1px solid var(--notice-error-edge); + background-color: var(--notice-error-tint); + border-radius: var(--radius-lg); flex-shrink: 0; } @@ -47,22 +48,11 @@ border-top: 1px solid var(--border); } -.uploadToggle { - margin-right: auto; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9rem; +.footerNote { + margin: 0 auto 0 0; + max-width: 60ch; + font-size: var(--text-sm); color: var(--text-secondary); - cursor: pointer; - user-select: none; -} - -.uploadToggle input { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: var(--accent); } .footerButton { @@ -70,8 +60,8 @@ align-items: center; gap: 6px; padding: 10px 20px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; transition: all 0.15s; } @@ -87,12 +77,22 @@ } .footerButton.save { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } .footerButton.save:hover:not(:disabled) { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); +} + +.footerButton.secondary { + color: var(--text-primary); + background-color: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.footerButton.secondary:hover:not(:disabled) { + background-color: var(--bg-hover); } .footerButton:disabled { @@ -104,4 +104,12 @@ .mainArea { flex-direction: column; } + + .footer { + flex-wrap: wrap; + } + + .footerNote { + flex-basis: 100%; + } } diff --git a/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx b/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx index 1eb262d..d31c074 100644 --- a/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx +++ b/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PosterBrowserModal } from './PosterBrowserModal'; +import { ToastProvider } from '../../context/ToastContext'; import { posterTargetFromItem } from './posterTarget'; import { postersApi } from '../../api'; import { usePosterConfig } from '../../hooks'; @@ -22,6 +23,10 @@ vi.mock('../../api', () => ({ vi.mock('../image', () => ({ PosterPreview: ({ title }: { title: string }) =>
{title}
, + + PosterStyleControls: ({ onOverlayChange }: { onOverlayChange: (c: object) => void }) => ( + + ), })); const CONFIG = { @@ -33,6 +38,7 @@ const CONFIG = { vi.mock('../../hooks', async (importOriginal) => ({ ...(await importOriginal()), usePosterConfig: vi.fn(), + useFonts: () => ({ fonts: [], isLoading: false }), useProviderStatus: () => ({ isAnyProviderConfigured: true, configuredProviders: ['tmdb', 'tvdb'], @@ -75,12 +81,14 @@ function renderModal(props: Partial + + + ); return { ...utils, onSave, onClose }; } @@ -156,7 +164,7 @@ describe('PosterBrowserModal — filling the grid', () => { await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); expect(getSeasonPosters.mock.calls[0][0].season_number).toBe(2); - await user.click(screen.getByRole('tab', { name: 'Show art' })); + await user.click(screen.getByRole('button', { name: 'Show art' })); await waitFor(() => expect(getPosters).toHaveBeenCalledTimes(1)); expect(screen.getByText(/still applies to Season 2/)).toBeInTheDocument(); @@ -168,6 +176,7 @@ describe('PosterBrowserModal — filling the grid', () => { renderModal(); await candidates(); + await user.click(screen.getByRole('button', { name: 'Find elsewhere' })); await user.click(screen.getByRole('button', { name: 'Search' })); await waitFor(() => expect(searchPosters).toHaveBeenCalled()); @@ -175,6 +184,30 @@ describe('PosterBrowserModal — filling the grid', () => { await waitFor(async () => expect(await candidates()).toHaveLength(1)); }); + it('keeps title search and own images folded until asked for', async () => { + const user = userEvent.setup(); + renderModal(); + await candidates(); + + expect(screen.queryByLabelText('Search by title')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Find elsewhere' })); + + expect(screen.getByLabelText('Search by title')).toBeInTheDocument(); + expect(screen.getByLabelText('Use your own image')).toBeInTheDocument(); + }); + + it('offers the folded ways out when the grid comes back empty', async () => { + + getPosters.mockResolvedValue([]); + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole('button', { name: /Search another title/ })); + + expect(screen.getByLabelText('Search by title')).toBeInTheDocument(); + }); + it('shows the reason a fetch failed', async () => { getPosters.mockRejectedValue(new Error('TMDB rate limit')); @@ -189,6 +222,7 @@ describe('PosterBrowserModal — filling the grid', () => { const { onSave } = renderModal(); await candidates(); + await user.click(screen.getByRole('button', { name: 'Find elsewhere' })); await user.type(screen.getByLabelText('Use your own image'), 'https://example.com/p.jpg'); await user.click(screen.getByRole('button', { name: 'Add' })); @@ -199,6 +233,44 @@ describe('PosterBrowserModal — filling the grid', () => { }); }); +describe('PosterBrowserModal — saving from the grid', () => { + it('saves a double-clicked poster with the button the library fills', async () => { + const user = userEvent.setup(); + const { onSave } = renderModal({ defaultUpload: true }); + const [, second] = await candidates(); + + await user.dblClick(second); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith('https://cdn/b.jpg', expect.objectContaining({ upload: true })); + }); + + it('saves on Enter only once the poster is already picked', async () => { + + const user = userEvent.setup(); + const { onSave } = renderModal(); + const [first] = await candidates(); + + first.focus(); + await user.keyboard('{Enter}'); + expect(first).toHaveAttribute('aria-pressed', 'true'); + expect(onSave).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onSave).toHaveBeenCalledWith('https://cdn/a.jpg', expect.objectContaining({ upload: false })); + }); + + it('does not start a second save while one is in flight', async () => { + const user = userEvent.setup(); + const { onSave } = renderModal({ isSaving: true }); + const [first] = await candidates(); + + await user.dblClick(first); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); + describe('PosterBrowserModal — a save in flight', () => { it('cannot be dismissed with Cancel', async () => { const { onClose } = renderModal({ isSaving: true }); @@ -261,18 +333,72 @@ describe('PosterBrowserModal — style drafts', () => { }); }); - it('defaults the upload toggle to the library setting and sends it', async () => { + it('uploads only when Save & upload is the button pressed', async () => { const user = userEvent.setup(); const { onSave } = renderModal({ defaultUpload: true }); const [first] = await candidates(); await user.click(first); + await user.click(screen.getByRole('button', { name: 'Save & upload' })); + expect(onSave).toHaveBeenLastCalledWith('https://cdn/a.jpg', expect.objectContaining({ upload: true })); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(onSave).toHaveBeenLastCalledWith('https://cdn/a.jpg', expect.objectContaining({ upload: false })); + }); - expect(onSave).toHaveBeenCalledWith( - 'https://cdn/a.jpg', - expect.objectContaining({ upload: true }) - ); + it('says in words which save is the default, not only by which button is filled', async () => { + renderModal({ defaultUpload: true }); + await candidates(); + + expect(screen.getByText(/This library uploads new posters/)).toBeInTheDocument(); + }); + + it('offers no upload where applying cannot reach the media server', async () => { + renderModal({ canUpload: false }); + await candidates(); + + expect(screen.queryByRole('button', { name: 'Save & upload' })).not.toBeInTheDocument(); + }); + + it('edits the style beside the grid, restyling whichever poster is picked', async () => { + const user = userEvent.setup(); + const { onSave } = renderModal(); + const [first, second] = await candidates(); + + await user.click(first); + await user.click(screen.getByRole('button', { name: /Edit style/ })); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(await candidates()).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Thicker border' })); + await user.click(second); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledWith('https://cdn/b.jpg', expect.objectContaining({ + overlayOptions: { border_px: 9 }, + })); + + await user.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.getByRole('button', { name: /Edit style/ })).toBeInTheDocument(); + }); + + it('puts the edits back when a reset is undone', async () => { + const user = userEvent.setup(); + const { onSave } = renderModal(); + const [first] = await candidates(); + await user.click(first); + await user.click(screen.getByRole('button', { name: /Edit style/ })); + await user.click(screen.getByRole('button', { name: 'Thicker border' })); + + await user.click(screen.getByRole('button', { name: 'Reset to defaults' })); + await user.click(await screen.findByRole('button', { name: 'Undo' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledWith('https://cdn/a.jpg', expect.objectContaining({ + overlayOptions: { border_px: 9 }, + })); }); it('titles a season pick with its season label', async () => { diff --git a/affiche-frontend/src/components/library/PosterBrowserModal.tsx b/affiche-frontend/src/components/library/PosterBrowserModal.tsx index 0dde4f3..9ec5edb 100644 --- a/affiche-frontend/src/components/library/PosterBrowserModal.tsx +++ b/affiche-frontend/src/components/library/PosterBrowserModal.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Loader2 } from 'lucide-react'; import { Modal } from '../common'; +import { useToast } from '../../context/ToastContext'; import { usePosterBrowseQuery, usePosterCandidates, @@ -34,6 +35,8 @@ interface PosterBrowserModalProps { defaultUpload?: boolean; canUpload?: boolean; + + initialPoster?: string; } export function PosterBrowserModal({ @@ -44,17 +47,19 @@ export function PosterBrowserModal({ isSaving = false, defaultUpload = false, canUpload = true, + initialPoster, }: PosterBrowserModalProps) { const { config: posterConfig } = usePosterConfig(); const { isAnyProviderConfigured, configuredProviders } = useProviderStatus(); + const toast = useToast(); const { title: itemTitle, year, mediaType, tmdbId, tvdbId, collectionId } = target; const isSeason = seasonNumber !== undefined; const defaultTitle = isSeason ? `Season ${seasonNumber}` : itemTitle; - const [selectedPoster, setSelectedPoster] = useState(null); + const [selectedPoster, setSelectedPoster] = useState(initialPoster ?? null); const [showEditPanel, setShowEditPanel] = useState(false); - const [uploadToLibrary, setUploadToLibrary] = useState(defaultUpload); + const [findOpen, setFindOpen] = useState(false); const query = usePosterBrowseQuery({ itemTitle, @@ -94,20 +99,37 @@ export function PosterBrowserModal({ const titleLanguageEnabled = isAnyProviderConfigured && (tmdbId !== undefined || tvdbId !== undefined); - const handleSave = () => { - if (!selectedPoster) return; - onSave(selectedPoster, { + const save = (posterUrl: string, upload: boolean) => { + onSave(posterUrl, { overlayOptions, textOptions, jpegQuality: quality, title: titleDraft.title, - upload: uploadToLibrary, + upload, }); }; + const handleSave = (upload: boolean) => { + if (selectedPoster) save(selectedPoster, upload); + }; + + const saveFromGrid = (posterUrl: string) => { + if (isSaving) return; + setSelectedPoster(posterUrl); + save(posterUrl, canUpload && defaultUpload); + }; + + const resetStyle = () => { + const undo = style.reset(); + if (undo) toast.info('Style reset to defaults', { action: { label: 'Undo', onClick: undo } }); + }; + + const isEditing = showEditPanel && !!overlayOptions && !!textOptions; + const sortedPosters = sortPosterCandidates(candidates.posters, query.sort); + const modalTitle = isSeason - ? `Select Poster for "${itemTitle}" - Season ${seasonNumber}` - : `Select Poster for "${itemTitle}"`; + ? `Choose artwork for ${itemTitle}, season ${seasonNumber}` + : `Choose artwork for ${itemTitle}`; return ( <> @@ -149,6 +171,8 @@ export function PosterBrowserModal({ onPickFile: (file) => stageCustom({ file }), isStaging: candidates.isStagingCustom, }} + find={{ isOpen: findOpen, onToggle: () => setFindOpen((open) => !open) }} + resultCount={candidates.isLoading ? undefined : sortedPosters.length} seasonSource={ isSeason ? { @@ -164,13 +188,35 @@ export function PosterBrowserModal({
setFindOpen(true)} /> - {!showEditPanel && ( + {isEditing ? ( + setShowEditPanel(false)} + /> + ) : (
- {canUpload && ( - - )} +

+ {!canUpload + ? 'The poster is kept in Affiche.' + : defaultUpload + ? "This library uploads new posters: Save & upload replaces the media server's artwork, keeping its current one for Reset. Save keeps the poster in Affiche only." + : "Save keeps the poster in Affiche. Save & upload also replaces the media server's artwork, keeping its current one for Reset."} +

- + {isSaving ? ( + + ) : ( + <> + {} + + {canUpload && ( + + )} + + )}
- - {} - {showEditPanel && selectedPoster && overlayOptions && textOptions && ( - setShowEditPanel(false)} - /> - )} ); } diff --git a/affiche-frontend/src/components/library/PosterBrowserToolbar.module.css b/affiche-frontend/src/components/library/PosterBrowserToolbar.module.css index 0480076..babb1a4 100644 --- a/affiche-frontend/src/components/library/PosterBrowserToolbar.module.css +++ b/affiche-frontend/src/components/library/PosterBrowserToolbar.module.css @@ -9,6 +9,44 @@ .toolbarRow { display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.count { + align-self: center; + margin-top: 22px; + font-size: var(--text-md); + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.findToggle { + margin-left: auto; +} + +.findToggle svg { + transition: transform 0.15s; +} + +.findToggle .chevronOpen { + transform: rotate(180deg); +} + +.findPanel { + display: flex; + flex-wrap: wrap; + gap: 12px 32px; + padding: 14px 16px; + border-radius: var(--radius-lg); + background-color: var(--bg-primary); + border: 1px solid var(--border); +} + +.findGroup { + display: flex; + flex: 1 1 360px; gap: 12px; align-items: flex-end; } @@ -20,7 +58,7 @@ } .sourceLabel { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } @@ -30,13 +68,13 @@ padding: 3px; background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); } .segment { padding: 7px 16px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; color: var(--text-secondary); white-space: nowrap; @@ -49,7 +87,7 @@ } .segmentActive { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } @@ -57,7 +95,7 @@ margin: 0 0 8px; align-self: flex-end; max-width: 320px; - font-size: 0.8rem; + font-size: var(--text-sm); line-height: 1.4; color: var(--text-muted); } @@ -81,18 +119,18 @@ } .inputGroup label { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } .inputGroup input, .inputGroup select { padding: 10px 14px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background-color: var(--bg-tertiary); color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); } .inputGroup select { @@ -122,8 +160,8 @@ align-items: center; gap: 6px; padding: 10px 16px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); background-color: var(--bg-tertiary); @@ -165,4 +203,12 @@ width: 100%; justify-content: center; } + + .findGroup { + flex-wrap: wrap; + } + + .findToggle { + margin-left: 0; + } } diff --git a/affiche-frontend/src/components/library/PosterBrowserToolbar.tsx b/affiche-frontend/src/components/library/PosterBrowserToolbar.tsx index f3d53da..e6520f1 100644 --- a/affiche-frontend/src/components/library/PosterBrowserToolbar.tsx +++ b/affiche-frontend/src/components/library/PosterBrowserToolbar.tsx @@ -1,4 +1,4 @@ -import { Loader2, Search, Upload } from 'lucide-react'; +import { ChevronDown, Loader2, Search, Upload } from 'lucide-react'; import { POSTER_LANGUAGES } from '../../constants/languages'; import { providerLabel } from '../../constants/providers'; @@ -36,6 +36,13 @@ interface PosterBrowserToolbarProps { isStaging: boolean; }; + find: { + isOpen: boolean; + onToggle: () => void; + }; + + resultCount?: number; + seasonSource?: { seasonNumber: number; onSeasonNumberChange: (value: number) => void; @@ -50,32 +57,13 @@ export function PosterBrowserToolbar({ search, filters, custom, + find, + resultCount, seasonSource, }: PosterBrowserToolbarProps) { return (
-
- - search.onTitleChange(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search.onSubmit()} - /> -
-
- - search.onYearChange(e.target.value)} - /> -
custom.onUrlChange(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && custom.onAddUrl()} - disabled={custom.isStaging} - /> + {find.isOpen && ( +
+
+
+ + search.onTitleChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && search.onSubmit()} + /> +
+
+ + search.onYearChange(e.target.value)} + /> +
+ +
+ +
+
+ + custom.onUrlChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && custom.onAddUrl()} + disabled={custom.isStaging} + /> +
+ + +
- - -
+ )} {seasonSource && (
Artwork source -
+
+ )} +
) : ( -
- {posters.map(({ url, provider }, index) => ( - - - ))} + + {isSelected && onActivate && ( + + )} + + {(details.language || details.size) && ( + + )} + + ); + })}
)}
diff --git a/affiche-frontend/src/components/library/PosterCompareModal.module.css b/affiche-frontend/src/components/library/PosterCompareModal.module.css index 1f96407..a4ac10a 100644 --- a/affiche-frontend/src/components/library/PosterCompareModal.module.css +++ b/affiche-frontend/src/components/library/PosterCompareModal.module.css @@ -4,14 +4,14 @@ .title { margin: 0; - font-size: 1.05rem; + font-size: var(--text-lg); font-weight: 600; color: var(--text-primary); } .hint { margin: 4px 0 0; - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-secondary); } @@ -26,7 +26,7 @@ height: min(70vh, 720px); aspect-ratio: 2/3; max-width: 100%; - border-radius: 12px; + border-radius: var(--radius-2xl); overflow: hidden; background-color: var(--bg-tertiary); box-shadow: var(--shadow-lg); diff --git a/affiche-frontend/src/components/library/PosterEditPanel.module.css b/affiche-frontend/src/components/library/PosterEditPanel.module.css index 3c37cfe..cd14fd0 100644 --- a/affiche-frontend/src/components/library/PosterEditPanel.module.css +++ b/affiche-frontend/src/components/library/PosterEditPanel.module.css @@ -1,51 +1,57 @@ -.header { +.editor { display: flex; - align-items: center; - gap: 16px; - - padding: 16px 48px 16px 24px; - border-bottom: 1px solid var(--border); + flex-direction: column; + width: 440px; flex-shrink: 0; + min-height: 0; + min-width: 0; } -.backButton { +.header { display: flex; align-items: center; gap: 8px; - color: var(--text-secondary); - padding: 8px 12px; - border-radius: 6px; - font-size: 0.9rem; - transition: all 0.15s; -} - -.backButton:hover { - background-color: var(--bg-hover); - color: var(--text-primary); + padding-bottom: 12px; + flex-shrink: 0; } .headerTitle { - font-size: 1.1rem; + font-size: var(--text-base); font-weight: 600; - color: var(--text-primary); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; flex: 1; } -.content { - flex: 1; - overflow-y: auto; - padding: 24px; +.doneButton { + padding: 6px 14px; + border-radius: var(--radius-md); + font-size: var(--text-md); + font-weight: 500; + color: var(--text-on-fill); + background-color: var(--accent-fill); +} + +.doneButton:hover { + background-color: var(--accent-fill-hover); +} + +.previewRow { display: flex; - gap: 32px; + align-items: center; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; } .previewWrapper { flex-shrink: 0; - width: 300px; - border-radius: 8px; + width: 150px; + border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-md); - align-self: flex-start; } .previewWrapper canvas { @@ -54,6 +60,32 @@ display: block; } +.previewPlaceholder { + aspect-ratio: 2/3; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + text-align: center; + border: 2px dashed var(--border); + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: var(--text-sm); +} + +.previewNote { + font-size: var(--text-md); + line-height: 1.45; + color: var(--text-secondary); +} + +.content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 4px 4px 0; +} + .row { display: flex; align-items: center; @@ -63,7 +95,7 @@ } .label { - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); } @@ -73,9 +105,9 @@ padding: 8px 10px; background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); font-family: inherit; line-height: 1.4; resize: vertical; @@ -91,9 +123,9 @@ padding: 8px 32px 8px 10px; background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); cursor: pointer; appearance: none; -webkit-appearance: none; @@ -123,61 +155,30 @@ } .hint { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); font-style: italic; } -.footer { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - border-top: 1px solid var(--border); - flex-shrink: 0; -} - .resetButton { display: flex; align-items: center; gap: 6px; - padding: 8px 16px; - border-radius: 6px; - font-size: 0.85rem; + padding: 6px 10px; + border-radius: var(--radius-md); + font-size: var(--text-md); color: var(--text-secondary); background-color: transparent; border: 1px solid var(--border); - transition: all 0.15s; } .resetButton:hover { background-color: var(--bg-hover); color: var(--text-primary); - border-color: var(--text-muted); -} - -.doneButton { - padding: 10px 32px; - border-radius: 6px; - font-size: 0.9rem; - font-weight: 500; - color: var(--text-on-fill); - background-color: var(--accent); - transition: all 0.15s; } -.doneButton:hover { - background-color: var(--accent-hover); -} - -@media (max-width: 700px) { - .content { - flex-direction: column; - align-items: center; - } - - .previewWrapper { +@media (max-width: 900px) { + .editor { width: 100%; - max-width: 280px; } } diff --git a/affiche-frontend/src/components/library/PosterEditPanel.tsx b/affiche-frontend/src/components/library/PosterEditPanel.tsx index d65088e..98edb5d 100644 --- a/affiche-frontend/src/components/library/PosterEditPanel.tsx +++ b/affiche-frontend/src/components/library/PosterEditPanel.tsx @@ -1,5 +1,4 @@ -import { ArrowLeft, RotateCcw, Loader2 } from 'lucide-react'; -import { Modal } from '../common'; +import { RotateCcw, Loader2 } from 'lucide-react'; import { PosterPreview, PosterStyleControls } from '../image'; import { useFonts } from '../../hooks'; import { POSTER_LANGUAGES } from '../../constants/languages'; @@ -7,7 +6,8 @@ import type { OverlayOptions, TextOptions } from '../../types'; import styles from './PosterEditPanel.module.css'; interface PosterEditPanelProps { - imageUrl: string; + + imageUrl: string | null; title: string; onTitleChange: (title: string) => void; titleLanguage: string; @@ -22,6 +22,7 @@ interface PosterEditPanelProps { onTextChange: (options: Partial) => void; onQualityChange: (quality: number) => void; onReset: () => void; + onClose: () => void; } @@ -46,25 +47,37 @@ export function PosterEditPanel({ const { fonts } = useFonts(); return ( - - +
- + -

Edit Poster Style

-
+
- + {imageUrl ? ( + + ) : ( +
No poster selected
+ )}
+

+ Pick any poster in the grid: it takes this style straight away. +

+
+ +
-
- - -
- +
); } diff --git a/affiche-frontend/src/components/library/PosterPreviewPane.module.css b/affiche-frontend/src/components/library/PosterPreviewPane.module.css index b80473b..e69d3bf 100644 --- a/affiche-frontend/src/components/library/PosterPreviewPane.module.css +++ b/affiche-frontend/src/components/library/PosterPreviewPane.module.css @@ -7,7 +7,7 @@ } .title { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 600; color: var(--text-secondary); text-transform: uppercase; @@ -18,7 +18,7 @@ width: 100% !important; max-width: 280px; height: auto !important; - border-radius: 8px; + border-radius: var(--radius-lg); box-shadow: var(--shadow-md); } @@ -30,9 +30,9 @@ justify-content: center; text-align: center; border: 2px dashed var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-base); padding: 20px; } @@ -44,8 +44,8 @@ width: 100%; padding: 10px 16px; margin-top: 12px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); background-color: var(--bg-tertiary); diff --git a/affiche-frontend/src/components/library/PosterPreviewPane.tsx b/affiche-frontend/src/components/library/PosterPreviewPane.tsx index d1334ca..86ac78d 100644 --- a/affiche-frontend/src/components/library/PosterPreviewPane.tsx +++ b/affiche-frontend/src/components/library/PosterPreviewPane.tsx @@ -34,7 +34,7 @@ export function PosterPreviewPane({ /> ) : ( diff --git a/affiche-frontend/src/components/library/SelectionBar.module.css b/affiche-frontend/src/components/library/SelectionBar.module.css index dd3984a..cad6130 100644 --- a/affiche-frontend/src/components/library/SelectionBar.module.css +++ b/affiche-frontend/src/components/library/SelectionBar.module.css @@ -1,14 +1,14 @@ .bar { position: sticky; top: var(--header-height); - z-index: 5; + z-index: var(--z-sticky); display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 10px 16px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); background-color: var(--bg-card); box-shadow: var(--shadow-md); } @@ -20,8 +20,8 @@ align-items: center; gap: 6px; padding: 6px 10px; - border-radius: 6px; - font-size: 0.85rem; + border-radius: var(--radius-md); + font-size: var(--text-md); color: var(--text-secondary); background-color: transparent; transition: background-color 0.15s, color 0.15s; @@ -42,7 +42,7 @@ } .count { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; @@ -63,3 +63,16 @@ .close { padding: 6px; } + +.matching { + padding: 6px 10px; + border-radius: var(--radius-md); + font-size: var(--text-md); + font-weight: 600; + color: var(--accent); + background-color: transparent; +} + +.matching:hover:not(:disabled) { + background-color: var(--bg-hover); +} diff --git a/affiche-frontend/src/components/library/SelectionBar.tsx b/affiche-frontend/src/components/library/SelectionBar.tsx index 057cc1a..8341f7a 100644 --- a/affiche-frontend/src/components/library/SelectionBar.tsx +++ b/affiche-frontend/src/components/library/SelectionBar.tsx @@ -1,11 +1,17 @@ import { CheckSquare, Image, Lock, RotateCcw, Unlock, Upload, X } from 'lucide-react'; +import { OverflowMenu } from '../common'; import styles from './SelectionBar.module.css'; interface SelectionBarProps { count: number; allSelected: boolean; + + matchingTotal?: number; + + isAllMatching?: boolean; + onSelectAllMatching?: () => void; isBusy?: boolean; onToggleAll: () => void; onClear: () => void; @@ -19,6 +25,9 @@ interface SelectionBarProps { export function SelectionBar({ count, allSelected, + matchingTotal, + isAllMatching = false, + onSelectAllMatching, isBusy = false, onToggleAll, onClear, @@ -28,6 +37,9 @@ export function SelectionBar({ onUnlock, onReset, }: SelectionBarProps) { + const canSelectAllMatching = !isAllMatching && !!onSelectAllMatching + && matchingTotal !== undefined && matchingTotal > count; + return (
{ @@ -37,10 +49,16 @@ export function SelectionBar({ {allSelected ? 'Clear all' : 'Select all'} - - {count} selected + + {isAllMatching ? `All ${count.toLocaleString()} matching selected` : `${count.toLocaleString()} selected`} + {canSelectAllMatching && ( + + )} +
- - - + , label: 'Lock', onClick: onLock, disabled: isBusy || count === 0 }, + { icon: , label: 'Unlock', onClick: onUnlock, disabled: isBusy || count === 0 }, + { icon: , label: 'Reset', onClick: onReset, disabled: isBusy || count === 0, danger: true }, + ]} + />
)} @@ -97,8 +97,8 @@ export function AddLibrariesModal({
{isLoading ? (
- - Loading libraries... + + Loading libraries…
) : !hasLibraries ? (
@@ -116,14 +116,14 @@ export function AddLibrariesModal({ className={styles.bulkButton} onClick={() => setSelected(new Set(libraries.map((l) => l.id)))} > - Select All + Select all
diff --git a/affiche-frontend/src/components/settings/AddMediaServerPanel.module.css b/affiche-frontend/src/components/settings/AddMediaServerPanel.module.css index ddff72d..27e4b06 100644 --- a/affiche-frontend/src/components/settings/AddMediaServerPanel.module.css +++ b/affiche-frontend/src/components/settings/AddMediaServerPanel.module.css @@ -24,10 +24,10 @@ padding: 10px 12px; background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); cursor: pointer; color: var(--text-primary); - font-size: 0.95rem; + font-size: var(--text-base); } .dropdownChevron { @@ -46,9 +46,9 @@ right: 0; background-color: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); box-shadow: var(--shadow-md); - z-index: 10; + z-index: var(--z-popover); overflow: hidden; } @@ -62,7 +62,7 @@ border: none; cursor: pointer; color: var(--text-primary); - font-size: 0.95rem; + font-size: var(--text-base); transition: background-color 0.15s; } @@ -91,7 +91,7 @@ .errorText { color: var(--error); - font-size: 0.85rem; + font-size: var(--text-md); margin-top: 8px; } @@ -109,14 +109,14 @@ } .libraryPickerTitle { - font-size: 1rem; + font-size: var(--text-lg); font-weight: 500; color: var(--text-primary); margin-bottom: 4px; } .libraryPickerHint { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); margin: 0; } @@ -127,10 +127,10 @@ } .bulkButton { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--accent); padding: 4px 8px; - border-radius: 4px; + border-radius: var(--radius-sm); } .bulkButtonMuted { @@ -148,7 +148,7 @@ } .libraryType { - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); text-transform: capitalize; } diff --git a/affiche-frontend/src/components/settings/AddMediaServerPanel.tsx b/affiche-frontend/src/components/settings/AddMediaServerPanel.tsx index ec14789..7c836bc 100644 --- a/affiche-frontend/src/components/settings/AddMediaServerPanel.tsx +++ b/affiche-frontend/src/components/settings/AddMediaServerPanel.tsx @@ -168,20 +168,20 @@ export function AddMediaServerPanel({ onClose, onCreated }: AddMediaServerPanelP onClick={handleSave} disabled={isSaving || !canSave} > - {isSaving ? : } - {isSaving ? 'Saving...' : 'Add Server'} + {isSaving ? : } + {isSaving ? 'Saving…' : 'Add server'} } >
-

Add Media Server

+

Add media server

Connect to your Plex or Jellyfin server

- +
@@ -299,14 +299,14 @@ export function AddMediaServerPanel({ onClose, onCreated }: AddMediaServerPanelP
diff --git a/affiche-frontend/src/components/settings/AddProviderPanel.module.css b/affiche-frontend/src/components/settings/AddProviderPanel.module.css index 1d24a37..91a0a7d 100644 --- a/affiche-frontend/src/components/settings/AddProviderPanel.module.css +++ b/affiche-frontend/src/components/settings/AddProviderPanel.module.css @@ -1,13 +1,13 @@ .title { margin: 0; - font-size: 1rem; + font-size: var(--text-lg); font-weight: 600; color: var(--text-primary); } .description { margin: 4px 0 0; - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } @@ -19,7 +19,7 @@ padding: 0; background: none; border: none; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-secondary); cursor: pointer; } @@ -46,7 +46,7 @@ text-align: left; background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); cursor: pointer; transition: border-color 0.15s, background-color 0.15s; } @@ -70,13 +70,13 @@ } .choiceName { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); } .choiceDescription { - font-size: 0.8rem; + font-size: var(--text-sm); line-height: 1.4; color: var(--text-muted); } @@ -85,6 +85,6 @@ margin: 0; padding: 24px; text-align: center; - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } diff --git a/affiche-frontend/src/components/settings/AddProviderPanel.tsx b/affiche-frontend/src/components/settings/AddProviderPanel.tsx index 3dcca3a..691a535 100644 --- a/affiche-frontend/src/components/settings/AddProviderPanel.tsx +++ b/affiche-frontend/src/components/settings/AddProviderPanel.tsx @@ -82,7 +82,7 @@ export function AddProviderPanel({ existing, onClose, onAdded }: AddProviderPane title={!canSave ? 'Validate the credentials first' : undefined} > - {isSaving ? 'Saving...' : 'Add Provider'} + {isSaving ? 'Saving…' : 'Add provider'} ) : undefined} @@ -98,7 +98,7 @@ export function AddProviderPanel({ existing, onClose, onAdded }: AddProviderPane All providers ) : null} -

{meta ? providerLabel(meta.serviceName) : 'Add Provider'}

+

{meta ? providerLabel(meta.serviceName) : 'Add provider'}

{meta ? meta.description : 'Pick a poster artwork provider to configure'}

diff --git a/affiche-frontend/src/components/settings/ConfigForm.module.css b/affiche-frontend/src/components/settings/ConfigForm.module.css index 330d2bd..cf1e9ec 100644 --- a/affiche-frontend/src/components/settings/ConfigForm.module.css +++ b/affiche-frontend/src/components/settings/ConfigForm.module.css @@ -1,6 +1,6 @@ .form { background-color: var(--bg-secondary); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 24px; } @@ -9,14 +9,14 @@ } .title { - font-size: 1.1rem; + font-size: var(--text-xl); font-weight: 600; color: var(--text-primary); margin-bottom: 4px; } .description { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } @@ -26,7 +26,7 @@ .label { display: block; - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; @@ -37,9 +37,9 @@ padding: 10px 12px; background-color: var(--bg-primary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); transition: border-color 0.15s; } @@ -61,7 +61,7 @@ .hint { display: block; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; } @@ -82,7 +82,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--accent); text-decoration: none; transition: color 0.15s; @@ -101,11 +101,11 @@ padding: 10px 12px; background-color: var(--bg-primary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); } .maskedValue { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); font-family: monospace; letter-spacing: 0.05em; @@ -113,11 +113,11 @@ .changeTokenButton { flex-shrink: 0; - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 500; color: var(--accent); padding: 4px 10px; - border-radius: 4px; + border-radius: var(--radius-sm); transition: background-color 0.15s; } @@ -140,7 +140,7 @@ transform: translateY(-50%); padding: 6px; color: var(--text-muted); - border-radius: 4px; + border-radius: var(--radius-sm); } .toggleToken:hover { @@ -153,7 +153,7 @@ align-items: center; gap: 8px; cursor: pointer; - font-size: 0.9rem; + font-size: var(--text-base); } .checkboxLabel input[type="checkbox"] { @@ -173,32 +173,19 @@ align-items: center; gap: 6px; padding: 10px 16px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; transition: all 0.15s; - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } .saveButton:hover:not(:disabled) { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); } .saveButton:disabled { opacity: 0.5; cursor: not-allowed; } - -.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/affiche-frontend/src/components/settings/ConfigForm.test.tsx b/affiche-frontend/src/components/settings/ConfigForm.test.tsx index d5ea12b..dbc9e17 100644 --- a/affiche-frontend/src/components/settings/ConfigForm.test.tsx +++ b/affiche-frontend/src/components/settings/ConfigForm.test.tsx @@ -24,7 +24,7 @@ function renderForm(props: Props = {}) { } const saveButton = () => screen.getByRole('button', { name: /save/i }); -const tokenField = () => screen.getByLabelText('API Token'); +const tokenField = () => screen.getByLabelText('API token'); describe('ConfigForm', () => { it('starts in token-entry mode when nothing is configured yet', () => { @@ -38,7 +38,7 @@ describe('ConfigForm', () => { renderForm({ hasStoredToken: true, storedTokenHint: 'c3et', initialUrl: TEST_URL }); - expect(screen.queryByLabelText('API Token')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('API token')).not.toBeInTheDocument(); expect(screen.getByText(/c3et/)).toBeInTheDocument(); expect(screen.getByLabelText('API URL')).toHaveValue(TEST_URL); @@ -141,7 +141,7 @@ describe('ConfigForm for a provider with an open API', () => { it('asks for no token at all', () => { renderForm({ hideToken: true }); - expect(screen.queryByLabelText('API Token')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('API token')).not.toBeInTheDocument(); }); it('can still validate, since reachability is the only question left', async () => { diff --git a/affiche-frontend/src/components/settings/ConfigForm.tsx b/affiche-frontend/src/components/settings/ConfigForm.tsx index 3a2220d..071c9a4 100644 --- a/affiche-frontend/src/components/settings/ConfigForm.tsx +++ b/affiche-frontend/src/components/settings/ConfigForm.tsx @@ -147,7 +147,7 @@ export function ConfigForm({
diff --git a/affiche-frontend/src/components/settings/GeneralSettings.module.css b/affiche-frontend/src/components/settings/GeneralSettings.module.css index 1e8f6e2..0c9bf9c 100644 --- a/affiche-frontend/src/components/settings/GeneralSettings.module.css +++ b/affiche-frontend/src/components/settings/GeneralSettings.module.css @@ -2,10 +2,10 @@ display: inline-flex; align-items: center; gap: 6px; - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 500; padding: 4px 10px; - border-radius: 10px; + border-radius: var(--radius-xl); border: 1px solid var(--border); color: var(--text-muted); } @@ -21,19 +21,19 @@ } .mutedNote { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); margin: 8px 0 0; } .infoValue { - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-secondary); font-family: monospace; } .errorMsg { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--error); margin-left: 8px; } diff --git a/affiche-frontend/src/components/settings/GeneralSettings.tsx b/affiche-frontend/src/components/settings/GeneralSettings.tsx index 28dbb53..62614f3 100644 --- a/affiche-frontend/src/components/settings/GeneralSettings.tsx +++ b/affiche-frontend/src/components/settings/GeneralSettings.tsx @@ -75,7 +75,7 @@ export function GeneralSettings() {

App-wide preferences and diagnostics. Defaults for newly added libraries live with the - libraries themselves, under Media Servers. + libraries themselves, under Media servers.

{} diff --git a/affiche-frontend/src/components/settings/LanguageOrderPanel.module.css b/affiche-frontend/src/components/settings/LanguageOrderPanel.module.css index 0677f57..b37b3cf 100644 --- a/affiche-frontend/src/components/settings/LanguageOrderPanel.module.css +++ b/affiche-frontend/src/components/settings/LanguageOrderPanel.module.css @@ -11,12 +11,12 @@ .title { font-weight: 500; - font-size: 0.9rem; + font-size: var(--text-base); } .description { margin: 0 0 10px 0; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); line-height: 1.5; } diff --git a/affiche-frontend/src/components/settings/LibraryRow.module.css b/affiche-frontend/src/components/settings/LibraryRow.module.css index f9fb947..8ca2b99 100644 --- a/affiche-frontend/src/components/settings/LibraryRow.module.css +++ b/affiche-frontend/src/components/settings/LibraryRow.module.css @@ -1,7 +1,7 @@ .row { background-color: var(--bg-tertiary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); } .rowDirty { @@ -31,13 +31,13 @@ } .dirtyBadge { - font-size: 0.7rem; + font-size: var(--text-2xs); font-weight: 500; color: var(--accent); } .count { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); } @@ -54,7 +54,7 @@ } .intervalLabel { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-secondary); } @@ -63,8 +63,8 @@ background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.85rem; + border-radius: var(--radius-md); + font-size: var(--text-md); } .intervalNumber { @@ -79,7 +79,7 @@ } .fieldLabel { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; } @@ -89,8 +89,8 @@ background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.85rem; + border-radius: var(--radius-md); + font-size: var(--text-md); } .fieldHint { diff --git a/affiche-frontend/src/components/settings/LibraryRow.tsx b/affiche-frontend/src/components/settings/LibraryRow.tsx index 4cf6307..09f16ba 100644 --- a/affiche-frontend/src/components/settings/LibraryRow.tsx +++ b/affiche-frontend/src/components/settings/LibraryRow.tsx @@ -197,7 +197,7 @@ export function LibraryRow({
-

Provider Priority

+

Provider priority

Drag to reorder. First provider with a result will be used.

@@ -209,7 +209,7 @@ export function LibraryRow({
-

Poster Style

+

Poster style

{isCustomStyle ? 'This library overrides the global style options.' @@ -227,7 +227,7 @@ export function LibraryRow({ onClick={onDelete} > - Delete Library + Delete library

diff --git a/affiche-frontend/src/components/settings/LibraryStyleModal.module.css b/affiche-frontend/src/components/settings/LibraryStyleModal.module.css index eb33338..cd30880 100644 --- a/affiche-frontend/src/components/settings/LibraryStyleModal.module.css +++ b/affiche-frontend/src/components/settings/LibraryStyleModal.module.css @@ -13,12 +13,10 @@ gap: 10px; margin: 0; padding: 12px 14px; - border: 1px solid var(--border); - border-left-width: 3px; - border-left-color: var(--warning); - border-radius: 10px; - background-color: var(--bg-secondary); - font-size: 0.85rem; + border: 1px solid var(--notice-warning-edge); + border-radius: var(--radius-xl); + background-color: var(--notice-warning-tint); + font-size: var(--text-md); color: var(--text-secondary); } @@ -33,12 +31,12 @@ flex-direction: column; gap: 10px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 14px 16px; } .modeLegend { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 600; color: var(--text-secondary); padding: 0 6px; @@ -58,7 +56,7 @@ } .modeName { - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); } @@ -70,7 +68,7 @@ } .profileLabel { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 600; color: var(--text-secondary); } @@ -79,10 +77,10 @@ .profileNameInput { padding: 6px 10px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background-color: var(--bg-secondary); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); } .profileSelect { @@ -115,7 +113,7 @@ .previewColumn { flex-shrink: 0; width: 240px; - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow-md); } @@ -130,3 +128,15 @@ flex: 1; min-width: 0; } + +@media (max-width: 700px) { + .editor { + flex-direction: column; + align-items: stretch; + } + + .previewColumn { + width: min(240px, 100%); + align-self: center; + } +} diff --git a/affiche-frontend/src/components/settings/LibraryStyleModal.tsx b/affiche-frontend/src/components/settings/LibraryStyleModal.tsx index c2a6bbe..f7f6941 100644 --- a/affiche-frontend/src/components/settings/LibraryStyleModal.tsx +++ b/affiche-frontend/src/components/settings/LibraryStyleModal.tsx @@ -181,7 +181,7 @@ export function LibraryStyleModal({ Global style - Follow Settings → Style Options, including later changes to it. + Follow Settings → Style options, including later changes to it. diff --git a/affiche-frontend/src/components/settings/MediaServerCard.module.css b/affiche-frontend/src/components/settings/MediaServerCard.module.css index 5a58941..711c5e3 100644 --- a/affiche-frontend/src/components/settings/MediaServerCard.module.css +++ b/affiche-frontend/src/components/settings/MediaServerCard.module.css @@ -11,7 +11,7 @@ .type { margin-left: auto; - font-size: 0.75rem; + font-size: var(--text-xs); font-weight: 500; } @@ -36,7 +36,7 @@ .saveButtonSmall { padding: 6px 12px; - font-size: 0.85rem; + font-size: var(--text-md); } .spacedEmptyState { @@ -55,7 +55,7 @@ margin-bottom: 16px; background-color: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; } @@ -66,7 +66,7 @@ padding: 7px 14px; background: transparent; color: var(--text-secondary); - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 500; white-space: nowrap; cursor: pointer; @@ -79,12 +79,12 @@ .sectionTabActive, .sectionTabActive:hover { - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); } .tabCount { - font-size: 0.75rem; + font-size: var(--text-xs); font-variant-numeric: tabular-nums; opacity: 0.75; } diff --git a/affiche-frontend/src/components/settings/MediaServerCard.tsx b/affiche-frontend/src/components/settings/MediaServerCard.tsx index e23b31a..063114d 100644 --- a/affiche-frontend/src/components/settings/MediaServerCard.tsx +++ b/affiche-frontend/src/components/settings/MediaServerCard.tsx @@ -114,7 +114,7 @@ export function MediaServerCard({ disabled={isSaving} > - {isSaving ? 'Saving...' : 'Save Changes'} + {isSaving ? 'Saving…' : 'Save changes'}
)} @@ -178,7 +178,7 @@ export function MediaServerCard({ onClick={onAddLibraries} > - Add Libraries + Add libraries
@@ -223,7 +223,7 @@ export function MediaServerCard({ placement="top-start" triggerClassName={sectionStyles.menuTrigger} items={[ - { icon: , label: 'Delete Server', onClick: onDeleteServer, + { icon: , label: 'Delete server', onClick: onDeleteServer, danger: true }, ]} /> diff --git a/affiche-frontend/src/components/settings/MediaServersSettings.module.css b/affiche-frontend/src/components/settings/MediaServersSettings.module.css index a6bbd48..13c9d85 100644 --- a/affiche-frontend/src/components/settings/MediaServersSettings.module.css +++ b/affiche-frontend/src/components/settings/MediaServersSettings.module.css @@ -4,7 +4,7 @@ .groupTitle { margin-bottom: 12px; - font-size: 0.95rem; + font-size: var(--text-base); font-weight: 500; color: var(--text-secondary); } diff --git a/affiche-frontend/src/components/settings/MediaServersSettings.test.tsx b/affiche-frontend/src/components/settings/MediaServersSettings.test.tsx index 252b9d3..98942e7 100644 --- a/affiche-frontend/src/components/settings/MediaServersSettings.test.tsx +++ b/affiche-frontend/src/components/settings/MediaServersSettings.test.tsx @@ -178,7 +178,7 @@ describe('MediaServersSettings saving', () => { const user = await openMoviesSettings(); await user.click(screen.getByRole('checkbox', { name: 'Enabled' })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(updateLibrarySettings).toHaveBeenCalledTimes(1)); const [serverId, libraryId, body] = updateLibrarySettings.mock.calls[0]; @@ -206,7 +206,7 @@ describe('MediaServersSettings saving', () => { await user.click(screen.getByRole('checkbox', { name: 'Enabled' })); expect(screen.getByText('Unsaved')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(screen.queryByText('Unsaved')).not.toBeInTheDocument()); }); @@ -216,11 +216,11 @@ describe('MediaServersSettings saving', () => { const user = await openMoviesSettings(); await user.click(screen.getByRole('checkbox', { name: 'Enabled' })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); expect(await screen.findByRole('alert')).toHaveTextContent('Library is locked'); expect(screen.getByText('Unsaved')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Save Changes/ })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Save changes/ })).toBeEnabled(); }); it('saves every dirty library of the card in one go', async () => { @@ -230,7 +230,7 @@ describe('MediaServersSettings saving', () => { await user.click(screen.getByRole('checkbox', { name: 'Enabled' })); await user.click(screen.getByRole('button', { name: /Shows/ })); await user.click(screen.getByRole('checkbox', { name: 'Track episodes' })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(updateLibrarySettings).toHaveBeenCalledTimes(2)); expect(updateLibrarySettings.mock.calls.map((c) => c[1]).sort()).toEqual([10, 11]); @@ -243,7 +243,7 @@ describe('MediaServersSettings deletion', () => { const user = await openServerSettings(); await user.click(screen.getByRole('button', { name: 'More actions' })); - await user.click(screen.getByRole('menuitem', { name: 'Delete Server' })); + await user.click(screen.getByRole('menuitem', { name: 'Delete server' })); return user; }; @@ -255,7 +255,7 @@ describe('MediaServersSettings deletion', () => { await user.click(screen.getByRole('button', { name: 'Delete' })); expect(await screen.findByRole('alert')).toHaveTextContent('Server is in use'); - expect(screen.getByText('Delete Server')).toBeInTheDocument(); + expect(screen.getByText('Delete server')).toBeInTheDocument(); }); it('closes the confirmation and refreshes once the delete lands', async () => { @@ -278,7 +278,7 @@ describe('MediaServersSettings artwork languages', () => { const user = await openServerSettings(); await user.click(screen.getByRole('button', { name: 'Move English up' })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(setLanguageOrder).toHaveBeenCalledWith(1, ['en', '', 'fr'])); @@ -291,10 +291,10 @@ describe('MediaServersSettings artwork languages', () => { const user = await openServerSettings(); await user.click(screen.getByRole('button', { name: 'Move English up' })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); expect(await screen.findByRole('alert')).toHaveTextContent('Server unreachable'); - expect(screen.getByRole('button', { name: /Save Changes/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save changes/ })).toBeInTheDocument(); }); }); @@ -312,7 +312,7 @@ describe('MediaServersSettings poster fallbacks', () => { const user = await openServerCard(); await user.click(screen.getByRole('checkbox', { name: /Style the media server's own poster/ })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(setPosterFallback).toHaveBeenCalledWith(1, { @@ -327,7 +327,7 @@ describe('MediaServersSettings poster fallbacks', () => { await user.click( screen.getByRole('checkbox', { name: /Use posters that already have a title as-is/ }) ); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(setPosterFallback).toHaveBeenCalledWith(1, { @@ -342,10 +342,10 @@ describe('MediaServersSettings poster fallbacks', () => { const user = await openServerCard(); await user.click(screen.getByRole('checkbox', { name: /Style the media server's own poster/ })); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); expect(await screen.findByRole('alert')).toHaveTextContent('Server unreachable'); - expect(screen.getByRole('button', { name: /Save Changes/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save changes/ })).toBeInTheDocument(); }); }); @@ -387,7 +387,7 @@ describe('MediaServersSettings auto-sync interval', () => { expect(screen.getByRole('spinbutton', { name: 'Check every' })).toHaveValue(6); await user.selectOptions(screen.getByRole('combobox', { name: 'Interval unit' }), 'days'); - await user.click(screen.getByRole('button', { name: /Save Changes/ })); + await user.click(screen.getByRole('button', { name: /Save changes/ })); await waitFor(() => expect(updateLibrarySettings).toHaveBeenCalled()); const body = updateLibrarySettings.mock.calls[0][2]; diff --git a/affiche-frontend/src/components/settings/MediaServersSettings.tsx b/affiche-frontend/src/components/settings/MediaServersSettings.tsx index a83eefa..c78bd8e 100644 --- a/affiche-frontend/src/components/settings/MediaServersSettings.tsx +++ b/affiche-frontend/src/components/settings/MediaServersSettings.tsx @@ -75,20 +75,20 @@ export function MediaServersSettings({ onServerCreated }: MediaServersSettingsPr
-

Media Servers

+

Media servers

Connect your media servers and configure library settings.

{media.servers.length > 0 ? (
-

Connected Servers

+

Connected servers

{media.servers.map(({ server, libraries }) => ( ) : (
- No media servers connected yet. Click "Add Media Server" to connect your Plex or Jellyfin + No media servers connected yet. Click "Add media server" to connect your Plex or Jellyfin server.
)} @@ -152,7 +152,7 @@ export function MediaServersSettings({ onServerCreated }: MediaServersSettingsPr - {testStatus === 'testing' && } + {testStatus === 'testing' && } {testStatus === 'delivered' && } {testStatus === 'failed' && } {testStatus === 'idle' && } diff --git a/affiche-frontend/src/components/settings/NotificationsSettings.module.css b/affiche-frontend/src/components/settings/NotificationsSettings.module.css index 55244f8..407767e 100644 --- a/affiche-frontend/src/components/settings/NotificationsSettings.module.css +++ b/affiche-frontend/src/components/settings/NotificationsSettings.module.css @@ -13,7 +13,7 @@ gap: 10px; padding: 8px 12px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background-color: var(--bg-secondary); } @@ -26,7 +26,7 @@ .name { flex: 1; min-width: 0; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; @@ -34,12 +34,12 @@ } .meta { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); } .events { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-secondary); } @@ -48,7 +48,7 @@ align-items: center; justify-content: center; padding: 5px; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-secondary); transition: background-color 0.15s, color 0.15s; } diff --git a/affiche-frontend/src/components/settings/PosterApisSettings.test.tsx b/affiche-frontend/src/components/settings/PosterApisSettings.test.tsx index db2b841..abf775a 100644 --- a/affiche-frontend/src/components/settings/PosterApisSettings.test.tsx +++ b/affiche-frontend/src/components/settings/PosterApisSettings.test.tsx @@ -88,7 +88,7 @@ describe('PosterApisSettings save failures', () => { const user = await openCard('TMDB'); await user.click(screen.getByRole('button', { name: /^save$/i })); - expect(await screen.findByRole('alert')).toHaveTextContent(/saved/i); + expect(await screen.findByRole('status')).toHaveTextContent(/saved/i); }); }); @@ -110,7 +110,7 @@ describe('PosterApisSettings open-API provider', () => { it('asks for no API token', async () => { await openCard('TVmaze'); - expect(screen.queryByLabelText('API Token')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('API token')).not.toBeInTheDocument(); }); it('saves the enabled toggle without sending a token', async () => { @@ -191,7 +191,7 @@ describe('PosterApisSettings URL field', () => { await user.clear(screen.getByLabelText('API URL')); await user.type(screen.getByLabelText('API URL'), 'http://nas:8111'); - await user.type(screen.getByLabelText('API Token'), 'a-key'); + await user.type(screen.getByLabelText('API token'), 'a-key'); await user.click(screen.getByRole('button', { name: /validate/i })); await waitFor(() => expect(testProvider).toHaveBeenCalledWith( diff --git a/affiche-frontend/src/components/settings/PosterApisSettings.tsx b/affiche-frontend/src/components/settings/PosterApisSettings.tsx index 902f88d..1b5bd18 100644 --- a/affiche-frontend/src/components/settings/PosterApisSettings.tsx +++ b/affiche-frontend/src/components/settings/PosterApisSettings.tsx @@ -92,7 +92,7 @@ export function PosterApisSettings({ configs, onConfigSaved }: PosterApisSetting
@@ -118,7 +118,7 @@ export function PosterApisSettings({ configs, onConfigSaved }: PosterApisSetting
) : (
- No poster providers yet. Click "Add Provider" to configure one — TMDB is a good place to + No poster providers yet. Click "Add provider" to configure one — TMDB is a good place to start.
)} diff --git a/affiche-frontend/src/components/settings/PosterFallbackPanel.module.css b/affiche-frontend/src/components/settings/PosterFallbackPanel.module.css index e541951..d36e4d5 100644 --- a/affiche-frontend/src/components/settings/PosterFallbackPanel.module.css +++ b/affiche-frontend/src/components/settings/PosterFallbackPanel.module.css @@ -11,12 +11,12 @@ .title { font-weight: 500; - font-size: 0.9rem; + font-size: var(--text-base); } .description { margin: 0 0 10px 0; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); line-height: 1.5; } @@ -27,7 +27,7 @@ .hint { margin: 0 0 12px 26px; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; } diff --git a/affiche-frontend/src/components/settings/PosterProviderCard.module.css b/affiche-frontend/src/components/settings/PosterProviderCard.module.css index 8f48c9c..0ae02c6 100644 --- a/affiche-frontend/src/components/settings/PosterProviderCard.module.css +++ b/affiche-frontend/src/components/settings/PosterProviderCard.module.css @@ -18,10 +18,10 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: 0.72rem; + font-size: var(--text-2xs); font-weight: 500; padding: 2px 8px; - border-radius: 10px; + border-radius: var(--radius-xl); border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap; @@ -38,7 +38,7 @@ } .description { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); margin: 0; padding: 16px 24px 0; diff --git a/affiche-frontend/src/components/settings/PreviewSubjectModal.module.css b/affiche-frontend/src/components/settings/PreviewSubjectModal.module.css index fac1047..316d344 100644 --- a/affiche-frontend/src/components/settings/PreviewSubjectModal.module.css +++ b/affiche-frontend/src/components/settings/PreviewSubjectModal.module.css @@ -13,11 +13,11 @@ .librarySelect { flex: 1 1 200px; padding: 8px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background-color: var(--bg-tertiary); color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); } .searchWrapper { @@ -37,11 +37,11 @@ .searchInput { width: 100%; padding: 8px 10px 8px 32px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background-color: var(--bg-tertiary); color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); } .searchInput::placeholder { @@ -57,7 +57,7 @@ .error { margin-bottom: 12px; color: var(--error); - font-size: 0.85rem; + font-size: var(--text-md); } .state { @@ -68,7 +68,7 @@ padding: 40px 16px; text-align: center; color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-base); } .list { @@ -90,7 +90,7 @@ width: 100%; padding: 10px 12px; border: 1px solid transparent; - border-radius: 8px; + border-radius: var(--radius-lg); background: none; text-align: left; cursor: pointer; @@ -105,23 +105,23 @@ .itemTitle { flex: 1; color: var(--text-primary); - font-size: 0.92rem; + font-size: var(--text-base); } .itemMeta { color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-sm); } .loadMore { width: 100%; margin-top: 12px; padding: 10px 16px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background-color: transparent; color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); transition: all 0.15s; } @@ -137,11 +137,11 @@ .cancelButton { flex: 1; padding: 10px 16px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background-color: transparent; color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; transition: all 0.15s; } @@ -149,11 +149,3 @@ .cancelButton:hover { background-color: var(--bg-hover); } - -.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} diff --git a/affiche-frontend/src/components/settings/PreviewSubjectModal.tsx b/affiche-frontend/src/components/settings/PreviewSubjectModal.tsx index 1e3ada0..75aa3a6 100644 --- a/affiche-frontend/src/components/settings/PreviewSubjectModal.tsx +++ b/affiche-frontend/src/components/settings/PreviewSubjectModal.tsx @@ -93,6 +93,7 @@ export function PreviewSubjectModal({ onClose, onSelect }: PreviewSubjectModalPr
setDeleteTarget(null)} /> )} + {unsavedDialog} ); } @@ -299,7 +323,7 @@ export function StyleSettings() { return (
-

Style Options

+

Style options

{ }

@@ -307,7 +331,7 @@ export function StyleSettings() { a style of its own.

-

Generation Defaults

+

Generation defaults

These defaults are applied to every poster during generation.

@@ -359,8 +383,8 @@ export function StyleSettings() {
); } diff --git a/affiche-frontend/src/components/settings/UserAccountModal.module.css b/affiche-frontend/src/components/settings/UserAccountModal.module.css index 76b84ab..33af4bf 100644 --- a/affiche-frontend/src/components/settings/UserAccountModal.module.css +++ b/affiche-frontend/src/components/settings/UserAccountModal.module.css @@ -17,14 +17,14 @@ } .label { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 600; color: var(--text-secondary); padding: 0; } .readOnly { - font-size: 0.95rem; + font-size: var(--text-base); color: var(--text-primary); } @@ -34,7 +34,7 @@ gap: 10px; padding: 10px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); cursor: pointer; transition: border-color 0.15s, background-color 0.15s; } @@ -65,7 +65,7 @@ } .optionName { - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 500; color: var(--text-primary); } @@ -76,9 +76,9 @@ padding: 8px 10px; background-color: var(--bg-primary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); transition: border-color 0.15s; } diff --git a/affiche-frontend/src/components/settings/UsersSettings.module.css b/affiche-frontend/src/components/settings/UsersSettings.module.css index 8979196..4d9eac8 100644 --- a/affiche-frontend/src/components/settings/UsersSettings.module.css +++ b/affiche-frontend/src/components/settings/UsersSettings.module.css @@ -12,22 +12,22 @@ .identityName { font-weight: 500; - font-size: 0.95rem; + font-size: var(--text-base); } .identityHint { margin-left: auto; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--text-muted); } .badge { - font-size: 0.68rem; + font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); border: 1px solid var(--border); - border-radius: 999px; + border-radius: var(--radius-pill); padding: 2px 8px; } @@ -54,12 +54,12 @@ .blockTitle { font-weight: 500; - font-size: 0.9rem; + font-size: var(--text-base); } .blockHint { margin: 0 0 14px 0; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); } @@ -79,7 +79,7 @@ } .label { - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary); } @@ -89,9 +89,9 @@ padding: 8px 10px; background-color: var(--bg-primary); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-primary); - font-size: 0.85rem; + font-size: var(--text-md); transition: border-color 0.15s; } @@ -132,7 +132,7 @@ } .accountName { - font-size: 0.9rem; + font-size: var(--text-base); } .rowActions { @@ -148,7 +148,7 @@ padding: 5px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-muted); cursor: pointer; transition: color 0.15s, background-color 0.15s; diff --git a/affiche-frontend/src/components/settings/UsersSettings.tsx b/affiche-frontend/src/components/settings/UsersSettings.tsx index 3843a4b..319688d 100644 --- a/affiche-frontend/src/components/settings/UsersSettings.tsx +++ b/affiche-frontend/src/components/settings/UsersSettings.tsx @@ -88,7 +88,7 @@ export function UsersSettings() { - {isSaving ? 'Saving...' : 'Change password'} + {isSaving ? 'Saving…' : 'Change password'}
diff --git a/affiche-frontend/src/components/settings/WebhookPanel.module.css b/affiche-frontend/src/components/settings/WebhookPanel.module.css index 1b184a1..44b00b1 100644 --- a/affiche-frontend/src/components/settings/WebhookPanel.module.css +++ b/affiche-frontend/src/components/settings/WebhookPanel.module.css @@ -11,12 +11,12 @@ .title { font-weight: 500; - font-size: 0.9rem; + font-size: var(--text-base); } .description { margin: 0 0 10px 0; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); line-height: 1.5; } @@ -44,8 +44,8 @@ padding: 8px 10px; background: var(--bg-primary); border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.8rem; + border-radius: var(--radius-md); + font-size: var(--text-sm); color: var(--text-secondary); } diff --git a/affiche-frontend/src/components/settings/mediaServerHelpers.ts b/affiche-frontend/src/components/settings/mediaServerHelpers.ts index c023278..703417c 100644 --- a/affiche-frontend/src/components/settings/mediaServerHelpers.ts +++ b/affiche-frontend/src/components/settings/mediaServerHelpers.ts @@ -11,13 +11,13 @@ export const SERVER_CONFIG: Record void; +} + +export function ConnectServerStep({ onCreated }: ConnectServerStepProps) { + const [serverType, setServerType] = useState('PLEX'); + const [url, setUrl] = useState(SERVER_CONFIG.PLEX.url); + const [token, setToken] = useState(''); + const [found, setFound] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [status, setStatus] = useState<'idle' | 'connecting' | 'saving'>('idle'); + const [error, setError] = useState(null); + + const forget = () => { + setFound(null); + setError(null); + }; + + const chooseType = (type: MediaServerType) => { + if (type === serverType) return; + setServerType(type); + setUrl(SERVER_CONFIG[type].url); + setToken(''); + forget(); + }; + + const connect = async () => { + setStatus('connecting'); + forget(); + try { + const result = serverType === 'PLEX' + ? await mediaServerApi.testPlex(url, token) + : await mediaServerApi.testJellyfin(url, token); + setFound(result); + setSelected(new Set(result.libraries.map((library) => library.id))); + } catch (err) { + setError(errorMessage(err, `Could not reach ${SERVER_CONFIG[serverType].name} at that address.`)); + } finally { + setStatus('idle'); + } + }; + + const save = async () => { + if (!found) return; + setStatus('saving'); + setError(null); + try { + await mediaServerApi.create({ + name: found.name, + type: serverType, + url, + token, + enabled: true, + libraries: found.libraries.filter((library) => selected.has(library.id)), + }); + onCreated(); + } catch (err) { + setError(errorMessage(err, 'The server could not be saved.')); + setStatus('idle'); + } + }; + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(id)) next.add(id); + return next; + }); + }; + + const config = SERVER_CONFIG[serverType]; + const count = selected.size; + + return ( + <> +
+ {(Object.keys(SERVER_CONFIG) as MediaServerType[]).map((type) => ( + + ))} +
+ +
+
+ + { setUrl(e.target.value); forget(); }} + placeholder={config.url} + /> +
+
+ + { setToken(e.target.value); forget(); }} + placeholder={config.tokenPlaceholder} + /> +
+
+

+ {serverType === 'PLEX' ? ( + <>Where to find it: Finding your Plex token. + ) : ( + 'Create one in Jellyfin under Dashboard → API Keys.' + )} +

+ + {error &&

{error}

} + + {found ? ( + <> +

+ Connected to “{found.name}” — {found.libraries.length}{' '} + {found.libraries.length === 1 ? 'library' : 'libraries'} found +

+
+ {found.libraries.map((library) => ( + + ))} +
+
+ +
+ + ) : ( +
+ +
+ )} + + ); +} diff --git a/affiche-frontend/src/components/welcome/FirstPostersStep.tsx b/affiche-frontend/src/components/welcome/FirstPostersStep.tsx new file mode 100644 index 0000000..bee762f --- /dev/null +++ b/affiche-frontend/src/components/welcome/FirstPostersStep.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; + +import { errorMessage, libraryApi } from '../../api'; +import { useLibraryUploadSetting } from '../../hooks'; +import type { Library } from '../../types'; +import styles from './Welcome.module.css'; + +interface FirstPostersStepProps { + libraries: Library[]; + + onStarted: (library: Library) => void; +} + +export function FirstPostersStep({ libraries, onStarted }: FirstPostersStepProps) { + const [libraryId, setLibraryId] = useState(libraries[0]?.id); + const [status, setStatus] = useState<'idle' | 'generating' | 'syncing'>('idle'); + const [error, setError] = useState(null); + + const library = libraries.find((l) => l.id === libraryId) ?? libraries[0]; + const uploads = useLibraryUploadSetting({ library, enabled: !!library }); + + const start = async (generate: boolean) => { + if (!library) return; + setStatus(generate ? 'generating' : 'syncing'); + setError(null); + try { + await (generate + ? libraryApi.syncLibraryPosters(library.media_server_id, library.id) + : libraryApi.syncLibrary(library.media_server_id, library.id)); + onStarted(library); + } catch (err) { + setError(errorMessage(err, 'The run could not be started.')); + setStatus('idle'); + } + }; + + if (!library) { + return

No library was added. Add one in Settings → Media servers.

; + } + + return ( + <> +
+ + +
+ {} + {uploads !== undefined && ( +

+ {uploads + ? `${library.name} uploads new posters to your media server as they are made, keeping its current artwork for Reset.` + : `${library.name} keeps new posters in Affiche until you upload them.`} +

+ )} + + {error &&

{error}

} + +
+ + +
+ + ); +} diff --git a/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx b/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx new file mode 100644 index 0000000..8f80a72 --- /dev/null +++ b/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; + +import { configApi, errorMessage, serviceApi } from '../../api'; +import { POSTER_PROVIDER_CARDS, type PosterProvider } from '../../constants/providers'; +import type { Library, ServiceConfiguration } from '../../types'; +import { PROVIDER_ICONS } from '../settings/providerIcons'; +import { missingTmdbWarning } from './welcomeSteps'; +import styles from './Welcome.module.css'; + +const card = (name: PosterProvider) => POSTER_PROVIDER_CARDS.find((c) => c.serviceName === name)!; +const TMDB = card('tmdb'); +const TVMAZE = card('tvmaze'); + +interface PosterSourcesStepProps { + + libraries: Library[]; + + onDone: (providers: PosterProvider[]) => void; +} + +export function PosterSourcesStep({ libraries, onDone }: PosterSourcesStepProps) { + const [configs, setConfigs] = useState(null); + const [tmdbToken, setTmdbToken] = useState(''); + const [status, setStatus] = useState<'idle' | 'checking' | 'saving'>('idle'); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + configApi + .findConfigs('PROVIDER') + .then((found) => { if (!cancelled) setConfigs(found); }) + .catch(() => { if (!cancelled) setConfigs([]); }); + return () => { cancelled = true; }; + }, []); + + const stored = (name: string) => configs?.find((config) => config.name === name); + const tmdbReady = !!stored('tmdb')?.configured; + const warning = missingTmdbWarning(libraries, tmdbReady); + + const checkTmdb = async () => { + const token = tmdbToken.trim(); + setStatus('checking'); + setError(null); + try { + await serviceApi.testProvider('tmdb', token, TMDB.defaultUrl); + const saved = await configApi.createConfig({ + name: 'tmdb', type: 'PROVIDER', url: TMDB.defaultUrl, token, enabled: true, + }); + setConfigs((prev) => [...(prev ?? []).filter((c) => c.name !== 'tmdb'), saved]); + setTmdbToken(''); + } catch (err) { + setError(errorMessage(err, 'TMDB did not accept that token. Copy the "API Read Access Token", not the API key.')); + } finally { + setStatus('idle'); + } + }; + + const done = async () => { + setStatus('saving'); + setError(null); + try { + if (!stored('tvmaze')) { + await configApi.createConfig({ name: 'tvmaze', type: 'PROVIDER', url: TVMAZE.defaultUrl, enabled: true }); + } + onDone(tmdbReady ? ['tmdb', 'tvmaze'] : ['tvmaze']); + } catch (err) { + setError(errorMessage(err, 'TVmaze could not be turned on.')); + setStatus('idle'); + } + }; + + if (configs === null) { + return ; + } + + return ( + <> +
+
+ {PROVIDER_ICONS.tvmaze} +
+

TVmaze

+

Series artwork. No key needed.

+
+ Ready +
+ +
+ {PROVIDER_ICONS.tmdb} +
+

TMDB

+

+ The main source for films and series. Free token from{' '} + themoviedb.org. +

+
+ {tmdbReady ? 'Ready' : 'Suggested'} + {!tmdbReady && ( +
+ setTmdbToken(e.target.value)} + /> + +
+ )} +
+
+

TheTVDB, Fanart.tv, MediUX and Shoko can be added in Settings → Poster APIs.

+ + {error &&

{error}

} + +
+ + {warning && {warning}} +
+ + ); +} diff --git a/affiche-frontend/src/components/welcome/SetupStep.tsx b/affiche-frontend/src/components/welcome/SetupStep.tsx new file mode 100644 index 0000000..3aac83b --- /dev/null +++ b/affiche-frontend/src/components/welcome/SetupStep.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; +import { Check } from 'lucide-react'; + +import type { StepState } from './welcomeSteps'; +import styles from './Welcome.module.css'; + +interface SetupStepProps { + number: number; + title: string; + + summary: string; + state: StepState; + + onChange?: () => void; + + children: ReactNode; +} + +export function SetupStep({ number, title, summary, state, onChange, children }: SetupStepProps) { + const headingId = `setup-step-${number}`; + return ( +
  • +
    + +
    +

    + {title} + {state === 'done' && (done)} +

    +

    {summary}

    +
    + {state === 'done' && onChange && ( + + )} +
    + {state === 'current' &&
    {children}
    } +
  • + ); +} diff --git a/affiche-frontend/src/components/welcome/Welcome.module.css b/affiche-frontend/src/components/welcome/Welcome.module.css new file mode 100644 index 0000000..73f2b60 --- /dev/null +++ b/affiche-frontend/src/components/welcome/Welcome.module.css @@ -0,0 +1,314 @@ +.step { + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background-color: var(--bg-secondary); +} + +.stepHead { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; +} + +.stepNumber { + flex-shrink: 0; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1.5px solid var(--border); + color: var(--text-secondary); + font-size: var(--text-md); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.current .stepNumber { + border-color: var(--accent-fill); + color: var(--accent); +} + +.done .stepNumber { + border-color: var(--success-fill); + background-color: var(--success-fill); + color: var(--text-on-fill); +} + +.stepText { + flex: 1; + min-width: 0; +} + +.stepTitle { + font-size: var(--text-lg); + font-weight: 600; +} + +.upcoming .stepTitle { + color: var(--text-secondary); +} + +.stepSummary { + font-size: var(--text-md); + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.stepBody { + display: flex; + flex-direction: column; + gap: 14px; + padding: 0 18px 18px 60px; +} + +.primary, +.secondary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: var(--text-base); + font-weight: 500; + white-space: nowrap; +} + +.primary { + background-color: var(--accent-fill); + color: var(--text-on-fill); +} + +.primary:hover:not(:disabled) { + background-color: var(--accent-fill-hover); +} + +.secondary { + border: 1px solid var(--border); + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.secondary:hover:not(:disabled) { + background-color: var(--bg-hover); +} + +.primary:disabled, +.secondary:disabled, +.linkButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.linkButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: var(--radius-md); + color: var(--accent); + font-size: var(--text-base); +} + +.linkButton:hover:not(:disabled) { + background-color: var(--bg-tertiary); +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; +} + +.choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.choice { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1.5px solid var(--border); + border-radius: var(--radius-lg); + background-color: var(--bg-primary); + text-align: left; + color: var(--text-primary); +} + +.choice[aria-pressed='true'] { + border-color: var(--accent-fill); +} + +.choiceName { + display: block; + font-weight: 600; +} + +.choiceHint { + display: block; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 5px; + max-width: 100%; +} + +.field label { + font-size: var(--text-md); + color: var(--text-secondary); +} + +.field input, +.field select, +.keyRow input { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: var(--text-base); +} + +.field select { + max-width: 320px; +} + +.hint { + font-size: var(--text-md); + color: var(--text-secondary); +} + +.warning { + font-size: var(--text-md); + color: var(--warning); +} + +.error { + font-size: var(--text-md); + color: var(--error); +} + +.ok { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-base); + font-weight: 500; + color: var(--success); +} + +.libraries { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.library { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background-color: var(--bg-primary); + cursor: pointer; +} + +.library + .library { + border-top: 1px solid var(--border); +} + +.libraryCount { + margin-left: auto; + font-size: var(--text-md); + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.providers { + display: flex; + flex-direction: column; + gap: 8px; +} + +.provider { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background-color: var(--bg-primary); +} + +.providerIcon { + display: flex; +} + +.providerName { + font-weight: 600; +} + +.providerText { + font-size: var(--text-md); + color: var(--text-secondary); +} + +.tag { + padding: 2px 8px; + border-radius: var(--radius-pill); + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-size: var(--text-2xs); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.tagReady { + color: var(--success); +} + +.keyRow { + grid-column: 2 / 4; + display: flex; + gap: 8px; +} + +.keyRow input { + flex: 1; + background-color: var(--bg-secondary); +} + +@media (max-width: 600px) { + .stepBody { + padding-left: 18px; + } + + .choices, + .fields { + grid-template-columns: 1fr; + } + + .keyRow { + grid-column: 1 / 4; + flex-wrap: wrap; + } +} diff --git a/affiche-frontend/src/components/welcome/index.ts b/affiche-frontend/src/components/welcome/index.ts new file mode 100644 index 0000000..512e599 --- /dev/null +++ b/affiche-frontend/src/components/welcome/index.ts @@ -0,0 +1,5 @@ +export { SetupStep } from './SetupStep'; +export { ConnectServerStep } from './ConnectServerStep'; +export { PosterSourcesStep } from './PosterSourcesStep'; +export { FirstPostersStep } from './FirstPostersStep'; +export { currentWelcomeStep, stepState, missingTmdbWarning } from './welcomeSteps'; diff --git a/affiche-frontend/src/components/welcome/welcomeSteps.test.ts b/affiche-frontend/src/components/welcome/welcomeSteps.test.ts new file mode 100644 index 0000000..5eb5016 --- /dev/null +++ b/affiche-frontend/src/components/welcome/welcomeSteps.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { currentWelcomeStep, missingTmdbWarning, stepState } from './welcomeSteps'; +import type { Library } from '../../types'; + +const lib = (name: string, library_type: string) => ({ id: 1, media_server_id: 1, name, library_type }) as Library; + +describe('currentWelcomeStep', () => { + it('opens on the server until one is stored, however far the sources got', () => { + expect(currentWelcomeStep(false, true)).toBe('server'); + }); + + it('asks for sources after a reload with a server, since nothing stored says they were decided', () => { + expect(currentWelcomeStep(true, false)).toBe('sources'); + expect(currentWelcomeStep(true, true)).toBe('posters'); + }); + + it('marks earlier steps done and later ones upcoming', () => { + expect(['server', 'sources', 'posters'].map((s) => stepState(s as never, 'sources'))) + .toEqual(['done', 'current', 'upcoming']); + }); +}); + +describe('missingTmdbWarning', () => { + it('names the film libraries that going without TMDB leaves short', () => { + expect(missingTmdbWarning([lib('Films', 'movie'), lib('Shows', 'show')], false)) + .toBe('Without TMDB, Films will get few posters: TVmaze only has series.'); + }); + + it('says nothing when TMDB is ready or there are only series', () => { + expect(missingTmdbWarning([lib('Films', 'movie')], true)).toBeNull(); + expect(missingTmdbWarning([lib('Shows', 'show')], false)).toBeNull(); + }); +}); diff --git a/affiche-frontend/src/components/welcome/welcomeSteps.ts b/affiche-frontend/src/components/welcome/welcomeSteps.ts new file mode 100644 index 0000000..a112750 --- /dev/null +++ b/affiche-frontend/src/components/welcome/welcomeSteps.ts @@ -0,0 +1,26 @@ +import type { Library } from '../../types'; + +export type WelcomeStep = 'server' | 'sources' | 'posters'; + +export const WELCOME_STEPS: WelcomeStep[] = ['server', 'sources', 'posters']; + +export type StepState = 'done' | 'current' | 'upcoming'; + +export function currentWelcomeStep(hasServer: boolean, sourcesConfirmed: boolean): WelcomeStep { + if (!hasServer) return 'server'; + return sourcesConfirmed ? 'posters' : 'sources'; +} + +export function stepState(step: WelcomeStep, current: WelcomeStep): StepState { + const at = WELCOME_STEPS.indexOf(step); + const now = WELCOME_STEPS.indexOf(current); + return at < now ? 'done' : at === now ? 'current' : 'upcoming'; +} + +export function missingTmdbWarning(libraries: Library[], tmdbReady: boolean): string | null { + if (tmdbReady) return null; + const films = libraries.filter((library) => library.library_type === 'movie'); + if (films.length === 0) return null; + const names = films.map((library) => library.name).join(', '); + return `Without TMDB, ${names} will get few posters: TVmaze only has series.`; +} diff --git a/affiche-frontend/src/constants/platform.test.ts b/affiche-frontend/src/constants/platform.test.ts new file mode 100644 index 0000000..64bfe59 --- /dev/null +++ b/affiche-frontend/src/constants/platform.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { searchShortcutLabel } from './platform'; + +afterEach(() => vi.unstubAllGlobals()); + +const onPlatform = (platform: string) => vi.stubGlobal('navigator', { platform }); + +describe('searchShortcutLabel', () => { + it('writes the shortcut the way the platform does', () => { + onPlatform('MacIntel'); + expect(searchShortcutLabel()).toBe('⌘K'); + + onPlatform('Win32'); + expect(searchShortcutLabel()).toBe('Ctrl K'); + }); + + it('prefers the client-hints platform where the browser offers it', () => { + vi.stubGlobal('navigator', { platform: 'Win32', userAgentData: { platform: 'macOS' } }); + expect(searchShortcutLabel()).toBe('⌘K'); + }); +}); diff --git a/affiche-frontend/src/constants/platform.ts b/affiche-frontend/src/constants/platform.ts new file mode 100644 index 0000000..e2ccf01 --- /dev/null +++ b/affiche-frontend/src/constants/platform.ts @@ -0,0 +1,7 @@ +export function isApplePlatform(): boolean { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform ?? nav.platform ?? ''; + return /mac|iphone|ipad|ipod/i.test(platform); +} + +export const searchShortcutLabel = () => (isApplePlatform() ? '⌘K' : 'Ctrl K'); diff --git a/affiche-frontend/src/context/ToastContext.module.css b/affiche-frontend/src/context/ToastContext.module.css index cb9da42..cf295ec 100644 --- a/affiche-frontend/src/context/ToastContext.module.css +++ b/affiche-frontend/src/context/ToastContext.module.css @@ -2,7 +2,7 @@ position: fixed; bottom: 20px; right: 20px; - z-index: 1000; + z-index: var(--z-toast); display: flex; flex-direction: column; gap: 10px; @@ -16,10 +16,9 @@ align-items: flex-start; gap: 10px; padding: 12px 14px; - border-radius: 10px; + border-radius: var(--radius-xl); background-color: var(--bg-secondary); border: 1px solid var(--border); - border-left-width: 3px; box-shadow: var(--shadow-lg); animation: slideIn 0.18s ease-out; } @@ -41,7 +40,8 @@ } .error { - border-left-color: var(--error); + border-color: var(--notice-error-edge); + background: linear-gradient(var(--notice-error-tint), var(--notice-error-tint)), var(--bg-secondary); } .error .icon { @@ -49,7 +49,8 @@ } .success { - border-left-color: var(--success); + border-color: var(--notice-success-edge); + background: linear-gradient(var(--notice-success-tint), var(--notice-success-tint)), var(--bg-secondary); } .success .icon { @@ -57,7 +58,8 @@ } .info { - border-left-color: var(--accent); + border-color: var(--notice-info-edge); + background: linear-gradient(var(--notice-info-tint), var(--notice-info-tint)), var(--bg-secondary); } .info .icon { @@ -70,14 +72,14 @@ } .title { - font-size: 0.85rem; + font-size: var(--text-md); font-weight: 600; color: var(--text-primary); margin-bottom: 2px; } .message { - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.4; @@ -93,7 +95,7 @@ align-items: center; justify-content: center; padding: 3px; - border-radius: 5px; + border-radius: var(--radius-sm); color: var(--text-muted); transition: all 0.15s; } @@ -102,3 +104,22 @@ background-color: var(--bg-hover); color: var(--text-primary); } + +.action { + margin-top: 6px; + padding: 0; + font-size: var(--text-sm); + font-weight: 600; + color: var(--accent); +} + +.action:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +@media (prefers-reduced-motion: reduce) { + .toast { + animation: none; + } +} diff --git a/affiche-frontend/src/context/ToastContext.test.tsx b/affiche-frontend/src/context/ToastContext.test.tsx new file mode 100644 index 0000000..0f752e0 --- /dev/null +++ b/affiche-frontend/src/context/ToastContext.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { ToastProvider, useToast } from './ToastContext'; + +function Trigger() { + const toast = useToast(); + return ; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Toasts', () => { + it('stay while hovered, and leave once the pointer does', () => { + vi.useFakeTimers(); + render(); + + fireEvent.click(screen.getByText('go')); + const toast = screen.getByRole('status'); + fireEvent.mouseEnter(toast); + act(() => { vi.advanceTimersByTime(10_000); }); + expect(screen.getByText('Posters generated')).toBeInTheDocument(); + + fireEvent.mouseLeave(toast); + act(() => { vi.advanceTimersByTime(4_000); }); + expect(screen.queryByText('Posters generated')).not.toBeInTheDocument(); + }); + + it('announce success politely and errors as alerts', () => { + function Both() { + const toast = useToast(); + return ; + } + render(); + + fireEvent.click(screen.getByText('go')); + + expect(screen.getByRole('status')).toHaveTextContent('ok'); + expect(screen.getByRole('alert')).toHaveTextContent('nope'); + }); +}); diff --git a/affiche-frontend/src/context/ToastContext.tsx b/affiche-frontend/src/context/ToastContext.tsx index 8c9766d..c1fe13d 100644 --- a/affiche-frontend/src/context/ToastContext.tsx +++ b/affiche-frontend/src/context/ToastContext.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useRef, useState, @@ -12,17 +13,27 @@ import styles from './ToastContext.module.css'; export type ToastType = 'error' | 'success' | 'info'; +interface ToastAction { + label: string; + onClick: () => void; +} + interface Toast { id: number; type: ToastType; title?: string; message: string; + action?: ToastAction; + + duration: number; } interface ShowToastOptions { title?: string; duration?: number; + + action?: ToastAction; } interface ToastContextValue { @@ -56,12 +67,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { const show = useCallback((type: ToastType, message: string, options?: ShowToastOptions) => { const id = nextId.current++; - setToasts((prev) => [...prev, { id, type, message, title: options?.title }]); const duration = options?.duration ?? DEFAULT_DURATION[type]; - if (duration > 0) { - window.setTimeout(() => remove(id), duration); - } - }, [remove]); + setToasts((prev) => [...prev, { id, type, message, title: options?.title, action: options?.action, duration }]); + }, []); const error = useCallback((m: string, o?: ShowToastOptions) => show('error', m, o), [show]); const success = useCallback((m: string, o?: ShowToastOptions) => show('success', m, o), [show]); @@ -76,30 +84,57 @@ export function ToastProvider({ children }: { children: ReactNode }) { {children}
    - {toasts.map((toast) => { - const Icon = ICONS[toast.type]; - return ( -
    - -
    - {toast.title &&
    {toast.title}
    } -
    {toast.message}
    -
    - -
    - ); - })} + {toasts.map((toast) => ( + + ))}
    ); } +function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: number) => void }) { + const [paused, setPaused] = useState(false); + const Icon = ICONS[toast.type]; + const onDismiss = () => onRemove(toast.id); + + useEffect(() => { + if (paused || toast.duration <= 0) return; + const timer = window.setTimeout(() => onRemove(toast.id), toast.duration); + return () => window.clearTimeout(timer); + }, [paused, toast.duration, toast.id, onRemove]); + + return ( +
    setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)} + > + +
    + {toast.title &&
    {toast.title}
    } +
    {toast.message}
    + {toast.action && ( + + )} +
    + +
    + ); +} + // eslint-disable-next-line react-refresh/only-export-components export function useToast(): ToastContextValue { const ctx = useContext(ToastContext); diff --git a/affiche-frontend/src/hooks/index.ts b/affiche-frontend/src/hooks/index.ts index 45db3fd..e7d6d20 100644 --- a/affiche-frontend/src/hooks/index.ts +++ b/affiche-frontend/src/hooks/index.ts @@ -9,8 +9,11 @@ export { useLibraryItems, PAGE_SIZE } from './useLibraryItems'; export { useInfiniteScroll } from './useInfiniteScroll'; export { useLibraryListing } from './useLibraryListing'; export { useLibraryItemCounts } from './useLibraryItemCounts'; +export { useLibraryCoverage } from './useLibraryCoverage'; +export { useLibraryUploadSetting } from './useLibraryUploadSetting'; export { useOpenItem } from './useOpenItem'; export { useLibraryRows } from './useLibraryRows'; +export { useStagePoster } from './useStagePoster'; export { useTaskTracking, taskKindFromName } from './useTaskTracking'; export { usePosterBrowser } from './usePosterBrowser'; export { useAlphabetIndex } from './useAlphabetIndex'; @@ -29,3 +32,9 @@ export { usePosterStyleDrafts } from './usePosterStyleDrafts'; export { usePosterTitleDraft } from './usePosterTitleDraft'; export { usePosterBrowseQuery } from './usePosterBrowseQuery'; export { usePopoverDismiss } from './usePopoverDismiss'; +export { useMenuKeyboard } from './useMenuKeyboard'; +export { useMediaQuery } from './useMediaQuery'; +export { useInView } from './useInView'; +export { useItemNeighbours } from './useItemNeighbours'; +export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'; +export { useItemArtwork, resetItemArtworkCache } from './useItemArtwork'; diff --git a/affiche-frontend/src/hooks/useAlphabetIndex.ts b/affiche-frontend/src/hooks/useAlphabetIndex.ts index 2fa3aab..df50a9f 100644 --- a/affiche-frontend/src/hooks/useAlphabetIndex.ts +++ b/affiche-frontend/src/hooks/useAlphabetIndex.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; import { errorMessage, libraryApi } from '../api'; import { useToast } from '../context/ToastContext'; +import { scrollBehavior } from '../motion'; import { PAGE_SIZE } from './useLibraryItems'; import type { AlphaIndexEntry, @@ -88,7 +89,7 @@ export function useAlphabetIndex({ }, [isTrash, hasLibrary, search, listingKey]); const scrollToLetter = useCallback((letter: string) => { - document.getElementById(`alpha-anchor-${letter}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + document.getElementById(`alpha-anchor-${letter}`)?.scrollIntoView({ behavior: scrollBehavior(), block: 'start' }); }, []); const handleLetterClick = useCallback(async (letter: string) => { diff --git a/affiche-frontend/src/hooks/useInView.ts b/affiche-frontend/src/hooks/useInView.ts new file mode 100644 index 0000000..22d7381 --- /dev/null +++ b/affiche-frontend/src/hooks/useInView.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +export function useInView(rootMargin = '200px') { + const [element, setElement] = useState(null); + const [seen, setSeen] = useState(() => typeof IntersectionObserver === 'undefined'); + + useEffect(() => { + if (!element || seen) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setSeen(true); + }, + { rootMargin } + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element, seen, rootMargin]); + + return [setElement, seen] as const; +} diff --git a/affiche-frontend/src/hooks/useItemArtwork.test.tsx b/affiche-frontend/src/hooks/useItemArtwork.test.tsx new file mode 100644 index 0000000..522c084 --- /dev/null +++ b/affiche-frontend/src/hooks/useItemArtwork.test.tsx @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { resetItemArtworkCache, useItemArtwork } from './useItemArtwork'; +import { postersApi } from '../api'; + +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal()), + postersApi: { getPosters: vi.fn() }, +})); + +const getPosters = vi.mocked(postersApi.getPosters); +const ALIEN = { mediaType: 'movie' as const, tmdbId: 348 }; +const POSTER = { url: 'https://img/a.jpg', provider: 'tmdb', rank: 0, rank_score: 1 }; + +beforeEach(() => { + resetItemArtworkCache(); + getPosters.mockReset(); +}); + +describe('useItemArtwork', () => { + it('asks nothing while held back', () => { + renderHook(() => useItemArtwork(ALIEN, false)); + + expect(getPosters).not.toHaveBeenCalled(); + }); + + it('reuses a recent answer for the same title instead of asking the providers again', async () => { + getPosters.mockResolvedValue([POSTER]); + const first = renderHook(() => useItemArtwork(ALIEN, true)); + await waitFor(() => expect(first.result.current.posters).toEqual([POSTER])); + first.unmount(); + + const again = renderHook(() => useItemArtwork(ALIEN, true)); + + expect(again.result.current.posters).toEqual([POSTER]); + expect(getPosters).toHaveBeenCalledTimes(1); + }); + + it('tries again after a failure rather than remembering it', async () => { + getPosters.mockRejectedValueOnce(new Error('TMDB is down')).mockResolvedValue([POSTER]); + const first = renderHook(() => useItemArtwork(ALIEN, true)); + await waitFor(() => expect(first.result.current.error).toBe('TMDB is down')); + first.unmount(); + + const again = renderHook(() => useItemArtwork(ALIEN, true)); + + await waitFor(() => expect(again.result.current.posters).toEqual([POSTER])); + expect(getPosters).toHaveBeenCalledTimes(2); + }); +}); diff --git a/affiche-frontend/src/hooks/useItemArtwork.ts b/affiche-frontend/src/hooks/useItemArtwork.ts new file mode 100644 index 0000000..91842b3 --- /dev/null +++ b/affiche-frontend/src/hooks/useItemArtwork.ts @@ -0,0 +1,62 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +import { errorMessage, postersApi } from '../api'; +import type { PosterCandidate } from '../types'; + +const FRESH_FOR_MS = 10 * 60 * 1000; + +type Entry = + | { status: 'loading' } + | { status: 'ready'; posters: PosterCandidate[]; fetchedAt: number } + | { status: 'error'; error: string }; + +const cache = new Map(); +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((listener) => listener()); + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export function resetItemArtworkCache() { + cache.clear(); +} + +interface ArtworkTarget { + mediaType: 'movie' | 'show'; + tmdbId?: number; + tvdbId?: number; +} + +function load(key: string, { mediaType, tmdbId, tvdbId }: ArtworkTarget) { + const entry = cache.get(key); + if (entry?.status === 'loading') return; + if (entry?.status === 'ready' && Date.now() - entry.fetchedAt < FRESH_FOR_MS) return; + + cache.set(key, { status: 'loading' }); + emit(); + postersApi + .getPosters({ media_type: mediaType, tmdb_id: tmdbId, tvdb_id: tvdbId }) + .then((posters) => cache.set(key, { status: 'ready', posters, fetchedAt: Date.now() })) + .catch((err) => cache.set(key, { status: 'error', error: errorMessage(err, 'Failed to fetch posters') })) + .finally(emit); +} + +export function useItemArtwork(target: ArtworkTarget, enabled: boolean) { + const { mediaType, tmdbId, tvdbId } = target; + const canBrowse = tmdbId !== undefined || tvdbId !== undefined; + const key = `${mediaType}:${tmdbId ?? ''}:${tvdbId ?? ''}`; + const entry = useSyncExternalStore(subscribe, () => cache.get(key)); + + useEffect(() => { + if (enabled && canBrowse) load(key, { mediaType, tmdbId, tvdbId }); + }, [enabled, canBrowse, key, mediaType, tmdbId, tvdbId]); + + if (!canBrowse) return { posters: [], isLoading: false, error: null }; + return { + posters: entry?.status === 'ready' ? entry.posters : [], + isLoading: entry === undefined || entry.status === 'loading', + error: entry?.status === 'error' ? entry.error : null, + }; +} diff --git a/affiche-frontend/src/hooks/useItemLock.test.tsx b/affiche-frontend/src/hooks/useItemLock.test.tsx new file mode 100644 index 0000000..0c0313c --- /dev/null +++ b/affiche-frontend/src/hooks/useItemLock.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { act, renderHook, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import { useItemLock } from './useItemLock'; +import { libraryApi } from '../api'; +import { ToastProvider } from '../context/ToastContext'; +import type { Library, LibraryItem } from '../types'; + +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal()), + libraryApi: { setItemLock: vi.fn() }, +})); + +const ITEM = { id: 1, library_id: 2, title: 'Alien', locked: false } as LibraryItem; +const LIBRARIES = [{ id: 2, media_server_id: 9 } as Library]; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +describe('useItemLock', () => { + it('says what a lock does once it is set, and what unlocking gives back', async () => { + const setLock = vi.mocked(libraryApi.setItemLock); + const { result } = renderHook( + () => useItemLock({ allLibraries: LIBRARIES, setItems: vi.fn() }), + { wrapper } + ); + + setLock.mockResolvedValueOnce({ ...ITEM, locked: true }); + await act(() => result.current.toggle(ITEM)); + expect(screen.getByText('Poster generation will skip "Alien" until it is unlocked.')).toBeInTheDocument(); + + setLock.mockResolvedValueOnce({ ...ITEM, locked: false }); + await act(() => result.current.toggle({ ...ITEM, locked: true })); + expect(screen.getByText('Poster generation can replace the poster for "Alien" again.')).toBeInTheDocument(); + }); +}); diff --git a/affiche-frontend/src/hooks/useItemLock.ts b/affiche-frontend/src/hooks/useItemLock.ts index e30fd22..4732c13 100644 --- a/affiche-frontend/src/hooks/useItemLock.ts +++ b/affiche-frontend/src/hooks/useItemLock.ts @@ -43,6 +43,13 @@ export function useItemLock({ allLibraries, setItems, onLockChanged }: UseItemLo ) ); onLockChanged?.(); + + toast.info( + updated.locked + ? `Poster generation will skip "${item.title}" until it is unlocked.` + : `Poster generation can replace the poster for "${item.title}" again.`, + { title: updated.locked ? 'Locked' : 'Unlocked' } + ); } catch (error) { toast.error(errorMessage(error, 'Failed to change the lock on this item.'), { title: 'Lock failed', diff --git a/affiche-frontend/src/hooks/useItemNeighbours.test.tsx b/affiche-frontend/src/hooks/useItemNeighbours.test.tsx new file mode 100644 index 0000000..8e2ab5f --- /dev/null +++ b/affiche-frontend/src/hooks/useItemNeighbours.test.tsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useItemNeighbours } from './useItemNeighbours'; +import { libraryApi } from '../api'; +import type { Library, LibraryItem } from '../types'; + +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal()), + libraryApi: { getLibraryItemIds: vi.fn(), getItem: vi.fn() }, +})); + +const FILMS = { id: 2, media_server_id: 1, name: 'Films', library_type: 'movie' } as Library; +const film = (id: number, title: string) => ({ id, library_id: 2, title }) as LibraryItem; +const [ALIEN, BRAZIL, HEAT, ZODIAC] = [film(1, 'Alien'), film(2, 'Brazil'), film(3, 'Heat'), film(4, 'Zodiac')]; +const LISTING = { search: 'a', sort: { by: 'year', dir: 'desc' as const } }; + +const ids = vi.mocked(libraryApi.getLibraryItemIds); +const getItem = vi.mocked(libraryApi.getItem); + +beforeEach(() => { + ids.mockReset().mockResolvedValue([1, 2, 3, 4]); + getItem.mockReset().mockImplementation(async (_s, _l, id) => [ALIEN, BRAZIL, HEAT, ZODIAC][id - 1]); +}); + +const run = (item: LibraryItem, items: LibraryItem[], hasMore: boolean, library: Library | null = FILMS) => + renderHook(() => useItemNeighbours({ + library: library ?? undefined, item, items, hasMore, listing: LISTING, enabled: true, + })); + +describe('useItemNeighbours', () => { + it('answers from the loaded items when they hold both neighbours', () => { + const { result } = run(BRAZIL, [ALIEN, BRAZIL, HEAT], true); + + expect(result.current).toEqual({ previous: ALIEN, next: HEAT }); + expect(ids).not.toHaveBeenCalled(); + }); + + it('finds the next item beyond the last loaded one, in the listing order', async () => { + const { result } = run(HEAT, [ALIEN, BRAZIL, HEAT], true); + + await waitFor(() => expect(result.current.next).toEqual(ZODIAC)); + expect(result.current.previous).toEqual(BRAZIL); + expect(ids).toHaveBeenCalledWith(1, 2, expect.objectContaining({ search: 'a', sortBy: 'year', sortDir: 'desc' })); + }); + + it('gives a deep-linked item both neighbours', async () => { + const { result } = run(HEAT, [], true); + + await waitFor(() => expect(result.current).toEqual({ previous: BRAZIL, next: ZODIAC })); + }); + + it('keeps to the loaded items in All libraries, which has no single listing to ask', () => { + const { result } = run(HEAT, [ALIEN, BRAZIL, HEAT], true, null); + + expect(result.current).toEqual({ previous: BRAZIL, next: undefined }); + expect(ids).not.toHaveBeenCalled(); + }); +}); diff --git a/affiche-frontend/src/hooks/useItemNeighbours.ts b/affiche-frontend/src/hooks/useItemNeighbours.ts new file mode 100644 index 0000000..eab3c31 --- /dev/null +++ b/affiche-frontend/src/hooks/useItemNeighbours.ts @@ -0,0 +1,63 @@ +import { useEffect, useEffectEvent, useState } from 'react'; + +import { libraryApi } from '../api'; +import { neighbourIds, neighbours } from '../components/library/selection'; +import type { ItemStatusFilter, Library, LibraryItem, SortState } from '../types'; + +interface UseItemNeighboursOptions { + library?: Library; + item: LibraryItem | null; + items: LibraryItem[]; + hasMore: boolean; + listing: { search?: string; status?: ItemStatusFilter; provider?: string; sort: SortState }; + enabled: boolean; +} + +interface Resolved { + + key: string; + previous?: LibraryItem; + next?: LibraryItem; +} + +export function useItemNeighbours({ library, item, items, hasMore, listing, enabled }: UseItemNeighboursOptions) { + const [resolved, setResolved] = useState(null); + + const local = enabled && item ? neighbours(items, item) : {}; + const isLoaded = !!item && items.some((i) => i.id === item.id && i.library_id === item.library_id); + const needsListing = enabled && !!item && !!library && (!isLoaded || (!local.next && hasMore)); + + const listingKey = `${listing.search ?? ''}|${listing.status ?? ''}|${listing.provider ?? ''}|${listing.sort.by}|${listing.sort.dir}`; + + const key = `${item?.library_id}|${item?.id}|${listingKey}`; + + const resolve = useEffectEvent(async () => { + const lib = library; + const open = item; + if (!lib || !open) return; + const requestKey = key; + try { + const ids = await libraryApi.getLibraryItemIds(lib.media_server_id, lib.id, { + search: listing.search, status: listing.status, provider: listing.provider, + sortBy: listing.sort.by, sortDir: listing.sort.dir, + }); + const around = neighbourIds(ids, open.id); + const fetchOne = async (id?: number) => { + if (id === undefined) return undefined; + return items.find((i) => i.id === id && i.library_id === lib.id) + ?? await libraryApi.getItem(lib.media_server_id, lib.id, id); + }; + const [previous, next] = await Promise.all([fetchOne(around.previous), fetchOne(around.next)]); + setResolved({ key: requestKey, previous, next }); + } catch {} + }); + + useEffect(() => { + if (needsListing) void resolve(); + }, [needsListing, key]); + + if (needsListing && resolved?.key === key) { + return { previous: resolved.previous ?? local.previous, next: resolved.next ?? local.next }; + } + return local; +} diff --git a/affiche-frontend/src/hooks/useItemSelection.ts b/affiche-frontend/src/hooks/useItemSelection.ts index e893a51..2dd3be5 100644 --- a/affiche-frontend/src/hooks/useItemSelection.ts +++ b/affiche-frontend/src/hooks/useItemSelection.ts @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { errorMessage, libraryApi } from '../api'; import { useToast } from '../context/ToastContext'; -import { emptySelection, pruneSelection, toggleAll, toggleId } from '../components/library/selection'; +import { emptySelection, pruneSelection, selectRange, toggleAll, toggleId } from '../components/library/selection'; import type { LibraryItem } from '../types'; interface UseItemSelectionOptions { @@ -14,6 +14,8 @@ interface UseItemSelectionOptions { onTaskStarted: (taskId: string) => void; refreshListing: (silent?: boolean) => void; + + listingKey: string; } export function useItemSelection({ @@ -21,17 +23,28 @@ export function useItemSelection({ mediaServerId, onTaskStarted, refreshListing, + listingKey, }: UseItemSelectionOptions) { const toast = useToast(); const [selected, setSelected] = useState>(emptySelection); const [isSelectMode, setIsSelectMode] = useState(false); const [isBusy, setIsBusy] = useState(false); + const [allMatching, setAllMatching] = useState<{ key: string; ids: ReadonlySet } | null>(null); + const anchor = useRef(null); useEffect(() => { setSelected((prev) => pruneSelection(prev, items)); }, [items]); - const ids = [...selected]; + const matching = allMatching?.key === listingKey ? allMatching.ids : null; + const current = matching ?? selected; + const ids = [...current]; + + const edit = (change: (prev: ReadonlySet) => ReadonlySet) => { + const base = matching ?? selected; + setAllMatching(null); + setSelected(change(base)); + }; const run = async (action: (itemIds: number[]) => Promise) => { if (!mediaServerId || ids.length === 0) return; @@ -40,6 +53,7 @@ export function useItemSelection({ await action(ids); setSelected(emptySelection()); + setAllMatching(null); setIsSelectMode(false); } finally { setIsBusy(false); @@ -77,24 +91,35 @@ export function useItemSelection({ }); return { - selected, - count: selected.size, + selected: current, + count: current.size, + isAllMatching: matching !== null, isBusy, isSelectMode, - isSelected: (id: number) => selected.has(id), - toggle: (id: number) => { - setSelected((prev) => toggleId(prev, id)); + isSelected: (id: number) => current.has(id), + + toggle: (id: number, extend = false) => { + edit((prev) => (extend ? selectRange(prev, items, anchor.current, id) : toggleId(prev, id))); + anchor.current = id; + setIsSelectMode(true); + }, + toggleAll: () => edit((prev) => toggleAll(prev, items)), + selectAllMatching: (matchingIds: number[]) => { + setAllMatching({ key: listingKey, ids: new Set(matchingIds) }); setIsSelectMode(true); }, - toggleAll: () => setSelected((prev) => toggleAll(prev, items)), toggleSelectMode: () => setIsSelectMode((prev) => { - if (prev) setSelected(emptySelection()); + if (prev) { + setSelected(emptySelection()); + setAllMatching(null); + } return !prev; }), clear: () => { setSelected(emptySelection()); + setAllMatching(null); setIsSelectMode(false); }, generate: () => diff --git a/affiche-frontend/src/hooks/useLibraryCoverage.ts b/affiche-frontend/src/hooks/useLibraryCoverage.ts new file mode 100644 index 0000000..b7d4c99 --- /dev/null +++ b/affiche-frontend/src/hooks/useLibraryCoverage.ts @@ -0,0 +1,33 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; + +import { dashboardApi } from '../api'; +import type { ItemStats } from '../types'; + +interface UseLibraryCoverageOptions { + libraryId?: number; + enabled: boolean; + + refreshKey?: number; +} + +export function useLibraryCoverage({ libraryId, enabled, refreshKey = 0 }: UseLibraryCoverageOptions) { + const [loaded, setLoaded] = useState<{ libraryId: number; stats: ItemStats } | null>(null); + const latest = useRef(0); + + const load = useEffectEvent(async (id: number) => { + const request = ++latest.current; + try { + const summary = await dashboardApi.getSummary(); + if (request !== latest.current) return; + const row = summary.libraries.find((library) => library.library_id === id); + setLoaded(row ? { libraryId: id, stats: row.stats } : null); + } catch {} + }); + + useEffect(() => { + if (!enabled || libraryId === undefined) return; + void load(libraryId); + }, [enabled, libraryId, refreshKey]); + + return loaded && loaded.libraryId === libraryId ? loaded.stats : undefined; +} diff --git a/affiche-frontend/src/hooks/useLibraryItemCounts.ts b/affiche-frontend/src/hooks/useLibraryItemCounts.ts index 072144f..5f3b02c 100644 --- a/affiche-frontend/src/hooks/useLibraryItemCounts.ts +++ b/affiche-frontend/src/hooks/useLibraryItemCounts.ts @@ -43,6 +43,8 @@ function sum(all: LibraryItemCounts[]): LibraryItemCounts { unprocessed: acc.unprocessed + c.unprocessed, errors: acc.errors + c.errors, locked: acc.locked + c.locked, + ready: (acc.ready ?? 0) + (c.ready ?? 0), + uploaded: (acc.uploaded ?? 0) + (c.uploaded ?? 0), providers: acc.providers, }), { ...ZERO, providers: sumProviders(all) } diff --git a/affiche-frontend/src/hooks/useLibraryRows.ts b/affiche-frontend/src/hooks/useLibraryRows.ts index eb92e6c..7432250 100644 --- a/affiche-frontend/src/hooks/useLibraryRows.ts +++ b/affiche-frontend/src/hooks/useLibraryRows.ts @@ -1,8 +1,8 @@ import { useEffect, useEffectEvent, useRef, useState } from 'react'; -import { errorMessage, libraryApi } from '../api'; +import { dashboardApi, errorMessage, libraryApi } from '../api'; import { useToast } from '../context/ToastContext'; -import type { Library, LibraryItem } from '../types'; +import type { ItemStats, Library, LibraryItem } from '../types'; export const ROW_SIZE = 20; @@ -11,6 +11,8 @@ export interface LibraryRow { items: LibraryItem[]; total: number; + + stats?: ItemStats; } interface UseLibraryRowsOptions { @@ -37,16 +39,22 @@ export function useLibraryRows({ const mine = ++request.current; setIsLoading(true); try { - const pages = await Promise.all(libraries.map((library) => - libraryApi.getLibraryItems(library.media_server_id, library.id, { - page: 0, pageSize: ROW_SIZE, sortBy: 'added_at', sortDir: 'desc', - }))); + + const [pages, summary] = await Promise.all([ + Promise.all(libraries.map((library) => + libraryApi.getLibraryItems(library.media_server_id, library.id, { + page: 0, pageSize: ROW_SIZE, sortBy: 'added_at', sortDir: 'desc', + }))), + dashboardApi.getSummary().catch(() => null), + ]); if (mine !== request.current) return; + const statsByLibrary = new Map(summary?.libraries.map((row) => [row.library_id, row.stats])); setRows(libraries.map((library, index) => ({ library, items: pages[index].items, total: pages[index].total, + stats: statsByLibrary.get(library.id), }))); } catch (error) { if (mine !== request.current) return; diff --git a/affiche-frontend/src/hooks/useLibraryUploadSetting.ts b/affiche-frontend/src/hooks/useLibraryUploadSetting.ts new file mode 100644 index 0000000..f13c343 --- /dev/null +++ b/affiche-frontend/src/hooks/useLibraryUploadSetting.ts @@ -0,0 +1,33 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; + +import { libraryApi } from '../api'; +import type { Library } from '../types'; + +interface UseLibraryUploadSettingOptions { + library?: Library; + enabled: boolean; +} + +export function useLibraryUploadSetting({ library, enabled }: UseLibraryUploadSettingOptions) { + const [loaded, setLoaded] = useState<{ libraryId: number; uploads: boolean } | null>(null); + const latest = useRef(0); + + const libraryId = library?.id; + const mediaServerId = library?.media_server_id; + + const load = useEffectEvent(async (serverId: number, id: number) => { + const request = ++latest.current; + try { + const settings = await libraryApi.getLibrarySettings(serverId, id); + if (request !== latest.current) return; + setLoaded({ libraryId: id, uploads: !!settings.upload_enabled }); + } catch {} + }); + + useEffect(() => { + if (!enabled || libraryId === undefined || mediaServerId === undefined) return; + void load(mediaServerId, libraryId); + }, [enabled, libraryId, mediaServerId]); + + return loaded && loaded.libraryId === libraryId ? loaded.uploads : undefined; +} diff --git a/affiche-frontend/src/hooks/useMediaQuery.ts b/affiche-frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..e20dc07 --- /dev/null +++ b/affiche-frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from 'react'; + +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (onChange) => { + const list = window.matchMedia?.(query); + list?.addEventListener('change', onChange); + return () => list?.removeEventListener('change', onChange); + }, + () => window.matchMedia?.(query).matches ?? false, + ); +} diff --git a/affiche-frontend/src/hooks/useMenuKeyboard.ts b/affiche-frontend/src/hooks/useMenuKeyboard.ts new file mode 100644 index 0000000..c5547bc --- /dev/null +++ b/affiche-frontend/src/hooks/useMenuKeyboard.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, type KeyboardEvent } from 'react'; + +import { menuFocusIndex } from '../components/common/menuKeys'; + +const ITEM_SELECTOR = '[role="menuitem"]'; + +export function useMenuKeyboard( + isOpen: boolean, + close: () => void +) { + const menuRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const first = [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) ?? [])] + .find((item) => !item.disabled); + first?.focus(); + }, [isOpen]); + + const onKeyDown = (event: KeyboardEvent) => { + const items = [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) ?? [])]; + if (event.key === 'Escape' || event.key === 'Tab') { + if (event.key === 'Escape') event.preventDefault(); + close(); + triggerRef.current?.focus(); + return; + } + const current = items.findIndex((item) => item === document.activeElement); + const next = menuFocusIndex(event.key, current, items.map((item) => !item.disabled)); + if (next === null) return; + event.preventDefault(); + items[next].focus(); + }; + + return { menuRef, triggerRef, onKeyDown }; +} diff --git a/affiche-frontend/src/hooks/usePosterBrowser.ts b/affiche-frontend/src/hooks/usePosterBrowser.ts index e542d35..7f78d77 100644 --- a/affiche-frontend/src/hooks/usePosterBrowser.ts +++ b/affiche-frontend/src/hooks/usePosterBrowser.ts @@ -26,6 +26,7 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr const [season, setSeason] = useState(null); const [isSaving, setIsSaving] = useState(false); const [uploadDefault, setUploadDefault] = useState(false); + const [initialPoster, setInitialPoster] = useState(undefined); const loadUploadDefault = useCallback(async (libraryId: number) => { if (mediaServerId == null) { @@ -40,9 +41,10 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr } }, [mediaServerId]); - const open = useCallback(async (forSeason: ItemSeason | null) => { + const open = useCallback(async (forSeason: ItemSeason | null, poster?: string) => { if (item) await loadUploadDefault(item.library_id); setSeason(forSeason); + setInitialPoster(poster); setIsOpen(true); }, [item, loadUploadDefault]); @@ -83,5 +85,5 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr } }, [item, mediaServerId, season, onApplied, toast]); - return { isOpen, season, isSaving, uploadDefault, open, close, save }; + return { isOpen, season, isSaving, uploadDefault, initialPoster, open, close, save }; } diff --git a/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts b/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts index f6ab670..762fc8e 100644 --- a/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts +++ b/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts @@ -104,4 +104,28 @@ describe('usePosterStyleDrafts', () => { expect(result.current.overlayOptions).toEqual({ border_enabled: true, blur_amount: 99 }); expect(result.current.quality).toBe(85); }); + + it('puts every edit back when a reset is undone', () => { + const { result } = renderHook(() => usePosterStyleDrafts(config())); + act(() => result.current.changeOverlay({ blur_amount: 20 })); + act(() => result.current.changeQuality(50)); + + let undo: (() => void) | null = null; + act(() => { undo = result.current.reset(); }); + act(() => undo!()); + + expect(result.current.overlayOptions).toEqual({ border_enabled: true, blur_amount: 20 }); + expect(result.current.textOptions).toEqual({ font_name: 'Serif.ttf', all_caps: false }); + expect(result.current.quality).toBe(50); + }); + + it('has nothing to undo when the style was never edited', () => { + + const { result } = renderHook(() => usePosterStyleDrafts(config())); + + let undo: (() => void) | null = () => {}; + act(() => { undo = result.current.reset(); }); + + expect(undo).toBeNull(); + }); }); diff --git a/affiche-frontend/src/hooks/usePosterStyleDrafts.ts b/affiche-frontend/src/hooks/usePosterStyleDrafts.ts index fe28834..0a3e471 100644 --- a/affiche-frontend/src/hooks/usePosterStyleDrafts.ts +++ b/affiche-frontend/src/hooks/usePosterStyleDrafts.ts @@ -27,10 +27,16 @@ export function usePosterStyleDrafts(posterConfig: PosterConfig | null) { }, changeQuality: setQualityDraft, - reset: () => { + reset: (): (() => void) | null => { + if (overlayDraft === null && textDraft === null && qualityDraft === null) return null; setOverlayDraft(null); setTextDraft(null); setQualityDraft(null); + return () => { + setOverlayDraft(overlayDraft); + setTextDraft(textDraft); + setQualityDraft(qualityDraft); + }; }, }; } diff --git a/affiche-frontend/src/hooks/useStagePoster.ts b/affiche-frontend/src/hooks/useStagePoster.ts new file mode 100644 index 0000000..02c600b --- /dev/null +++ b/affiche-frontend/src/hooks/useStagePoster.ts @@ -0,0 +1,76 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; + +import { libraryApi } from '../api'; +import { isNewArrival, newestWithPoster, type StagePoster } from '../components/library/stagePoster'; +import type { Library, LibraryItem } from '../types'; + +const DWELL_MS = 4000; +const NEWEST_PAGE = 12; + +interface UseStagePosterOptions { + + library?: Library; + + items: LibraryItem[]; + enabled: boolean; +} + +export function useStagePoster({ library, items, enabled }: UseStagePosterOptions) { + const [staged, setStaged] = useState(null); + const offer = useRef | null>(null); + + const libraryId = library?.id; + const mediaServerId = library?.media_server_id; + + useEffect(() => { + if (!enabled || libraryId === undefined || mediaServerId === undefined) return; + let cancelled = false; + const load = async () => { + try { + const page = await libraryApi.getLibraryItems(mediaServerId, libraryId, { + + pageSize: NEWEST_PAGE, sortBy: 'poster_generated_at', sortDir: 'desc', + }); + if (cancelled) return; + + setStaged((prev) => (prev?.reason === 'generated' && prev.libraryId === libraryId + ? prev + : newestWithPoster(page.items))); + } catch {} + }; + void load(); + return () => { cancelled = true; }; + }, [enabled, libraryId, mediaServerId]); + + const takeOffer = useEffectEvent(async () => { + const next = offer.current; + offer.current = null; + if (!next || mediaServerId === undefined || !isNewArrival(staged, next)) return; + + const listed = items.find((item) => item.id === next.itemId && item.library_id === next.libraryId); + let title = listed?.title; + if (!title) { + try { + title = (await libraryApi.getItem(mediaServerId, next.libraryId, next.itemId)).title; + } catch { + return; + } + } + setStaged({ ...next, title }); + }); + + useEffect(() => { + if (!enabled) return; + const timer = window.setInterval(() => { void takeOffer(); }, DWELL_MS); + return () => window.clearInterval(timer); + }, [enabled]); + + return { + + poster: staged?.libraryId === libraryId ? staged : null, + offerGenerated: (eventLibraryId: number, itemId: number, posterVersion?: string | null) => { + if (eventLibraryId !== libraryId || posterVersion == null) return; + offer.current = { libraryId: eventLibraryId, itemId, posterVersion, reason: 'generated' }; + }, + }; +} diff --git a/affiche-frontend/src/hooks/useTaskTracking.test.ts b/affiche-frontend/src/hooks/useTaskTracking.test.ts index b5e35a8..1411363 100644 --- a/affiche-frontend/src/hooks/useTaskTracking.test.ts +++ b/affiche-frontend/src/hooks/useTaskTracking.test.ts @@ -24,7 +24,7 @@ describe('taskKindFromName', () => { }); it('falls back to a bar-less kind for anything else', () => { - expect(taskKindFromName('poster_upload_2')).toBe('other'); + expect(taskKindFromName('poster_upload_2')).toBe('upload'); expect(taskKindFromName('something_new')).toBe('other'); expect(taskKindFromName(undefined)).toBe('other'); }); diff --git a/affiche-frontend/src/hooks/useTaskTracking.ts b/affiche-frontend/src/hooks/useTaskTracking.ts index 5a65a41..bfb4431 100644 --- a/affiche-frontend/src/hooks/useTaskTracking.ts +++ b/affiche-frontend/src/hooks/useTaskTracking.ts @@ -6,13 +6,19 @@ import type { TaskKind, TaskProgressState } from '../types'; export function taskKindFromName(name?: string): TaskKind { if (name?.startsWith('library_sync')) return 'sync'; if (name?.startsWith('poster_sync')) return 'generate'; + if (name?.startsWith('poster_upload')) return 'upload'; if (name?.startsWith('poster_reset')) return 'reset'; return 'other'; } +export interface TaskOutcome { + status: 'completed' | 'failed' | 'cancelled'; + kind: TaskKind; +} + interface UseTaskTrackingOptions { - onTaskFinished: () => void; + onTaskFinished: (outcome: TaskOutcome) => void; } export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) { @@ -27,7 +33,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) { const startTaskTracking = useCallback((taskId: string, message?: string, kind: TaskKind = 'other') => { currentTaskIdRef.current = taskId; setIsActionLoading(true); - setTaskMessage(message || 'Starting task...'); + setTaskMessage(message || 'Starting task…'); setTaskKind(kind); setTaskProgress(null); }, []); @@ -63,7 +69,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) { (taskId: string, status: string, taskName: string, message?: string, error?: string) => { if (currentTaskIdRef.current !== taskId) return; - setTaskMessage(message || `Task ${status}...`); + setTaskMessage(message || `Task ${status}…`); setTaskKind((prev) => prev ?? taskKindFromName(taskName)); if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') return; @@ -79,7 +85,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) { toast.error(error || 'Unknown error', { title: 'Task failed' }); } - onTaskFinished(); + onTaskFinished({ status: status as TaskOutcome['status'], kind: taskKindFromName(taskName) }); }, [onTaskFinished, toast] ); diff --git a/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx b/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx new file mode 100644 index 0000000..e672e84 --- /dev/null +++ b/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider, useNavigate } from 'react-router-dom'; + +import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'; + +function Editor({ dirty }: { dirty: boolean }) { + const navigate = useNavigate(); + const guard = useUnsavedChangesGuard(dirty, (from, to) => from.pathname === to.pathname); + return ( + <> + + + {guard.isAsking && ( + <> +

    Leave without saving?

    + + + + )} + + ); +} + +function renderAt(dirty: boolean) { + const router = createMemoryRouter( + [ + { path: '/settings', element: }, + { path: '/dashboard', element:

    Dashboard page

    }, + ], + { initialEntries: ['/settings'] }, + ); + render(); + return router; +} + +const click = (name: string) => act(() => screen.getByRole('button', { name }).click()); + +describe('useUnsavedChangesGuard', () => { + it('lets a clean editor go anywhere without asking', async () => { + const router = renderAt(false); + + await click('Dashboard'); + + expect(router.state.location.pathname).toBe('/dashboard'); + }); + + it('asks before leaving with unsaved edits, and stays when told to', async () => { + const router = renderAt(true); + + await click('Dashboard'); + expect(screen.getByText('Leave without saving?')).toBeInTheDocument(); + + await click('Keep editing'); + expect(router.state.location.pathname).toBe('/settings'); + expect(screen.queryByText('Leave without saving?')).not.toBeInTheDocument(); + }); + + it('leaves when the edits are discarded', async () => { + const router = renderAt(true); + + await click('Dashboard'); + await click('Discard changes'); + + expect(router.state.location.pathname).toBe('/dashboard'); + }); + + it('does not ask for a move that keeps the editor mounted', async () => { + const router = renderAt(true); + + await click('Fonts'); + + expect(router.state.location.search).toBe('?section=fonts'); + expect(screen.queryByText('Leave without saving?')).not.toBeInTheDocument(); + }); +}); diff --git a/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts b/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts new file mode 100644 index 0000000..5bd52e8 --- /dev/null +++ b/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { useBlocker, type Location } from 'react-router-dom'; + +export function useUnsavedChangesGuard( + hasUnsavedChanges: boolean, + isSameScreen: (from: Location, to: Location) => boolean = () => false, +) { + const blocker = useBlocker(({ currentLocation, nextLocation }) => + hasUnsavedChanges && !isSameScreen(currentLocation, nextLocation)); + + useEffect(() => { + if (!hasUnsavedChanges) return; + const warn = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [hasUnsavedChanges]); + + return { + isAsking: blocker.state === 'blocked', + stay: () => blocker.reset?.(), + leave: () => blocker.proceed?.(), + }; +} diff --git a/affiche-frontend/src/index.css b/affiche-frontend/src/index.css index f69d7a5..c7c2fee 100644 --- a/affiche-frontend/src/index.css +++ b/affiche-frontend/src/index.css @@ -2,6 +2,42 @@ --sidebar-width: 240px; --header-height: 56px; + + --text-micro: 0.6rem; + --text-2xs: 0.7rem; + --text-xs: 0.75rem; + --text-sm: 0.8rem; + --text-md: 0.85rem; + --text-base: 0.9rem; + --text-lg: 1rem; + --text-xl: 1.1rem; + --text-2xl: 1.25rem; + --text-3xl: 1.5rem; + + --radius-xs: 3px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-2xl: 12px; + --radius-pill: 999px; + + --z-rail: 5; + --z-sticky: 10; + --z-popover: 20; + --z-sidebar-rail: 99; + --z-sidebar: 100; + --z-sidebar-backdrop: 101; + --z-sidebar-overlay: 102; + --z-modal: 200; + --z-modal-elevated: 300; + --z-toast: 1000; +} + +@media (max-width: 699px) { + :root { + --header-height: 104px; + } } :root, @@ -20,21 +56,40 @@ --bg-hover: #3d3d3d; --bg-card: #2a2a2a; --text-primary: #f0f0f0; - --text-secondary: #9a9a9a; - --text-muted: #666666; - --accent: #5c9ded; + + --text-secondary: #ababab; + --text-muted: #9c9c9c; + --accent: #5f9fed; --accent-hover: #7ab3f5; - --success: #5cb85c; + --success: #65bc65; --warning: #f0ad4e; - --warning-hover: #d9932c; - --error: #d9534f; - --error-hover: #c33c38; + --error: #ec7b76; --border: #3a3a3a; + --accent-fill: #2f6fd0; + --accent-fill-hover: #245bb0; + --success-fill: #2e8540; + --success-fill-hover: #256b34; + --warning-fill: #a86400; + --warning-fill-hover: #874f00; + --error-fill: #c9443f; + --error-fill-hover: #a52020; + + --focus-ring: #7ab3f5; + --text-on-fill: #ffffff; --error-tint: rgba(217, 83, 79, 0.12); + --notice-error-edge: rgba(236, 123, 118, 0.5); + --notice-error-tint: rgba(236, 123, 118, 0.1); + --notice-warning-edge: rgba(240, 173, 78, 0.5); + --notice-warning-tint: rgba(240, 173, 78, 0.1); + --notice-success-edge: rgba(101, 188, 101, 0.5); + --notice-success-tint: rgba(101, 188, 101, 0.1); + --notice-info-edge: rgba(95, 159, 237, 0.5); + --notice-info-tint: rgba(95, 159, 237, 0.1); + --overlay-scrim: rgba(0, 0, 0, 0.5); --backdrop: rgba(0, 0, 0, 0.7); @@ -68,20 +123,37 @@ --bg-card: #ffffff; --text-primary: #1b1d21; --text-secondary: #5c6370; - --text-muted: #848b98; - - --accent: #2f6fd0; + --text-muted: #5f6672; + --accent: #2a64bb; --accent-hover: #245bb0; - --success: #2e7d32; - --warning: #a05f00; - --warning-hover: #874f00; - --error: #c62828; - --error-hover: #a52020; + --success: #2b742e; + --warning: #935700; + --error: #c32727; --border: #dcdfe4; + --accent-fill: #2f6fd0; + --accent-fill-hover: #245bb0; + --success-fill: #2e7d32; + --success-fill-hover: #256b34; + --warning-fill: #a05f00; + --warning-fill-hover: #874f00; + --error-fill: #c62828; + --error-fill-hover: #a52020; + + --focus-ring: #2f6fd0; + --text-on-fill: #ffffff; --error-tint: rgba(198, 40, 40, 0.1); + --notice-error-edge: rgba(198, 40, 40, 0.45); + --notice-error-tint: rgba(198, 40, 40, 0.07); + --notice-warning-edge: rgba(160, 95, 0, 0.45); + --notice-warning-tint: rgba(160, 95, 0, 0.08); + --notice-success-edge: rgba(46, 125, 50, 0.45); + --notice-success-tint: rgba(46, 125, 50, 0.07); + --notice-info-edge: rgba(47, 111, 208, 0.45); + --notice-info-tint: rgba(47, 111, 208, 0.07); + --overlay-scrim: rgba(0, 0, 0, 0.5); --backdrop: rgba(15, 18, 22, 0.45); --backdrop-elevated: rgba(15, 18, 22, 0.6); @@ -149,6 +221,20 @@ input, select { font-size: inherit; } +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + ::-webkit-scrollbar { width: 8px; height: 8px; @@ -160,7 +246,7 @@ input, select { ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); - border-radius: 4px; + border-radius: var(--radius-sm); } ::-webkit-scrollbar-thumb:hover { @@ -187,6 +273,15 @@ input, select { color: var(--error); } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .spin { animation: spin 0.8s linear infinite; } diff --git a/affiche-frontend/src/motion.ts b/affiche-frontend/src/motion.ts new file mode 100644 index 0000000..69a4ebe --- /dev/null +++ b/affiche-frontend/src/motion.ts @@ -0,0 +1,3 @@ +export function scrollBehavior(): ScrollBehavior { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; +} diff --git a/affiche-frontend/src/pages/AuthPage.module.css b/affiche-frontend/src/pages/AuthPage.module.css index 4412bfd..ed994f2 100644 --- a/affiche-frontend/src/pages/AuthPage.module.css +++ b/affiche-frontend/src/pages/AuthPage.module.css @@ -12,7 +12,7 @@ max-width: 380px; background-color: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 12px; + border-radius: var(--radius-2xl); padding: 32px; box-shadow: var(--shadow-xl); } @@ -27,7 +27,7 @@ } .brandText { - font-size: 1.25rem; + font-size: var(--text-2xl); font-weight: 600; letter-spacing: 0.26em; text-transform: uppercase; @@ -45,7 +45,7 @@ .subtitle { text-align: center; color: var(--text-secondary); - font-size: 0.9rem; + font-size: var(--text-base); margin-bottom: 24px; } @@ -62,7 +62,7 @@ } .label { - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary); } @@ -70,11 +70,11 @@ .input { width: 100%; padding: 10px 12px; - border-radius: 8px; + border-radius: var(--radius-lg); border: 1px solid var(--border); background-color: var(--bg-tertiary); color: var(--text-primary); - font-size: 0.9rem; + font-size: var(--text-base); transition: border-color 0.15s; } @@ -86,16 +86,16 @@ .submit { margin-top: 4px; padding: 11px 14px; - border-radius: 8px; - background-color: var(--accent); + border-radius: var(--radius-lg); + background-color: var(--accent-fill); color: var(--text-on-fill); - font-size: 0.9rem; + font-size: var(--text-base); font-weight: 600; transition: background-color 0.15s; } .submit:hover:not(:disabled) { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); } .submit:disabled { @@ -104,19 +104,20 @@ } .error { - background-color: var(--error-tint); - border: 1px solid var(--error); + background-color: var(--notice-error-tint); + border: 1px solid var(--notice-error-edge); color: var(--error); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 10px 12px; - font-size: 0.82rem; + font-size: var(--text-sm); } .notice { - border: 1px solid var(--warning); + border: 1px solid var(--notice-warning-edge); + background-color: var(--notice-warning-tint); color: var(--text-secondary); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 10px 12px; - font-size: 0.82rem; + font-size: var(--text-sm); line-height: 1.5; } diff --git a/affiche-frontend/src/pages/ChangePasswordPage.test.tsx b/affiche-frontend/src/pages/ChangePasswordPage.test.tsx index 92b669b..0e6d440 100644 --- a/affiche-frontend/src/pages/ChangePasswordPage.test.tsx +++ b/affiche-frontend/src/pages/ChangePasswordPage.test.tsx @@ -70,13 +70,24 @@ describe('ChangePasswordPage', () => { await waitFor(() => expect(changePassword).toHaveBeenCalledWith(TEMPORARY, CHOSEN)); }); - it('refuses a mistyped confirmation without asking the backend', async () => { + it('refuses a mistyped confirmation without asking the backend, and says so while typing', async () => { const user = userEvent.setup(); renderPage(); await fill(user, { confirm: 'something-else' }); - expect(await screen.findByText('Passwords do not match')).toBeInTheDocument(); + expect(screen.getByText('Both passwords match').closest('li')).toHaveTextContent('(not met yet)'); + expect(screen.getByRole('button', { name: 'Set password' })).toBeDisabled(); + expect(changePassword).not.toHaveBeenCalled(); + }); + + it('holds a new password shorter than the minimum back before submitting', async () => { + const user = userEvent.setup(); + renderPage(); + + await fill(user, { next: 'short', confirm: 'short' }); + + expect(screen.getByText('At least 8 characters').closest('li')).toHaveTextContent('(not met yet)'); expect(changePassword).not.toHaveBeenCalled(); }); diff --git a/affiche-frontend/src/pages/ChangePasswordPage.tsx b/affiche-frontend/src/pages/ChangePasswordPage.tsx index 65bbf89..3182ab9 100644 --- a/affiche-frontend/src/pages/ChangePasswordPage.tsx +++ b/affiche-frontend/src/pages/ChangePasswordPage.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react'; import { errorMessage } from '../api'; import { useAuth } from '../context/AuthContext'; -import { AfficheLogo } from '../components/common'; +import { AfficheLogo, PasswordRules, passwordRules } from '../components/common'; import styles from './AuthPage.module.css'; export function ChangePasswordPage() { @@ -11,6 +11,7 @@ export function ChangePasswordPage() { const [confirm, setConfirm] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const rules = passwordRules(password, confirm); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -85,7 +86,8 @@ export function ChangePasswordPage() { required />
    - diff --git a/affiche-frontend/src/pages/CollectionsPage.module.css b/affiche-frontend/src/pages/CollectionsPage.module.css index 6d0796a..5a670b6 100644 --- a/affiche-frontend/src/pages/CollectionsPage.module.css +++ b/affiche-frontend/src/pages/CollectionsPage.module.css @@ -13,13 +13,13 @@ } .breadcrumb { - font-size: 0.85rem; + font-size: var(--text-md); color: var(--text-muted); } .title { margin-top: 2px; - font-size: 1.5rem; + font-size: var(--text-3xl); font-weight: 600; color: var(--text-primary); } @@ -36,16 +36,20 @@ gap: 8px; padding: 7px 12px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-muted); background-color: var(--bg-secondary); } +.searchBox:focus-within { + border-color: var(--accent); +} + .searchBox input { width: 180px; border: none; outline: none; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); background: transparent; } @@ -56,19 +60,19 @@ align-items: center; gap: 6px; padding: 8px 14px; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; transition: background-color 0.15s, color 0.15s; } .primary { color: var(--text-on-fill); - background-color: var(--accent); + background-color: var(--accent-fill); } .primary:hover { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); } .secondary { @@ -105,14 +109,14 @@ } .empty h2 { - font-size: 1.1rem; + font-size: var(--text-xl); font-weight: 600; color: var(--text-primary); } .empty p { max-width: 44ch; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-secondary); } @@ -122,6 +126,6 @@ .taskStatus { margin: 4px 0 0; - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-secondary); } diff --git a/affiche-frontend/src/pages/CollectionsPage.test.tsx b/affiche-frontend/src/pages/CollectionsPage.test.tsx index 8aa1460..9b51cb4 100644 --- a/affiche-frontend/src/pages/CollectionsPage.test.tsx +++ b/affiche-frontend/src/pages/CollectionsPage.test.tsx @@ -124,6 +124,21 @@ describe('CollectionsPage', () => { expect(toast.error.mock.calls[0][0]).toMatch(/would not delete/i); }); + it('says that removing a member changes the collection on the media server', async () => { + vi.mocked(collectionsApi.getCollection).mockResolvedValue({ + ...collection(), + members: [{ id: 5, library_id: 2, title: 'Aliens', type: 'movie', processed: true, locked: false, has_poster: true }], + } as never); + + renderPage(); + fireEvent.click(await screen.findByText('Alien Saga')); + fireEvent.click(await screen.findByRole('button', { name: 'Remove Aliens from Alien Saga' })); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('on Plex? The change is made on the server straight away.'); + expect(within(dialog).getByRole('button', { name: 'Remove on server' })).toBeInTheDocument(); + }); + it('reports how many members the media server holds beyond what Affiche synced', async () => { getCollections.mockResolvedValue({ collections: [collection({ member_count: 2, child_count: 7 })], @@ -170,13 +185,13 @@ describe('CollectionsPage', () => { renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); - fireEvent.click(await screen.findByRole('button', { name: /select poster/i })); + fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByRole('heading', { name: /Alien Saga/ })).toBeInTheDocument(); }); - it('defaults the upload toggle to the library setting', async () => { + it('offers Save & upload as the default for a library that uploads', async () => { getCollections.mockResolvedValue({ collections: [collection()], total: 1, page: 0, page_size: 50, @@ -185,13 +200,13 @@ describe('CollectionsPage', () => { renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); - fireEvent.click(await screen.findByRole('button', { name: /select poster/i })); + fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); await screen.findByRole('dialog'); - expect(await screen.findByLabelText(/upload to library/i)).toBeChecked(); + expect(await screen.findByText(/This library uploads new posters/)).toBeInTheDocument(); }); - it('leaves the upload toggle off for a library that does not upload', async () => { + it('keeps Save as the default for a library that does not upload', async () => { getCollections.mockResolvedValue({ collections: [collection()], total: 1, page: 0, page_size: 50, }); @@ -201,10 +216,11 @@ describe('CollectionsPage', () => { renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); - fireEvent.click(await screen.findByRole('button', { name: /select poster/i })); + fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); await screen.findByRole('dialog'); - expect(await screen.findByLabelText(/upload to library/i)).not.toBeChecked(); + expect(await screen.findByText(/Save keeps the poster in Affiche\./)).toBeInTheDocument(); + expect(screen.queryByText(/This library uploads new posters/)).not.toBeInTheDocument(); }); it('does not browse providers for a collection no catalogue matched', async () => { @@ -216,7 +232,7 @@ describe('CollectionsPage', () => { renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); - fireEvent.click(await screen.findByRole('button', { name: /select poster/i })); + fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); await screen.findByRole('dialog'); expect(postersApi.getPosters).not.toHaveBeenCalled(); @@ -233,7 +249,7 @@ describe('CollectionsPage', () => { renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); - fireEvent.click(await screen.findByRole('button', { name: /select poster/i })); + fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); await screen.findByRole('dialog'); await waitFor(() => expect(postersApi.getCollectionPosters).toHaveBeenCalled()); diff --git a/affiche-frontend/src/pages/CollectionsPage.tsx b/affiche-frontend/src/pages/CollectionsPage.tsx index a876788..3b4692c 100644 --- a/affiche-frontend/src/pages/CollectionsPage.tsx +++ b/affiche-frontend/src/pages/CollectionsPage.tsx @@ -179,6 +179,7 @@ export function CollectionsPage({ value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search collections…" + aria-label="Search collections" />
    { @@ -217,7 +218,7 @@ export function CollectionsPage({ {search ? 'No collection matches that search.' : `Collection sync is off by default. Turn on "Track collections" for ${library.name} in - Settings → Media Servers, then sync the library — or create one here.`} + Settings → Media servers, then sync the library — or create one here.`}

    ) : ( @@ -242,7 +243,7 @@ export function CollectionsPage({ if (dialog.kind === 'match') { return ( void matchCollections()} @@ -321,8 +322,9 @@ export function CollectionsPage({ return ( { await collections.removeItems(dialog.collectionId, [dialog.item.id]); diff --git a/affiche-frontend/src/pages/DashboardPage.module.css b/affiche-frontend/src/pages/DashboardPage.module.css index 50f846c..9ae06ab 100644 --- a/affiche-frontend/src/pages/DashboardPage.module.css +++ b/affiche-frontend/src/pages/DashboardPage.module.css @@ -19,7 +19,7 @@ .empty { padding: 24px 0; text-align: left; - font-size: 0.9rem; + font-size: var(--text-base); } .error { @@ -27,7 +27,7 @@ align-items: center; gap: 8px; padding: 12px 16px; - border-radius: 8px; + border-radius: var(--radius-lg); color: var(--error); background-color: var(--bg-card); } @@ -41,14 +41,14 @@ } .title { - font-size: 1.5rem; + font-size: var(--text-3xl); font-weight: 600; color: var(--text-primary); } .subtitle { margin-top: 4px; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-secondary); } @@ -58,8 +58,8 @@ gap: 8px; padding: 8px 14px; border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); color: var(--text-primary); background-color: var(--bg-card); transition: background-color 0.15s; @@ -81,7 +81,7 @@ flex-direction: column; gap: 4px; padding: 16px; - border-radius: 8px; + border-radius: var(--radius-lg); background-color: var(--bg-card); } @@ -98,7 +98,7 @@ } .tileValue { - font-size: 1.5rem; + font-size: var(--text-3xl); font-weight: 600; color: var(--text-primary); @@ -106,7 +106,7 @@ } .tileLabel { - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-secondary); } @@ -115,14 +115,14 @@ } .sectionTitle { - font-size: 1rem; + font-size: var(--text-lg); font-weight: 600; color: var(--text-primary); } .sectionHint { margin: 2px 0 12px; - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-muted); } @@ -145,7 +145,7 @@ gap: 16px; width: 100%; padding: 12px 16px; - border-radius: 8px; + border-radius: var(--radius-lg); text-align: left; background-color: var(--bg-card); transition: background-color 0.15s; @@ -160,7 +160,7 @@ align-items: center; gap: 8px; min-width: 0; - font-size: 0.9rem; + font-size: var(--text-base); color: var(--text-primary); white-space: nowrap; overflow: hidden; @@ -168,7 +168,7 @@ } .libraryServer { - font-size: 0.8rem; + font-size: var(--text-sm); color: var(--text-muted); } @@ -181,7 +181,7 @@ .coverageTrack { flex: 1; height: 6px; - border-radius: 3px; + border-radius: var(--radius-xs); background-color: var(--bg-tertiary); overflow: hidden; } @@ -190,14 +190,14 @@ display: block; width: 100%; height: 100%; - background-color: var(--accent); + background-color: var(--accent-fill); transform-origin: left; transition: transform 0.2s ease; } .coverageValue { min-width: 3.5ch; - font-size: 0.82rem; + font-size: var(--text-sm); color: var(--text-secondary); font-variant-numeric: tabular-nums; text-align: right; @@ -207,7 +207,7 @@ display: flex; align-items: center; gap: 10px; - font-size: 0.8rem; + font-size: var(--text-sm); white-space: nowrap; } @@ -234,7 +234,7 @@ grid-template-columns: minmax(90px, auto) minmax(0, 1fr) auto; align-items: center; gap: 12px; - font-size: 0.85rem; + font-size: var(--text-md); } .providerName { @@ -246,7 +246,7 @@ .providerTrack { height: 8px; - border-radius: 4px; + border-radius: var(--radius-sm); background-color: var(--bg-tertiary); overflow: hidden; } @@ -254,8 +254,8 @@ .providerFill { display: block; height: 100%; - border-radius: 4px; - background-color: var(--accent); + border-radius: var(--radius-sm); + background-color: var(--accent-fill); } .providerCount { @@ -274,13 +274,13 @@ grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; - font-size: 0.85rem; + font-size: var(--text-md); } .taskStatus { padding: 2px 8px; - border-radius: 4px; - font-size: 0.72rem; + border-radius: var(--radius-sm); + font-size: var(--text-2xs); font-weight: 600; text-transform: capitalize; color: var(--text-secondary); diff --git a/affiche-frontend/src/pages/DashboardPage.tsx b/affiche-frontend/src/pages/DashboardPage.tsx index d925446..0d06a37 100644 --- a/affiche-frontend/src/pages/DashboardPage.tsx +++ b/affiche-frontend/src/pages/DashboardPage.tsx @@ -8,7 +8,7 @@ import type { DashboardLibrary, DashboardTask, ItemStats, MediaServerType, ProviderShare, } from '../types'; import { - byCoverageAscending, coveragePercent, providerBarPercent, taskLabel, taskTimestamp, + byCoverageAscending, coveragePercent, providerBarPercent, taskLabel, taskStatusLabel, taskTimestamp, } from './dashboardStats'; import styles from './DashboardPage.module.css'; @@ -135,9 +135,9 @@ export function DashboardPage({ onOpenLibrary }: DashboardPageProps) { function StatTiles({ totals }: { totals: ItemStats }) { const tiles = [ { label: 'Items', value: totals.total, icon: , tone: '' }, - { label: 'Posters generated', value: totals.processed, icon: , tone: styles.success }, - { label: 'Awaiting generation', value: totals.unprocessed, icon: , tone: '' }, - { label: 'Uploaded', value: totals.uploaded, icon: , tone: '' }, + { label: 'With a poster', value: totals.processed, icon: , tone: styles.success }, + { label: 'No poster yet', value: totals.unprocessed, icon: , tone: '' }, + { label: 'On server', value: totals.uploaded, icon: , tone: '' }, { label: 'Failed', value: totals.errors, icon: , tone: styles.danger }, { label: 'Locked', value: totals.locked, icon: , tone: '' }, ]; @@ -211,14 +211,14 @@ function RecentActivity({ tasks }: { tasks: DashboardTask[] }) { return (

    Recent activity

    -

    Since the server last started.

    +

    The latest runs, kept across restarts.

    {tasks.length === 0 ? (

    Nothing has run yet.

    ) : (
      {tasks.map((task) => (
    • - {task.status} + {taskStatusLabel(task.status)} {taskLabel(task)} {formatDateTime(taskTimestamp(task))}
    • diff --git a/affiche-frontend/src/pages/LibraryPage.module.css b/affiche-frontend/src/pages/LibraryPage.module.css index bff4c83..7eef870 100644 --- a/affiche-frontend/src/pages/LibraryPage.module.css +++ b/affiche-frontend/src/pages/LibraryPage.module.css @@ -13,14 +13,14 @@ .emptyContent h2 { color: var(--text-primary); - font-size: 1.5rem; + font-size: var(--text-3xl); font-weight: 600; margin-bottom: 12px; } .emptyContent p { color: var(--text-secondary); - font-size: 0.95rem; + font-size: var(--text-base); line-height: 1.5; margin-bottom: 8px; } @@ -28,18 +28,18 @@ .emptyButton { margin-top: 20px; padding: 10px 20px; - background-color: var(--accent); + background-color: var(--accent-fill); color: var(--text-on-fill); border: none; - border-radius: 6px; - font-size: 0.9rem; + border-radius: var(--radius-md); + font-size: var(--text-base); font-weight: 500; cursor: pointer; transition: background-color 0.15s; } .emptyButton:hover { - background-color: var(--accent-hover); + background-color: var(--accent-fill-hover); } .episodePane { @@ -53,10 +53,10 @@ margin: 0 24px 16px; padding: 10px 12px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-lg); background-color: var(--bg-secondary); color: var(--text-secondary); - font-size: 0.82rem; + font-size: var(--text-sm); line-height: 1.5; } diff --git a/affiche-frontend/src/pages/LibraryPage.test.tsx b/affiche-frontend/src/pages/LibraryPage.test.tsx index 5ef8611..349e2e8 100644 --- a/affiche-frontend/src/pages/LibraryPage.test.tsx +++ b/affiche-frontend/src/pages/LibraryPage.test.tsx @@ -1,10 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { LibraryPage } from './LibraryPage'; -import { libraryApi, tasksApi } from '../api'; +import { dashboardApi, libraryApi, tasksApi } from '../api'; import type { Library, LibraryItem } from '../types'; vi.mock('../api', () => ({ @@ -33,12 +33,19 @@ vi.mock('../api', () => ({ uploadItemPoster: vi.fn(), getItemWithSeasons: vi.fn(), getItem: vi.fn(), + getLibraryItemIds: vi.fn(), + getLibrarySettings: vi.fn(), + generateSelectedPosters: vi.fn(), + uploadSelectedPosters: vi.fn(), + resetSelectedPosters: vi.fn(), + setItemsLock: vi.fn(), }, tasksApi: { getRunningBlockingTask: vi.fn(), cancelTask: vi.fn(), }, postersApi: {}, + dashboardApi: { getSummary: vi.fn().mockRejectedValue(new Error('not needed here')) }, errorMessage: (error: unknown, fallback: string) => error instanceof Error && error.message ? error.message : fallback, @@ -112,6 +119,8 @@ beforeEach(() => { vi.mocked(libraryApi.getLibraryAlphaIndex).mockResolvedValue([]); vi.mocked(libraryApi.getLibraryItemCounts).mockResolvedValue({ total: 0, unprocessed: 0, errors: 0, locked: 0, providers: {} }); getRunningBlockingTask.mockResolvedValue(null); + vi.mocked(dashboardApi.getSummary).mockRejectedValue(new Error('not needed here')); + vi.mocked(libraryApi.getLibrarySettings).mockRejectedValue(new Error('not needed here')); }); afterEach(() => { @@ -182,7 +191,7 @@ describe('LibraryPage — task tracking', () => { renderPage(); - expect(await screen.findByText('Uploading posters...')).toBeInTheDocument(); + expect(await screen.findByText('Uploading… 30%')).toBeInTheDocument(); expect(stopButton()).toBeInTheDocument(); }); @@ -194,7 +203,7 @@ describe('LibraryPage — task tracking', () => { message: 'Uploading posters...', } as Awaited>); const { onRefreshLibraries } = renderPage(); - await screen.findByText('Uploading posters...'); + await screen.findByText(/Uploading…/); getLibraryItems.mockClear(); act(() => handlers.onTaskStatus?.('task-1', 'completed', 'poster_upload_2')); @@ -204,6 +213,44 @@ describe('LibraryPage — task tracking', () => { expect(getLibraryItems).toHaveBeenCalled(); }); + it('ends a finished generation run with a summary that leads to the failed items', async () => { + getRunningBlockingTask.mockResolvedValue({ + task_id: 'task-1', + status: 'running', + task_name: 'poster_sync_2', + message: 'Generating posters...', + } as Awaited>); + toast.success.mockClear(); + renderPage(); + await screen.findByText('Generating posters...'); + vi.mocked(libraryApi.getLibraryItemCounts) + .mockResolvedValue({ total: 10, unprocessed: 0, errors: 2, locked: 0, providers: {} }); + + act(() => handlers.onTaskStatus?.('task-1', 'completed', 'poster_sync_2')); + + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const [message, options] = toast.success.mock.calls[0]; + expect(message).toContain('2 items failed'); + expect(options).toMatchObject({ title: 'Posters generated', action: { label: 'Show failed items' } }); + }); + + it('does not announce a run that failed or was cancelled as finished', async () => { + getRunningBlockingTask.mockResolvedValue({ + task_id: 'task-1', + status: 'running', + task_name: 'poster_sync_2', + message: 'Generating posters...', + } as Awaited>); + toast.success.mockClear(); + renderPage(); + await screen.findByText('Generating posters...'); + + act(() => handlers.onTaskStatus?.('task-1', 'cancelled', 'poster_sync_2')); + + await waitFor(() => expect(stopButton()).not.toBeInTheDocument()); + expect(toast.success).not.toHaveBeenCalled(); + }); + it('ignores status for a task it is not tracking', async () => { getRunningBlockingTask.mockResolvedValue({ task_id: 'task-1', @@ -212,7 +259,7 @@ describe('LibraryPage — task tracking', () => { message: 'Uploading posters...', } as Awaited>); renderPage(); - await screen.findByText('Uploading posters...'); + await screen.findByText(/Uploading…/); act(() => handlers.onTaskStatus?.('someone-elses-task', 'completed', 'poster_upload_2')); @@ -242,6 +289,22 @@ describe('LibraryPage — listing', () => { expect(screen.getByRole('heading', { name: /Home/ })).toBeInTheDocument(); }); + it('labels each row with its poster coverage from the one summary request', async () => { + const stats = { total: 40, processed: 30, unprocessed: 8, errors: 2, locked: 0, uploaded: 25 }; + vi.mocked(dashboardApi.getSummary).mockResolvedValueOnce({ + libraries: [{ library_id: 2, library_name: 'Movies', library_type: 'movie', enabled: true, + media_server_id: 1, media_server_name: 'Plex', media_server_type: 'plex', stats }], + } as unknown as Awaited>); + + renderPage({ libraries: [MOVIES, SHOWS], allLibraries: [MOVIES, SHOWS], selectedLibraryId: undefined }); + + expect(await screen.findByText('75%')).toBeInTheDocument(); + expect(screen.getByText('30 of 40 have a poster · 2 failed')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /^Shows/ })).toBeInTheDocument(); + expect(screen.getAllByText(/have a poster/)).toHaveLength(1); + }); + it('asks each library only for the newest page, not for a listing behind the rows', async () => { renderPage({ libraries: [MOVIES, SHOWS], allLibraries: [MOVIES, SHOWS], selectedLibraryId: undefined }); @@ -282,6 +345,96 @@ describe('LibraryPage — listing', () => { }); }); +describe('LibraryPage — the poster stage', () => { + it('stages the most recently generated poster while nothing runs', async () => { + getLibraryItems.mockImplementation(async (_server, _library, options) => + options?.sortBy === 'poster_generated_at' + ? page([item({ id: 20, title: 'Blank' }), item({ id: 21, title: 'Heat', processed: true, has_poster: true, poster_version: 'h1' })]) + : page([item({ id: 10, title: 'Alien' })]) + ); + renderPage(); + + expect(await screen.findByText('Latest poster in Movies')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Heat' })).toBeInTheDocument(); + }); + + it('leads the library with its coverage, even before any item has a poster', async () => { + const stats = { total: 10, processed: 5, unprocessed: 4, errors: 1, locked: 0, uploaded: 3 }; + vi.mocked(dashboardApi.getSummary).mockResolvedValueOnce({ + libraries: [{ library_id: 2, library_name: 'Movies', library_type: 'movie', enabled: true, + media_server_id: 1, media_server_name: 'Plex', media_server_type: 'plex', stats }], + } as unknown as Awaited>); + + renderPage(); + + expect(await screen.findByText('50%')).toBeInTheDocument(); + expect(screen.getByText('5 of 10 have a poster · 1 failed')).toBeInTheDocument(); + expect(screen.queryByText(/Latest poster in/)).not.toBeInTheDocument(); + }); + + it('shows no stage when there is neither a poster nor coverage to show', async () => { + renderPage(); + await screen.findByText('Alien'); + + expect(screen.queryByRole('region', { name: /poster stage/ })).not.toBeInTheDocument(); + }); + + it('names the pending count on the generate button once it is counted', async () => { + vi.mocked(libraryApi.getLibraryItemCounts) + .mockResolvedValue({ total: 10, unprocessed: 4, errors: 0, locked: 0, providers: {} }); + + renderPage(); + + expect(await screen.findByRole('button', { name: /Generate 4 posters/ })).toBeInTheDocument(); + }); +}); + +describe('LibraryPage — working through a large library', () => { + it('selects every match, not only the loaded page, and acts on all of them', async () => { + getLibraryItems.mockResolvedValue(page([item({ id: 10, title: 'Alien' }), item({ id: 11, title: 'Heat' })], 3000)); + vi.mocked(libraryApi.getLibraryItemIds).mockResolvedValue(Array.from({ length: 3000 }, (_, i) => i + 1)); + vi.mocked(libraryApi.uploadSelectedPosters).mockResolvedValue({ task_id: 't' } as never); + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByLabelText('Select Alien')); + await user.click(screen.getByRole('button', { name: /Select all 3,000 matching/ })); + + expect(await screen.findByText('All 3,000 matching selected')).toBeInTheDocument(); + expect(libraryApi.getLibraryItemIds).toHaveBeenCalledWith(1, 2, { search: undefined, status: undefined, provider: undefined }); + + await user.click(within(screen.getByRole('region', { name: 'Selection actions' })).getByRole('button', { name: /Upload/ })); + await user.click(await screen.findByRole('button', { name: 'Upload 3000' })); + await waitFor(() => expect(libraryApi.uploadSelectedPosters).toHaveBeenCalled()); + expect(vi.mocked(libraryApi.uploadSelectedPosters).mock.calls[0][1]).toHaveLength(3000); + }); + + it('extends a selection with shift-click', async () => { + getLibraryItems.mockResolvedValue(page([ + item({ id: 10, title: 'Alien' }), item({ id: 11, title: 'Blade Runner' }), item({ id: 12, title: 'Heat' }), + ])); + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByLabelText('Select Alien')); + await user.keyboard('{Shift>}'); + await user.click(screen.getByLabelText('Select Heat')); + await user.keyboard('{/Shift}'); + + expect(await screen.findByText('3 selected')).toBeInTheDocument(); + }); + + it('says a generate run will upload when the library uploads automatically', async () => { + vi.mocked(libraryApi.getLibrarySettings).mockResolvedValue({ upload_enabled: true } as never); + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByText('New posters go straight to Plex')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Generate posters/ })); + expect(await screen.findByRole('button', { name: 'Generate & upload' })).toBeInTheDocument(); + }); +}); + describe('LibraryPage — filter counts', () => { const getCounts = vi.mocked(libraryApi.getLibraryItemCounts); @@ -300,8 +453,8 @@ describe('LibraryPage — filter counts', () => { await openFilters(); expect(await filterRow(/^All items 1,234$/)).toBeInTheDocument(); - expect(await filterRow(/^Unprocessed 57$/)).toBeInTheDocument(); - expect(await filterRow(/^With errors 3$/)).toBeInTheDocument(); + expect(await filterRow(/^No poster yet 57$/)).toBeInTheDocument(); + expect(await filterRow(/^Failed 3$/)).toBeInTheDocument(); }); it('offers no filter controls on the home, which steers no listing', async () => { @@ -320,7 +473,7 @@ describe('LibraryPage — filter counts', () => { await openFilters(); expect(await filterRow(/^All items$/)).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'With errors' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Failed' })).toBeInTheDocument(); }); it('offers a provider bucket per provenance the library holds', async () => { @@ -349,7 +502,7 @@ describe('LibraryPage — filter counts', () => { const user = await openFilters(); await user.click(await filterRow(/^MediUX/)); - await user.click(await filterRow(/^Unprocessed/)); + await user.click(await filterRow(/^No poster yet/)); await waitFor(() => { expect(getLibraryItems).toHaveBeenLastCalledWith( @@ -366,11 +519,11 @@ describe('LibraryPage — filter counts', () => { await screen.findByText('Alien'); const user = await openFilters(); await user.click(await filterRow(/^MediUX/)); - await user.click(await filterRow(/^Unprocessed/)); + await user.click(await filterRow(/^No poster yet/)); await user.keyboard('{Escape}'); - expect(screen.getByTitle('Remove the Unprocessed filter')).toBeInTheDocument(); + expect(screen.getByTitle('Remove the No poster yet filter')).toBeInTheDocument(); expect(screen.getByTitle('Remove the MediUX filter')).toBeInTheDocument(); }); @@ -382,7 +535,7 @@ describe('LibraryPage — filter counts', () => { await screen.findByText('Alien'); const user = await openFilters(); await user.click(await filterRow(/^MediUX/)); - await user.click(await filterRow(/^Unprocessed/)); + await user.click(await filterRow(/^No poster yet/)); await user.keyboard('{Escape}'); await user.click(screen.getByTitle('Remove the MediUX filter')); @@ -405,13 +558,13 @@ describe('LibraryPage — filter counts', () => { getCounts.mockResolvedValue({ total: 10, unprocessed: 10, errors: 0, locked: 0, providers: {} }); renderPage(); await openFilters(); - await filterRow(/^Unprocessed 10$/); + await filterRow(/^No poster yet 10$/); getCounts.mockResolvedValue({ total: 10, unprocessed: 0, errors: 2, locked: 0, providers: {} }); act(() => handlers.onTaskStatus?.('task-1', 'completed', 'poster_sync_2')); - expect(await filterRow(/^Unprocessed 0$/)).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'With errors 2' })).toBeInTheDocument(); + expect(await filterRow(/^No poster yet 0$/)).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Failed 2' })).toBeInTheDocument(); }); }); @@ -428,14 +581,14 @@ describe('LibraryPage — actions that fail', () => { return utils; }; - const runMenuAction = async (item: string | RegExp, confirmLabel: string) => { + const runMenuAction = async (item: string | RegExp, confirmLabel?: string) => { const user = userEvent.setup(); const utils = renderPage(); await screen.findByText('Alien'); await user.click(screen.getByRole('button', { name: 'Library actions' })); await user.click(await screen.findByRole('menuitem', { name: item })); - await user.click(await screen.findByRole('button', { name: confirmLabel })); + if (confirmLabel) await user.click(await screen.findByRole('button', { name: confirmLabel })); return utils; }; @@ -443,7 +596,7 @@ describe('LibraryPage — actions that fail', () => { it('reports a library sync the backend refused, instead of going quiet', async () => { vi.mocked(libraryApi.syncLibrary).mockRejectedValue(new Error('Plex is unreachable')); - await runMenuAction(/Sync library/, 'Sync'); + await runMenuAction(/Sync library/); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Plex is unreachable', { title: 'Sync failed' }) @@ -453,7 +606,7 @@ describe('LibraryPage — actions that fail', () => { it('does not leave the header looking like a task is running', async () => { vi.mocked(libraryApi.syncLibrary).mockRejectedValue(new Error('nope')); - await runMenuAction(/Sync library/, 'Sync'); + await runMenuAction(/Sync library/); await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(stopButton()).toBeNull(); @@ -462,7 +615,7 @@ describe('LibraryPage — actions that fail', () => { it('reports a generation that never started', async () => { vi.mocked(libraryApi.syncLibraryPosters).mockRejectedValue(new Error('No provider configured')); - await runHeaderAction(/Generate Posters/, 'Generate'); + await runHeaderAction(/Generate posters/, 'Generate'); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No provider configured', { @@ -474,7 +627,7 @@ describe('LibraryPage — actions that fail', () => { it('falls back to a readable sentence when the rejection carries no message', async () => { vi.mocked(libraryApi.syncLibrary).mockRejectedValue('boom'); - await runMenuAction(/Sync library/, 'Sync'); + await runMenuAction(/Sync library/); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not start the library sync.', { @@ -489,8 +642,8 @@ describe('LibraryPage — actions that fail', () => { renderPage(); await user.click(await screen.findByText('Alien')); - await user.click(await screen.findByRole('button', { name: /Generate Poster/ })); - await user.click(await screen.findByRole('button', { name: 'Generate' })); + + await user.click(await screen.findByRole('button', { name: /^Generate poster$/i })); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Poster source is down', { diff --git a/affiche-frontend/src/pages/LibraryPage.tsx b/affiche-frontend/src/pages/LibraryPage.tsx index 6088784..0ae3592 100644 --- a/affiche-frontend/src/pages/LibraryPage.tsx +++ b/affiche-frontend/src/pages/LibraryPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { Info } from 'lucide-react'; import { Header } from '../components/layout'; -import { ItemGrid, ItemTable, ItemDetail, EpisodeList, LibraryRows, PosterBrowserModal, AlphabetIndex, SelectionBar, posterTargetFromItem } from '../components/library'; +import { ItemGrid, ItemTable, ItemDetail, EpisodeList, LibraryRows, LibraryStage, PosterBrowserModal, AlphabetIndex, SelectionBar, posterTargetFromItem, type StagePoster } from '../components/library'; import { ConfirmModal } from '../components/common'; import { errorMessage, libraryApi } from '../api'; import { @@ -10,19 +10,24 @@ import { useEventStream, useItemLock, useItemSelection, + useLibraryCoverage, useLibraryItemCounts, useLibraryItems, + useItemNeighbours, useLibraryListing, useLibraryRows, + useLibraryUploadSetting, useOpenItem, usePosterBrowser, + useStagePoster, useTaskTracking, } from '../hooks'; import { useToast } from '../context/ToastContext'; -import type { Library, LibraryItem } from '../types'; +import type { Library, LibraryItem, TaskKind } from '../types'; import { libraryPath } from '../routes'; import { LIBRARY_ACTIONS, type LibraryActionName } from './libraryActions'; import { confirmationCopy, type ConfirmAction } from './libraryConfirmations'; +import { progressPercent, taskSummary } from './taskSummary'; import styles from './LibraryPage.module.css'; interface LibraryPageProps { @@ -75,7 +80,7 @@ export function LibraryPage({ const [rowsRefreshKey, setRowsRefreshKey] = useState(0); const { - items, setItems, setTotal, + items, setItems, total, setTotal, isLoading, isLoadingMore, hasMore, fetchItems, handleLoadMore, loadUpTo, } = useLibraryItems({ @@ -107,15 +112,35 @@ export function LibraryPage({ startTaskTracking, attachRunningTask, handleTaskStatus, handleTaskProgress, stopTask, } = useTaskTracking({ - onTaskFinished: () => { + onTaskFinished: ({ status, kind }) => { fetchItems(true); setRowsRefreshKey((key) => key + 1); reloadFilterCounts(); onRefreshLibraries(); + if (status === 'completed') void announceCompletion(kind); }, }); + const announceCompletion = async (kind: TaskKind) => { + let failedCount: number | undefined; + if (kind === 'generate' && selectedLibrary) { + try { + const counts = await libraryApi.getLibraryItemCounts(selectedLibrary.media_server_id, selectedLibrary.id); + failedCount = counts.errors; + } catch { + failedCount = undefined; + } + } + const summary = taskSummary(kind, selectedLibrary?.name ?? mediaServerName ?? 'All libraries', failedCount); + toast.success(summary.message, { + title: summary.title, + ...(summary.offerFailed + ? { duration: 12000, action: { label: 'Show failed items', onClick: () => setFilter('errors') } } + : {}), + }); + }; + const openItem = useOpenItem({ allLibraries, refreshListing: fetchItems, @@ -129,13 +154,41 @@ export function LibraryPage({ onLockChanged: reloadFilterCounts, }); + const coverage = useLibraryCoverage({ + libraryId: selectedLibrary?.id, + enabled: !showRows && !isTrash, + refreshKey: rowsRefreshKey, + }); + + const stage = useStagePoster({ + library: selectedLibrary, + items, + enabled: !showRows && !isTrash, + }); + const selection = useItemSelection({ items, mediaServerId, onTaskStarted: (taskId) => startTaskTracking(taskId), refreshListing: fetchItems, + listingKey: `${mediaServerId}|${selectedLibraryId ?? 'all'}|${mode}|${debouncedSearch}|${filter}|${provider ?? ''}`, }); + const autoUpload = useLibraryUploadSetting({ library: selectedLibrary, enabled: !showRows && !isTrash }); + + const handleSelectAllMatching = selectedLibrary + ? async () => { + try { + selection.selectAllMatching(await libraryApi.getLibraryItemIds( + selectedLibrary.media_server_id, selectedLibrary.id, + { search: debouncedSearch || undefined, status: filter === 'all' ? undefined : filter, provider }, + )); + } catch (error) { + toast.error(errorMessage(error, 'Could not select every matching item.'), { title: 'Selection' }); + } + } + : undefined; + useEventStream({ onItemProcessed: (libraryId, itemId, processed, posterVersion) => { @@ -148,6 +201,7 @@ export function LibraryPage({ ); openItem.applyProcessedEvent(libraryId, itemId, processed, posterVersion); + if (processed) stage.offerGenerated(libraryId, itemId, posterVersion); }, onLibrarySynced: (syncedMediaServerId, libraryId) => { @@ -167,7 +221,7 @@ export function LibraryPage({ onConnected: attachRunningTask, }); - const title = selectedLibrary ? selectedLibrary.name : (isTrash ? 'All Libraries' : 'Home'); + const title = selectedLibrary ? selectedLibrary.name : (isTrash ? 'All libraries' : 'Home'); const closeOpenItem = useEffectEvent(() => { if (openItemId === undefined) openItem.open(null); @@ -234,7 +288,7 @@ export function LibraryPage({ onApplied: openItem.applyPosterApplied, }); - const handleSyncLibraryClick = () => setConfirmAction('sync'); + const handleSyncLibraryClick = () => runLibraryAction('sync'); const handleSyncPostersClick = () => setConfirmAction('generate'); const handleUploadPostersClick = () => setConfirmAction('upload'); const handleResetPostersClick = () => { @@ -260,6 +314,29 @@ export function LibraryPage({ } }; + const openNeighbours = useItemNeighbours({ + library: selectedLibrary, + item: openItem.item, + items, + hasMore, + listing: { search: debouncedSearch || undefined, status: filter === 'all' ? undefined : filter, provider, sort }, + enabled: !isTrash, + }); + + const listingEmptyMessage = debouncedSearch || filter !== 'all' || provider + ? { title: 'Nothing matches', hint: 'Try another search, or clear the filters.' } + : { title: 'No items yet', hint: 'Use Sync library in the ⋮ menu to fetch this library from the media server.' }; + + const handleOpenStagePoster = async (poster: StagePoster) => { + if (!selectedLibrary) return; + const listed = items.find((i) => i.id === poster.itemId); + try { + handleItemClick(listed ?? await libraryApi.getItem(selectedLibrary.media_server_id, poster.libraryId, poster.itemId)); + } catch (error) { + toast.error(errorMessage(error, 'Could not open that item.'), { title: 'Library' }); + } + }; + const handleRefreshItems = () => { fetchItems(); reloadFilterCounts(); @@ -305,8 +382,7 @@ export function LibraryPage({ } }; - const handleItemSyncClick = () => setConfirmAction('item-sync'); - const handleItemGeneratePosterClick = () => setConfirmAction('item-generate'); + const handleItemGeneratePosterClick = () => openItem.generatePoster(); const handleItemResetClick = () => setConfirmAction('item-reset'); const confirmed = (run: () => void) => () => { @@ -315,13 +391,12 @@ export function LibraryPage({ }; const confirmHandlers: Record void> = { - 'sync': () => runLibraryAction('sync'), 'generate': () => runLibraryAction('generate'), 'upload': () => runLibraryAction('upload'), 'reset': () => runLibraryAction('reset'), - 'item-sync': confirmed(openItem.syncMetadata), - 'item-generate': confirmed(openItem.generatePoster), 'item-reset': confirmed(openItem.resetPoster), + 'selection-generate': confirmed(selection.generate), + 'selection-upload': confirmed(selection.upload), 'selection-reset': confirmed(selection.reset), 'empty-trash': executeEmptyTrash, }; @@ -332,6 +407,9 @@ export function LibraryPage({ libraryName: selectedLibrary?.name || 'all libraries', itemName: openItem.item?.title || '', selectionCount: selection.count, + + pendingCount: selectedLibrary && !debouncedSearch && !provider ? filterCounts?.unprocessed : undefined, + uploadsAutomatically: autoUpload, }); return { ...copy, @@ -347,13 +425,13 @@ export function LibraryPage({ return (
      -

      No Media Server Selected

      +

      No media server selected

      Select a media server from the sidebar or add one in settings.

      @@ -364,7 +442,7 @@ export function LibraryPage({ return ( <>
      -

      No Libraries Found

      -

      This media server doesn't have any libraries synced yet.

      -

      Click "Sync Library" to fetch libraries from your media server.

      +

      No libraries yet

      +

      This media server has no libraries in Affiche yet.

      +
      {confirmModalProps && ( @@ -416,11 +496,17 @@ export function LibraryPage({ handleItemClick(openNeighbours.previous!) : undefined} + onNext={openNeighbours.next ? () => handleItemClick(openNeighbours.next!) : undefined} + onSync={openItem.syncMetadata} onGeneratePoster={handleItemGeneratePosterClick} + uploadsAutomatically={autoUpload} onReset={handleItemResetClick} - onSelectPoster={() => posterBrowser.open(null)} + onSelectPoster={(posterUrl) => posterBrowser.open(null, posterUrl)} onUpload={openItem.uploadPoster} onToggleLock={openItem.toggleLock} onSeasonSelectPoster={(season) => posterBrowser.open(season)} @@ -438,6 +524,7 @@ export function LibraryPage({ onSave={posterBrowser.save} isSaving={posterBrowser.isSaving} defaultUpload={posterBrowser.uploadDefault} + initialPoster={posterBrowser.initialPoster} /> )} {confirmModalProps && ( @@ -474,6 +561,7 @@ export function LibraryPage({ provider={showRows ? undefined : provider} onProviderChange={showRows ? undefined : setProvider} filterCounts={showRows ? undefined : filterCounts} + pendingCount={selectedLibrary && !debouncedSearch && !provider ? filterCounts?.unprocessed : undefined} viewMode={showRows ? undefined : viewMode} onViewModeChange={showRows ? undefined : setViewMode} selectMode={showRows ? undefined : selection.isSelectMode} @@ -495,16 +583,31 @@ export function LibraryPage({ 0 && items.every((item) => selection.isSelected(item.id))} + matchingTotal={isTrash ? undefined : total} + isAllMatching={selection.isAllMatching} + onSelectAllMatching={isTrash ? undefined : handleSelectAllMatching} isBusy={selection.isBusy} onToggleAll={selection.toggleAll} onClear={selection.clear} - onGenerate={selection.generate} - onUpload={selection.upload} + onGenerate={() => setConfirmAction('selection-generate')} + onUpload={() => setConfirmAction('selection-upload')} onLock={selection.lock} onUnlock={selection.unlock} onReset={() => setConfirmAction('selection-reset')} /> )} + {!showRows && !isTrash && selectedLibrary && ( + navigate('/settings?tab=media-servers')} + runPercent={isActionLoading && taskKind === 'generate' ? progressPercent(taskProgress) : null} + onOpenPoster={handleOpenStagePoster} + /> + )} {showRows ? ( selection.toggle(item.id)} + onToggleSelect={isTrash ? undefined : (item, extend) => selection.toggle(item.id, extend)} onToggleSelectAll={selection.toggleAll} isSelected={(item) => selection.isSelected(item.id)} selectMode={selection.isSelectMode} @@ -540,9 +644,10 @@ export function LibraryPage({ onLoadMore={handleLoadMore} isLoadingMore={isLoadingMore} variant={isTrash ? 'trash' : 'default'} + emptyMessage={listingEmptyMessage} onRestore={isTrash ? handleRestoreItem : undefined} showAnchors={alphabet.isEnabled} - onToggleSelect={isTrash ? undefined : (item) => selection.toggle(item.id)} + onToggleSelect={isTrash ? undefined : (item, extend) => selection.toggle(item.id, extend)} isSelected={(item) => selection.isSelected(item.id)} selectMode={selection.isSelectMode} onToggleLock={isTrash ? undefined : itemLock.toggle} diff --git a/affiche-frontend/src/pages/SettingsPage.tsx b/affiche-frontend/src/pages/SettingsPage.tsx index 1be2bdf..2e257b9 100644 --- a/affiche-frontend/src/pages/SettingsPage.tsx +++ b/affiche-frontend/src/pages/SettingsPage.tsx @@ -79,7 +79,7 @@ export function SettingsPage({ onDataChanged }: SettingsPageProps) { if (isLoading) { return (
      -
      Loading settings...
      +
      Loading settings…
      ); } diff --git a/affiche-frontend/src/pages/SetupPage.tsx b/affiche-frontend/src/pages/SetupPage.tsx index a196544..859fbbd 100644 --- a/affiche-frontend/src/pages/SetupPage.tsx +++ b/affiche-frontend/src/pages/SetupPage.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react'; import { errorMessage } from '../api'; import { useAuth } from '../context/AuthContext'; -import { AfficheLogo } from '../components/common'; +import { AfficheLogo, PasswordRules, passwordRules } from '../components/common'; import styles from './AuthPage.module.css'; export function SetupPage() { @@ -11,6 +11,7 @@ export function SetupPage() { const [confirm, setConfirm] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const rules = passwordRules(password, confirm); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -79,7 +80,8 @@ export function SetupPage() { required />
    - diff --git a/affiche-frontend/src/pages/WelcomePage.module.css b/affiche-frontend/src/pages/WelcomePage.module.css new file mode 100644 index 0000000..c5ce922 --- /dev/null +++ b/affiche-frontend/src/pages/WelcomePage.module.css @@ -0,0 +1,53 @@ +.page { + max-width: 760px; + margin: 0 auto; + padding: 40px 24px 64px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.hero h2 { + font-size: var(--text-3xl); + font-weight: 650; + letter-spacing: -0.01em; + text-wrap: balance; +} + +.hero p { + margin-top: 4px; + color: var(--text-secondary); +} + +.steps { + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; +} + +.memberNotice { + margin-top: 40px; + padding: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background-color: var(--bg-secondary); + text-align: center; +} + +.memberNotice h2 { + font-size: var(--text-2xl); + font-weight: 600; +} + +.memberNotice p { + max-width: 46ch; + margin: 8px auto 0; + color: var(--text-secondary); +} + +@media (max-width: 600px) { + .page { + padding: 24px 16px 48px; + } +} diff --git a/affiche-frontend/src/pages/WelcomePage.test.tsx b/affiche-frontend/src/pages/WelcomePage.test.tsx new file mode 100644 index 0000000..eed8a7a --- /dev/null +++ b/affiche-frontend/src/pages/WelcomePage.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +import { WelcomePage } from './WelcomePage'; +import { configApi, libraryApi, mediaServerApi } from '../api'; +import type { Library, MediaServerResponse } from '../types'; + +const auth = vi.hoisted(() => ({ isAdmin: true })); +vi.mock('../context/AuthContext', () => ({ useAuth: () => auth })); + +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal()), + mediaServerApi: { testPlex: vi.fn(), testJellyfin: vi.fn(), create: vi.fn() }, + configApi: { findConfigs: vi.fn(), createConfig: vi.fn() }, + serviceApi: { testProvider: vi.fn() }, + libraryApi: { + syncLibraryPosters: vi.fn(), + syncLibrary: vi.fn(), + getLibrarySettings: vi.fn(() => Promise.resolve({ upload_enabled: true })), + }, +})); + +const SERVER = { id: 4, name: 'Living room', type: 'PLEX' } as MediaServerResponse; +const FILMS = { id: 9, media_server_id: 4, name: 'Films', library_type: 'movie' } as Library; + +function renderPage(mediaServers: { server: MediaServerResponse; libraries: Library[] }[] = []) { + const onDataChanged = vi.fn(); + const onOpenLibrary = vi.fn(); + render( + + + + ); + return { onDataChanged, onOpenLibrary }; +} + +beforeEach(() => { + vi.clearAllMocks(); + auth.isAdmin = true; + vi.mocked(configApi.findConfigs).mockResolvedValue([]); +}); + +describe('WelcomePage', () => { + it('tells a non-admin who can set things up, instead of showing forms', () => { + auth.isAdmin = false; + renderPage(); + + expect(screen.getByText(/Only an admin can connect one/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument(); + }); + + it('connects a server and adds the libraries it found', async () => { + vi.mocked(mediaServerApi.testPlex).mockResolvedValue({ + name: 'Living room', + libraries: [{ id: 'a', name: 'Films', type: 'movie', item_count: 1204, language: 'en' }], + }); + vi.mocked(mediaServerApi.create).mockResolvedValue(SERVER); + const user = userEvent.setup(); + const { onDataChanged } = renderPage(); + + await user.type(screen.getByLabelText('Plex token'), 'secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(await screen.findByRole('button', { name: 'Add server and 1 library' })); + + expect(mediaServerApi.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Living room', type: 'PLEX', token: 'secret', + libraries: [expect.objectContaining({ id: 'a' })], + })); + expect(onDataChanged).toHaveBeenCalled(); + }); + + it('turns TVmaze on when continuing, and warns what skipping TMDB costs a film library', async () => { + const user = userEvent.setup(); + renderPage([{ server: SERVER, libraries: [FILMS] }]); + + expect(await screen.findByText(/Without TMDB, Films will get few posters/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(configApi.createConfig).toHaveBeenCalledWith(expect.objectContaining({ name: 'tvmaze', enabled: true })); + expect(await screen.findByRole('button', { name: 'Generate posters' })).toBeInTheDocument(); + }); + + it('starts the run and opens the library that shows it', async () => { + vi.mocked(libraryApi.syncLibraryPosters).mockResolvedValue({ task_id: 't' } as never); + const user = userEvent.setup(); + const { onOpenLibrary } = renderPage([{ server: SERVER, libraries: [FILMS] }]); + await user.click(await screen.findByRole('button', { name: 'Continue' })); + + expect(await screen.findByText(/Films uploads new posters to your media server/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Generate posters' })); + + await waitFor(() => expect(onOpenLibrary).toHaveBeenCalledWith(4, 9)); + expect(libraryApi.syncLibraryPosters).toHaveBeenCalledWith(4, 9); + }); +}); diff --git a/affiche-frontend/src/pages/WelcomePage.tsx b/affiche-frontend/src/pages/WelcomePage.tsx new file mode 100644 index 0000000..13c9f35 --- /dev/null +++ b/affiche-frontend/src/pages/WelcomePage.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { + ConnectServerStep, + currentWelcomeStep, + FirstPostersStep, + PosterSourcesStep, + SetupStep, + stepState, +} from '../components/welcome'; +import { providerLabel } from '../constants/providers'; +import { useAuth } from '../context/AuthContext'; +import type { Library, MediaServerResponse } from '../types'; +import styles from './WelcomePage.module.css'; + +interface WelcomePageProps { + mediaServers: { server: MediaServerResponse; libraries: Library[] }[]; + + onDataChanged: () => void; + onOpenLibrary: (mediaServerId: number, libraryId: number) => void; +} + +export function WelcomePage({ mediaServers, onDataChanged, onOpenLibrary }: WelcomePageProps) { + const { isAdmin } = useAuth(); + const navigate = useNavigate(); + const [sourcesConfirmed, setSourcesConfirmed] = useState(false); + const [sourcesSummary, setSourcesSummary] = useState(null); + + const first = mediaServers[0]; + const libraries = mediaServers.flatMap((entry) => entry.libraries); + const current = currentWelcomeStep(!!first, sourcesConfirmed); + + if (!isAdmin) { + return ( +
    +
    +

    Nothing to show yet

    +

    + Affiche has no media server connected. Only an admin can connect one — once they have, + your libraries appear here. +

    +
    +
    + ); + } + + return ( +
    +
    +

    Let's get your first posters made

    +

    Three steps. Each one can be changed later in Settings.

    +
    + +
      + l.name).join(', ') || 'no libraries'}` + : 'Affiche reads your libraries from it and writes the posters back.'} + state={stepState('server', current)} + onChange={() => navigate('/settings?tab=media-servers')} + > + + + + setSourcesConfirmed(false)} + > + { + setSourcesSummary(providers.map(providerLabel).join(' and ')); + setSourcesConfirmed(true); + }} + /> + + + + onOpenLibrary(library.media_server_id, library.id)} + /> + +
    +
    + ); +} diff --git a/affiche-frontend/src/pages/dashboardStats.test.ts b/affiche-frontend/src/pages/dashboardStats.test.ts index 26d04c1..9ad58da 100644 --- a/affiche-frontend/src/pages/dashboardStats.test.ts +++ b/affiche-frontend/src/pages/dashboardStats.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { DashboardTask, ItemStats } from '../types'; import { - byCoverageAscending, coveragePercent, providerBarPercent, taskLabel, taskTimestamp, + byCoverageAscending, coveragePercent, providerBarPercent, taskLabel, taskStatusLabel, taskTimestamp, } from './dashboardStats'; const stats = (overrides: Partial = {}): ItemStats => ({ @@ -102,3 +102,14 @@ describe('taskTimestamp', () => { expect(taskTimestamp(task())).toBeNull(); }); }); + +describe('taskStatusLabel', () => { + it('puts the backend statuses into words', () => { + expect(taskStatusLabel('completed')).toBe('Done'); + expect(taskStatusLabel('cancelled')).toBe('Stopped'); + }); + + it('shows a status it does not know as sent', () => { + expect(taskStatusLabel('paused')).toBe('paused'); + }); +}); diff --git a/affiche-frontend/src/pages/dashboardStats.ts b/affiche-frontend/src/pages/dashboardStats.ts index 163c980..5d2fe6d 100644 --- a/affiche-frontend/src/pages/dashboardStats.ts +++ b/affiche-frontend/src/pages/dashboardStats.ts @@ -1,9 +1,7 @@ +import { coveragePercent } from '../components/library/coverage'; import type { DashboardTask, ItemStats } from '../types'; -export function coveragePercent(stats: ItemStats): number { - if (stats.total === 0) return 0; - return Math.round((stats.processed / stats.total) * 100); -} +export { coveragePercent }; export function byCoverageAscending(rows: T[]): T[] { return [...rows].sort((a, b) => { @@ -31,3 +29,15 @@ export function taskLabel(task: DashboardTask): string { export function taskTimestamp(task: DashboardTask): string | null { return task.completed_at ?? task.created_at ?? null; } + +const TASK_STATUS_LABELS: Record = { + pending: 'Queued', + running: 'Running', + completed: 'Done', + failed: 'Failed', + cancelled: 'Stopped', +}; + +export function taskStatusLabel(status: string): string { + return TASK_STATUS_LABELS[status] ?? status; +} diff --git a/affiche-frontend/src/pages/index.ts b/affiche-frontend/src/pages/index.ts index badbbeb..0547f3e 100644 --- a/affiche-frontend/src/pages/index.ts +++ b/affiche-frontend/src/pages/index.ts @@ -5,3 +5,4 @@ export { LoginPage } from './LoginPage'; export { SetupPage } from './SetupPage'; export { ChangePasswordPage } from './ChangePasswordPage'; export { CollectionsPage } from './CollectionsPage'; +export { WelcomePage } from './WelcomePage'; diff --git a/affiche-frontend/src/pages/libraryActions.test.ts b/affiche-frontend/src/pages/libraryActions.test.ts index 7c48373..0a5a0f2 100644 --- a/affiche-frontend/src/pages/libraryActions.test.ts +++ b/affiche-frontend/src/pages/libraryActions.test.ts @@ -77,7 +77,7 @@ describe('LIBRARY_ACTIONS metadata', () => { expect(LIBRARY_ACTIONS.generate.taskKind).toBe('generate'); expect(LIBRARY_ACTIONS.reset.taskKind).toBe('reset'); - expect(LIBRARY_ACTIONS.upload.taskKind).toBeUndefined(); + expect(LIBRARY_ACTIONS.upload.taskKind).toBe('upload'); }); it('gives every action a toast title and a fallback sentence', () => { diff --git a/affiche-frontend/src/pages/libraryActions.ts b/affiche-frontend/src/pages/libraryActions.ts index 75c7aa5..9a5886f 100644 --- a/affiche-frontend/src/pages/libraryActions.ts +++ b/affiche-frontend/src/pages/libraryActions.ts @@ -46,6 +46,7 @@ export const LIBRARY_ACTIONS: Record = { library ? libraryApi.uploadLibraryPosters(library.media_server_id, library.id) : libraryApi.uploadAllPosters(mediaServerId), + taskKind: 'upload', errorTitle: 'Upload failed', errorFallback: 'Could not start the poster upload.', }, diff --git a/affiche-frontend/src/pages/libraryConfirmations.test.ts b/affiche-frontend/src/pages/libraryConfirmations.test.ts index 9ee280c..b816bbf 100644 --- a/affiche-frontend/src/pages/libraryConfirmations.test.ts +++ b/affiche-frontend/src/pages/libraryConfirmations.test.ts @@ -6,8 +6,8 @@ const ctx = { libraryName: 'Movies', itemName: 'Alien', selectionCount: 3 }; describe('confirmationCopy', () => { it('names the library in library-scoped actions', () => { - expect(confirmationCopy('sync', ctx).message).toContain('Movies'); expect(confirmationCopy('generate', ctx).message).toContain('Movies'); + expect(confirmationCopy('upload', ctx).message).toContain('Movies'); }); it('names the item in item-scoped actions', () => { @@ -16,8 +16,8 @@ describe('confirmationCopy', () => { it('marks exactly the destructive actions as danger', () => { const actions: ConfirmAction[] = [ - 'sync', 'generate', 'upload', 'reset', 'item-sync', 'item-generate', 'item-reset', - 'selection-reset', 'empty-trash', + 'generate', 'upload', 'reset', 'item-reset', + 'selection-generate', 'selection-upload', 'selection-reset', 'empty-trash', ]; const danger = actions.filter((a) => confirmationCopy(a, ctx).variant === 'danger'); @@ -25,28 +25,67 @@ describe('confirmationCopy', () => { }); it('offers the unprocessed opt-in only on the library-wide reset', () => { - expect(confirmationCopy('reset', ctx).checkboxLabel).toBe('Also reset unprocessed items'); + expect(confirmationCopy('reset', ctx).checkboxLabel).toBe('Also reset items with no poster yet'); expect(confirmationCopy('item-reset', ctx).checkboxLabel).toBeUndefined(); - expect(confirmationCopy('sync', ctx).checkboxLabel).toBeUndefined(); + expect(confirmationCopy('generate', ctx).checkboxLabel).toBeUndefined(); }); - it('counts the selection in the bulk reset, so the number is confirmed not assumed', () => { - const { message, confirmLabel } = confirmationCopy('selection-reset', ctx); - - expect(message).toContain('3 selected items'); - expect(confirmLabel).toBe('Reset 3'); + it('counts the selection in every bulk action, so the number is confirmed not assumed', () => { + for (const [action, verb] of [ + ['selection-generate', 'Generate'], ['selection-upload', 'Upload'], ['selection-reset', 'Reset'], + ] as const) { + const { message, confirmLabel } = confirmationCopy(action, ctx); + expect(message).toContain('3 selected items'); + expect(confirmLabel).toBe(`${verb} 3`); + } }); - it('keeps the bulk reset wording singular for one item', () => { + it('keeps the bulk wording singular for one item', () => { expect(confirmationCopy('selection-reset', { ...ctx, selectionCount: 1 }).message) - .toContain('1 selected item '); + .toContain('1 selected item,'); }); - it('states that emptying the trash never touches the media server', () => { + it('says an upload replaces the media server artwork and that Reset brings it back', () => { + for (const action of ['upload', 'selection-upload'] as const) { + const { message } = confirmationCopy(action, ctx); + expect(message).toMatch(/replacing the artwork/); + expect(message).toMatch(/Reset puts it back/); + } + }); + + it('tells generate where the new posters end up, per the library upload setting', () => { + expect(confirmationCopy('generate', { ...ctx, uploadsAutomatically: false }).message) + .toMatch(/stay in Affiche until you upload/); + expect(confirmationCopy('generate', { ...ctx, uploadsAutomatically: true }).message) + .toMatch(/uploads automatically.*Reset puts it back/); + expect(confirmationCopy('generate', ctx).message) + .toMatch(/Libraries that upload automatically/); + }); + + it('names the upload in the generate button when the library uploads automatically', () => { + expect(confirmationCopy('generate', { ...ctx, uploadsAutomatically: true }).confirmLabel).toBe('Generate & upload'); + expect(confirmationCopy('generate', { ...ctx, uploadsAutomatically: false }).confirmLabel).toBe('Generate'); + }); + it('quotes the pending count on generate only when the page has one', () => { + expect(confirmationCopy('generate', { ...ctx, pendingCount: 354 }).message).toContain('(354)'); + expect(confirmationCopy('generate', ctx).message).not.toMatch(/\(\d+\)/); + }); + + it('states that emptying the trash never touches the media server', () => { const { message } = confirmationCopy('empty-trash', ctx); expect(message).toMatch(/media server is never touched/i); expect(message).toMatch(/cannot be undone/i); }); }); + +describe('reset wording', () => { + it('describes Reset as putting the original back, not as something that cannot be undone', () => { + for (const action of ['reset', 'item-reset', 'selection-reset'] as const) { + const { message } = confirmationCopy(action, ctx); + expect(message).toMatch(/puts back/); + expect(message).not.toMatch(/cannot be undone/); + } + }); +}); diff --git a/affiche-frontend/src/pages/libraryConfirmations.ts b/affiche-frontend/src/pages/libraryConfirmations.ts index 7b90c1b..e9f4b7b 100644 --- a/affiche-frontend/src/pages/libraryConfirmations.ts +++ b/affiche-frontend/src/pages/libraryConfirmations.ts @@ -1,7 +1,7 @@ export type ConfirmAction = - | 'sync' | 'generate' | 'upload' | 'reset' - | 'item-sync' | 'item-generate' | 'item-reset' - | 'selection-reset' + | 'generate' | 'upload' | 'reset' + | 'item-reset' + | 'selection-generate' | 'selection-upload' | 'selection-reset' | 'empty-trash'; export interface ConfirmationCopy { @@ -20,68 +20,79 @@ interface ConfirmationContext { itemName: string; selectionCount: number; + + pendingCount?: number; + + uploadsAutomatically?: boolean; } +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + +const KEPT_FOR_RESET = 'The artwork it shows now is kept, so Reset puts it back.'; + export function confirmationCopy( action: ConfirmAction, - { libraryName, itemName, selectionCount }: ConfirmationContext + { libraryName, itemName, selectionCount, pendingCount, uploadsAutomatically }: ConfirmationContext ): ConfirmationCopy { switch (action) { - case 'sync': - return { - title: 'Sync Library', - message: `This will sync metadata for ${libraryName}. Continue?`, - confirmLabel: 'Sync', - }; - case 'generate': + case 'generate': { + const scope = pendingCount === undefined + ? 'the items that have no poster yet' + : `the items that have no poster yet (${pendingCount.toLocaleString()})`; + const destination = uploadsAutomatically === undefined + ? 'Libraries that upload automatically send new posters straight to the media server; the others keep them in Affiche until you upload.' + : uploadsAutomatically + ? `This library uploads automatically, so each new poster replaces the media server's artwork as it is made. ${KEPT_FOR_RESET}` + : 'New posters stay in Affiche until you upload them.'; return { - title: 'Generate Posters', - message: `This will generate posters for ${libraryName}. Continue?`, - confirmLabel: 'Generate', + title: 'Generate posters', + message: `This syncs ${libraryName}, then generates posters for ${scope}. Locked items are skipped. ${destination}`, + confirmLabel: uploadsAutomatically ? 'Generate & upload' : 'Generate', }; + } case 'upload': return { - title: 'Upload Posters', - message: `This will upload generated posters that haven't been uploaded yet in ${libraryName} to the media server. Continue?`, + title: 'Upload posters', + message: `This uploads every poster in ${libraryName} that is generated but not on the media server yet, replacing the artwork the media server shows. ${KEPT_FOR_RESET}`, confirmLabel: 'Upload', }; case 'reset': return { - title: 'Reset Posters', - message: `This will reset processed posters in ${libraryName} to their originals. This action cannot be undone.`, + title: 'Reset posters', + message: `This puts back the artwork the media server had for every item in ${libraryName} that Affiche made a poster for. Those posters are discarded; you can generate them again.`, confirmLabel: 'Reset', variant: 'danger', - checkboxLabel: 'Also reset unprocessed items', + checkboxLabel: 'Also reset items with no poster yet', }; - case 'item-sync': + case 'item-reset': return { - title: 'Sync Metadata', - message: `This will sync metadata for "${itemName}". Continue?`, - confirmLabel: 'Sync', + title: 'Reset poster', + message: `This puts back the artwork the media server had for "${itemName}". Affiche's poster is discarded; you can generate it again.`, + confirmLabel: 'Reset', + variant: 'danger', }; - case 'item-generate': + case 'selection-generate': return { - title: 'Generate Poster', - message: `This will generate a poster for "${itemName}". Continue?`, - confirmLabel: 'Generate', + title: 'Generate selected posters', + message: `This generates posters for ${plural(selectionCount, 'selected item')}, replacing any poster Affiche already made for them. Libraries that upload automatically send the new posters straight to the media server.`, + confirmLabel: `Generate ${selectionCount}`, }; - case 'item-reset': + case 'selection-upload': return { - title: 'Reset Poster', - message: `This will reset the poster for "${itemName}" to its original. Continue?`, - confirmLabel: 'Reset', - variant: 'danger', + title: 'Upload selected posters', + message: `This uploads the posters of ${plural(selectionCount, 'selected item')} to the media server, replacing the artwork it shows for them. ${KEPT_FOR_RESET}`, + confirmLabel: `Upload ${selectionCount}`, }; case 'selection-reset': return { - title: 'Reset Selected Posters', - message: `This will reset ${selectionCount} selected item${selectionCount === 1 ? '' : 's'} to their original artwork, including any that were never processed. This action cannot be undone.`, + title: 'Reset selected posters', + message: `This puts back the original artwork for ${plural(selectionCount, 'selected item')}, including any Affiche never made a poster for. Their Affiche posters are discarded; you can generate them again.`, confirmLabel: `Reset ${selectionCount}`, variant: 'danger', }; case 'empty-trash': return { - title: 'Empty Trash', + title: 'Empty trash', message: `This will permanently remove all trashed items in ${libraryName}, including their generated posters, from Affiche's own database. Your media server is never touched — no media, metadata or artwork is deleted there. This action cannot be undone.`, confirmLabel: 'Empty trash', diff --git a/affiche-frontend/src/pages/taskSummary.test.ts b/affiche-frontend/src/pages/taskSummary.test.ts new file mode 100644 index 0000000..91a4f52 --- /dev/null +++ b/affiche-frontend/src/pages/taskSummary.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { progressPercent, taskSummary } from './taskSummary'; + +describe('taskSummary', () => { + it('reports a clean generation run without offering failures', () => { + expect(taskSummary('generate', 'Movies', 0)).toEqual({ + title: 'Posters generated', message: 'Finished Movies.', offerFailed: false, + }); + }); + + it('counts the items a generation run left without a poster', () => { + const summary = taskSummary('generate', 'Movies', 12); + + expect(summary.message).toContain('12 items failed'); + expect(summary.offerFailed).toBe(true); + }); + + it('keeps the failure wording singular for one item', () => { + expect(taskSummary('generate', 'Movies', 1).message).toContain('1 item failed'); + }); + + it('treats an unknown failure count as none rather than guessing', () => { + expect(taskSummary('generate', 'All libraries').offerFailed).toBe(false); + }); + + it('names the scope for every kind of task', () => { + for (const kind of ['sync', 'upload', 'reset', 'other'] as const) { + expect(taskSummary(kind, 'Movies').message).toContain('Movies'); + } + }); +}); + +describe('progressPercent', () => { + it('rounds and caps, and reads 0 before a total is known', () => { + expect(progressPercent({ current: 1, total: 3 })).toBe(33); + expect(progressPercent({ current: 5, total: 4 })).toBe(100); + expect(progressPercent(null)).toBe(0); + expect(progressPercent({ current: 0, total: 0 })).toBe(0); + }); +}); diff --git a/affiche-frontend/src/pages/taskSummary.ts b/affiche-frontend/src/pages/taskSummary.ts new file mode 100644 index 0000000..b49387e --- /dev/null +++ b/affiche-frontend/src/pages/taskSummary.ts @@ -0,0 +1,38 @@ +import type { TaskKind, TaskProgressState } from '../types'; + +export interface TaskSummary { + title: string; + message: string; + + offerFailed: boolean; +} + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + +export function taskSummary(kind: TaskKind, scopeName: string, failedCount?: number): TaskSummary { + switch (kind) { + case 'generate': { + const failed = failedCount ?? 0; + return { + title: 'Posters generated', + message: failed > 0 + ? `Finished ${scopeName}. ${plural(failed, 'item')} failed; each one says why.` + : `Finished ${scopeName}.`, + offerFailed: failed > 0, + }; + } + case 'upload': + return { title: 'Posters uploaded', message: `${scopeName} is up to date on the media server.`, offerFailed: false }; + case 'sync': + return { title: 'Library synced', message: `${scopeName} is up to date with the media server.`, offerFailed: false }; + case 'reset': + return { title: 'Posters reset', message: `${scopeName} shows its original artwork again.`, offerFailed: false }; + case 'other': + return { title: 'Task finished', message: `Finished working on ${scopeName}.`, offerFailed: false }; + } +} + +export function progressPercent(progress: TaskProgressState | null | undefined): number { + if (!progress || progress.total <= 0) return 0; + return Math.min(100, Math.round((progress.current / progress.total) * 100)); +} diff --git a/affiche-frontend/src/routes.test.ts b/affiche-frontend/src/routes.test.ts index 7906c6a..ea0eeae 100644 --- a/affiche-frontend/src/routes.test.ts +++ b/affiche-frontend/src/routes.test.ts @@ -7,7 +7,7 @@ describe('listingPath', () => { expect(libraryPath(1, 5)).toBe('/servers/1/libraries/5'); }); - it('uses the sentinel for the merged "All Libraries" listing', () => { + it('uses the sentinel for the merged "All libraries" listing', () => { expect(libraryPath(1)).toBe('/servers/1/libraries/all'); }); diff --git a/affiche-frontend/src/types/index.ts b/affiche-frontend/src/types/index.ts index 220ba76..e8618a9 100644 --- a/affiche-frontend/src/types/index.ts +++ b/affiche-frontend/src/types/index.ts @@ -36,7 +36,7 @@ export interface Library { enabled?: boolean; } -export type ItemStatusFilter = 'unprocessed' | 'errors' | 'locked'; +export type ItemStatusFilter = 'unprocessed' | 'errors' | 'locked' | 'ready' | 'uploaded'; export const NO_PROVIDER = 'none'; @@ -47,6 +47,11 @@ export interface PosterCandidate { rank: number; rank_score: number; + + language?: string | null; + textless?: boolean | null; + width?: number | null; + height?: number | null; } export type ItemProviderFilter = string | undefined; @@ -60,7 +65,7 @@ export interface SortState { export type ViewMode = 'grid' | 'list'; -export type TaskKind = 'sync' | 'generate' | 'reset' | 'other'; +export type TaskKind = 'sync' | 'generate' | 'upload' | 'reset' | 'other'; export interface TaskProgressState { current: number; @@ -84,6 +89,8 @@ export interface LibraryItem { last_seen_at?: string; poster_uploaded_at?: string; + + poster_generated_at?: string | null; imdb_id?: string; tmdb_id?: string; tvdb_id?: string; @@ -194,6 +201,10 @@ export interface ItemStatusCounts { unprocessed: number; errors: number; locked: number; + + ready?: number; + + uploaded?: number; } export interface ItemStats { @@ -203,6 +214,8 @@ export interface ItemStats { errors: number; locked: number; uploaded: number; + + ready?: number; } export interface DashboardLibrary {