diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 5c770568..c0fd43df 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -72,6 +72,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { var userEditAccessSchedules = makeUserEditAccessSchedules @Route(.modal) var userAddAccessSchedule = makeUserAddAccessSchedule + @Route(.push) + var userEditAccessTags = makeUserEditAccessTags + @Route(.modal) + var userAddAccessTag = makeUserAddAccessTag @Route(.modal) var userPhotoPicker = makeUserPhotoPicker @@ -182,6 +186,17 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { EditAccessScheduleView(viewModel: viewModel) } + @ViewBuilder + func makeUserEditAccessTags(viewModel: ServerUserAdminViewModel) -> some View { + EditServerUserAccessTagsView(viewModel: viewModel) + } + + func makeUserAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddServerUserAccessTagsView(viewModel: viewModel) + } + } + func makeUserAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator { AddAccessScheduleView(viewModel: viewModel) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index bd3e2892..8face6c3 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -26,6 +26,16 @@ internal enum L10n { internal static let accessSchedules = L10n.tr("Localizable", "accessSchedules", fallback: "Access Schedules") /// Define the allowed hours for usage and restrict access outside those times. internal static let accessSchedulesDescription = L10n.tr("Localizable", "accessSchedulesDescription", fallback: "Define the allowed hours for usage and restrict access outside those times.") + /// User will have access to no media unless it contains at least one allowed tag. + internal static let accessTagAllowDescription = L10n.tr("Localizable", "accessTagAllowDescription", fallback: "User will have access to no media unless it contains at least one allowed tag.") + /// Access tag already exists + internal static let accessTagAlreadyExists = L10n.tr("Localizable", "accessTagAlreadyExists", fallback: "Access tag already exists") + /// User will have access to all media except when it contains any blocked tag. + internal static let accessTagBlockDescription = L10n.tr("Localizable", "accessTagBlockDescription", fallback: "User will have access to all media except when it contains any blocked tag.") + /// Access Tags + internal static let accessTags = L10n.tr("Localizable", "accessTags", fallback: "Access Tags") + /// Use tags to grant or restrict this user's access to media. + internal static let accessTagsDescription = L10n.tr("Localizable", "accessTagsDescription", fallback: "Use tags to grant or restrict this user's access to media.") /// Active internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// Activity @@ -34,8 +44,10 @@ internal enum L10n { internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor") /// Add internal static let add = L10n.tr("Localizable", "add", fallback: "Add") - /// Add Access Schedule - internal static let addAccessSchedule = L10n.tr("Localizable", "addAccessSchedule", fallback: "Add Access Schedule") + /// Add access schedule + internal static let addAccessSchedule = L10n.tr("Localizable", "addAccessSchedule", fallback: "Add access schedule") + /// Add access tag + internal static let addAccessTag = L10n.tr("Localizable", "addAccessTag", fallback: "Add access tag") /// Add API key internal static let addAPIKey = L10n.tr("Localizable", "addAPIKey", fallback: "Add API key") /// Additional security access for users signed in to this device. This does not change any Jellyfin server user settings. @@ -74,6 +86,8 @@ internal enum L10n { internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") /// Allow collection management internal static let allowCollectionManagement = L10n.tr("Localizable", "allowCollectionManagement", fallback: "Allow collection management") + /// Allowed + internal static let allowed = L10n.tr("Localizable", "allowed", fallback: "Allowed") /// Allow media item deletion internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion") /// Allow media item editing @@ -198,6 +212,8 @@ internal enum L10n { internal static let bitrateTestDisclaimer = L10n.tr("Localizable", "bitrateTestDisclaimer", fallback: "Longer tests are more accurate but may result in a delayed playback.") /// bps internal static let bitsPerSecond = L10n.tr("Localizable", "bitsPerSecond", fallback: "bps") + /// Blocked + internal static let blocked = L10n.tr("Localizable", "blocked", fallback: "Blocked") /// Block unrated items internal static let blockUnratedItems = L10n.tr("Localizable", "blockUnratedItems", fallback: "Block unrated items") /// Block items from this user with no or unrecognized rating information. @@ -1202,6 +1218,8 @@ internal enum L10n { internal static let syncPlay = L10n.tr("Localizable", "syncPlay", fallback: "SyncPlay") /// System internal static let system = L10n.tr("Localizable", "system", fallback: "System") + /// Tag + internal static let tag = L10n.tr("Localizable", "tag", fallback: "Tag") /// Tagline internal static let tagline = L10n.tr("Localizable", "tagline", fallback: "Tagline") /// Taglines diff --git a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift index aea2dd6e..64c47eab 100644 --- a/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift +++ b/Shared/ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift @@ -279,6 +279,8 @@ class ItemEditorViewModel: ViewModel, Stateful, Eventful { // MARK: - Reorder Elements (To Be Overridden) + // TODO: should instead move to an index-based self insertion + // instead of replacement func reorderComponents(_ tags: [Element]) async throws { fatalError("This method should be overridden in subclasses") } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index f57561bd..2d511894 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -232,6 +232,11 @@ 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; }; 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; }; 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; }; + 4EFAC12C2D1E255900E40880 /* EditServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */; }; + 4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */; }; + 4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */; }; + 4EFAC1362D1FB1A100E40880 /* AccessTagSearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */; }; + 4EFAC1382D1FB26600E40880 /* TagInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1372D1FB26600E40880 /* TagInput.swift */; }; 4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; }; 4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; }; 4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; }; @@ -1361,6 +1366,11 @@ 4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = ""; }; 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = ""; }; + 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerUserAccessTagsView.swift; sourceTree = ""; }; + 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessTagRow.swift; sourceTree = ""; }; + 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserAccessTagsView.swift; sourceTree = ""; }; + 4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTagSearchResultsSection.swift; sourceTree = ""; }; + 4EFAC1372D1FB26600E40880 /* TagInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagInput.swift; sourceTree = ""; }; 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = ""; }; 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonKind.swift; sourceTree = ""; }; 4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemArrayElements.swift; sourceTree = ""; }; @@ -2373,6 +2383,7 @@ 4EED87492CBF824B002354D2 /* DevicesView */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4ECF5D8C2D0A780F00F066B1 /* ServerTasks */, + 4EFAC12A2D1E253300E40880 /* ServerUserAccessTags */, 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, 4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */, 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */, @@ -2861,6 +2872,50 @@ path = ServerUserAccessView; sourceTree = ""; }; + 4EFAC12A2D1E253300E40880 /* ServerUserAccessTags */ = { + isa = PBXGroup; + children = ( + 4EFAC1312D1E373B00E40880 /* AddServerUserAccessTagsView */, + 4EFAC12D2D1E2C4700E40880 /* EditServerUserAccessTagsView */, + ); + path = ServerUserAccessTags; + sourceTree = ""; + }; + 4EFAC12D2D1E2C4700E40880 /* EditServerUserAccessTagsView */ = { + isa = PBXGroup; + children = ( + 4EFAC12F2D1E2EB900E40880 /* Components */, + 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */, + ); + path = EditServerUserAccessTagsView; + sourceTree = ""; + }; + 4EFAC12F2D1E2EB900E40880 /* Components */ = { + isa = PBXGroup; + children = ( + 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EFAC1312D1E373B00E40880 /* AddServerUserAccessTagsView */ = { + isa = PBXGroup; + children = ( + 4EFAC1342D1FB19700E40880 /* Components */, + 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */, + ); + path = AddServerUserAccessTagsView; + sourceTree = ""; + }; + 4EFAC1342D1FB19700E40880 /* Components */ = { + isa = PBXGroup; + children = ( + 4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */, + 4EFAC1372D1FB26600E40880 /* TagInput.swift */, + ); + path = Components; + sourceTree = ""; + }; 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( @@ -5632,6 +5687,7 @@ E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, + 4EFAC1362D1FB1A100E40880 /* AccessTagSearchResultsSection.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, 4E2470082D078DD7009139D8 /* ServerUserParentalRatingView.swift in Sources */, @@ -5766,12 +5822,14 @@ 4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */, E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */, 4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */, + 4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, E1A5056A2D0B733F007EE305 /* Optional.swift in Sources */, + 4EFAC1382D1FB26600E40880 /* TagInput.swift in Sources */, E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E1BE1CEE2BDB68CD008176A9 /* UserProfileRow.swift in Sources */, @@ -5974,6 +6032,7 @@ E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */, E11895AC289383EE0042947B /* NavigationBarOffsetModifier.swift in Sources */, E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */, + 4EFAC12C2D1E255900E40880 /* EditServerUserAccessTagsView.swift in Sources */, E157563029355B7900976E1F /* UpdateView.swift in Sources */, E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */, E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */, @@ -6090,6 +6149,7 @@ E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */, E190704F2C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, + 4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift index 1fd9a8c8..61b8cf7e 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift @@ -92,7 +92,7 @@ struct AddAccessScheduleView: View { var body: some View { contentView - .navigationTitle(L10n.addAccessSchedule) + .navigationTitle(L10n.addAccessSchedule.localizedCapitalized) .navigationBarTitleDisplayMode(.inline) .navigationBarCloseButton { router.dismissCoordinator() diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift new file mode 100644 index 00000000..95415557 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift @@ -0,0 +1,151 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct AddServerUserAccessTagsView: View { + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + @StateObject + private var tagViewModel: TagEditorViewModel + + // MARK: - Access Tag Variables + + @State + private var tempPolicy: UserPolicy + @State + private var tempTag: String = "" + @State + private var access: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Name is Valid + + private var isValid: Bool { + tempTag.isNotEmpty && !tagIsDuplicate + } + + // MARK: - Tag is Already Blocked/Allowed + + private var tagIsDuplicate: Bool { + viewModel.user.policy!.blockedTags!.contains(tempTag) // && + //! viewModel.user.policy!.allowedTags!.contains(tempTag) + } + + // MARK: - Tag Already Exists on Jellyfin + + private var tagAlreadyExists: Bool { + tagViewModel.trie.contains(key: tempTag.localizedLowercase) + } + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy! + self._tagViewModel = StateObject(wrappedValue: TagEditorViewModel(item: .init())) + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.addAccessTag.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + ProgressView() + } + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + if access { + // TODO: Enable on 10.10 + /* tempPolicy.allowedTags = tempPolicy.allowedTags + .appendedOrInit(tempTag) */ + } else { + tempPolicy.blockedTags = tempPolicy.blockedTags + .appendedOrInit(tempTag) + } + + viewModel.send(.updatePolicy(tempPolicy)) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + } + .onFirstAppear { + tagViewModel.send(.load) + } + .onChange(of: tempTag) { _ in + if !tagViewModel.backgroundStates.contains(.loading) { + tagViewModel.send(.search(tempTag)) + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .onReceive(tagViewModel.events) { event in + switch event { + case .updated: + break + case .loaded: + tagViewModel.send(.search(tempTag)) + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + Form { + TagInput( + access: $access, + tag: $tempTag, + tagIsDuplicate: tagIsDuplicate, + tagAlreadyExists: tagAlreadyExists + ) + + SearchResultsSection( + tag: $tempTag, + tags: tagViewModel.matches, + isSearching: tagViewModel.backgroundStates.contains(.searching) + ) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift new file mode 100644 index 00000000..80c5a0bc --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift @@ -0,0 +1,72 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddServerUserAccessTagsView { + + struct SearchResultsSection: View { + + // MARK: - Element Variables + + @Binding + var tag: String + + // MARK: - Element Search Variables + + let tags: [String] + let isSearching: Bool + + // MARK: - Body + + var body: some View { + if tag.isNotEmpty { + Section { + if tags.isNotEmpty { + resultsView + } else if !isSearching { + noResultsView + } + } header: { + HStack { + Text(L10n.existingItems) + + if isSearching { + ProgressView() + } else { + Text("-") + + Text(tags.count, format: .number) + } + } + } + .animation(.linear(duration: 0.2), value: tags) + } + } + + // MARK: - No Results View + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + // MARK: - Results View + + private var resultsView: some View { + ForEach(tags, id: \.self) { result in + Button(result) { + tag = result + } + .foregroundStyle(.primary) + .disabled(tag == result) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift new file mode 100644 index 00000000..76a820e1 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift @@ -0,0 +1,92 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddServerUserAccessTagsView { + + struct TagInput: View { + + // MARK: - Element Variables + + @FocusState + private var isTagFocused: Bool + + @Binding + var access: Bool + @Binding + var tag: String + + let tagIsDuplicate: Bool + let tagAlreadyExists: Bool + + // MARK: - Body + + var body: some View { + // TODO: Enable on 10.10 +// Section { +// Picker(L10n.access, selection: $access) { +// Text(L10n.allowed).tag(true) +// Text(L10n.blocked).tag(false) +// } +// .disabled(true) +// } header: { +// Text(L10n.access) +// } footer: { +// LearnMoreButton(L10n.accessTags) { +// TextPair( +// title: L10n.allowed, +// subtitle: L10n.accessTagAllowDescription +// ) +// TextPair( +// title: L10n.blocked, +// subtitle: L10n.accessTagBlockDescription +// ) +// } +// } + + Section { + TextField(L10n.name, text: $tag) + .autocorrectionDisabled() + .focused($isTagFocused) + } footer: { + if tag.isEmpty { + Label( + L10n.required, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else if tagIsDuplicate { + Label( + L10n.accessTagAlreadyExists, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else { + if tagAlreadyExists { + Label( + L10n.existsOnServer, + systemImage: "checkmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .green)) + } else { + Label( + L10n.willBeCreatedOnServer, + systemImage: "checkmark.seal.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) + } + } + } + .onFirstAppear { + isTagFocused = true + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift new file mode 100644 index 00000000..9f1820a3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift @@ -0,0 +1,48 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension EditServerUserAccessTagsView { + + struct EditAccessTagRow: View { + + // MARK: - Metadata Variables + + let tag: String + + // MARK: - Row Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + HStack { + Text(tag) + .frame(maxWidth: .infinity, alignment: .leading) + + ListRowCheckbox() + } + } + .foregroundStyle(.primary) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift new file mode 100644 index 00000000..b72dc3f4 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift @@ -0,0 +1,231 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct EditServerUserAccessTagsView: View { + + private struct TagWithAccess: Hashable { + let tag: String + let access: Bool + } + + // MARK: - Observed, State, & Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @StateObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Dialog States + + @State + private var isPresentingDeleteConfirmation = false + + // MARK: - Editing States + + @State + private var selectedTags: Set = [] + @State + private var isEditing: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + private var blockedTags: [TagWithAccess] { + viewModel.user.policy?.blockedTags? + .sorted() + .map { TagWithAccess(tag: $0, access: false) } ?? [] + } + +// private var allowedTags: [TagWithAccess] { +// viewModel.user.policy?.allowedTags? +// .sorted() +// .map { TagWithAccess(tag: $0, access: true) } ?? [] +// } + + // MARK: - Initializera + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationTitle(L10n.accessTags) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(L10n.cancel) { + isEditing = false + UIDevice.impact(.light) + selectedTags.removeAll() + } + .buttonStyle(.toolbarPill) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedTags.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || ( + viewModel.user.policy?.blockedTags?.isEmpty == true + ) + ) { + Button(L10n.add, systemImage: "plus") { + router.route(to: \.userAddAccessTag, viewModel) + } + + if viewModel.user.policy?.blockedTags?.isNotEmpty == true { + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + default: + break + } + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteSelectedConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .errorMessage($error) + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + @ViewBuilder + private func makeRow(tag: TagWithAccess) -> some View { + EditAccessTagRow(tag: tag.tag) { + if isEditing { + selectedTags.toggle(value: tag) + } + } onDelete: { + selectedTags = [tag] + isPresentingDeleteConfirmation = true + } + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedTags.contains(tag)) + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + List { + ListTitleSection( + L10n.accessTags, + description: L10n.accessTagsDescription + ) { + UIApplication.shared.open(.jellyfinDocsManagingUsers) + } + + if blockedTags.isEmpty { + Button(L10n.add) { + router.route(to: \.userAddAccessTag, viewModel) + } + } else { + + // TODO: with allowed, use `DisclosureGroup` instead + Section(L10n.blocked) { + ForEach( + blockedTags, + id: \.self, + content: makeRow + ) + } + + // TODO: allowed with 10.10 + } + } + } + + // MARK: - Select/Remove All Button + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected = selectedTags.count == blockedTags.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + selectedTags = isAllSelected ? [] : Set(blockedTags) + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + } + + // MARK: - Delete Selected Confirmation Actions + + @ViewBuilder + private var deleteSelectedConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + var tempPolicy = viewModel.user.policy ?? UserPolicy() + + for tag in selectedTags { + if tag.access { + // tempPolicy.allowedTags?.removeAll { $0 == tag.tag } + } else { + tempPolicy.blockedTags?.removeAll { $0 == tag.tag } + } + } + + viewModel.send(.updatePolicy(tempPolicy)) + selectedTags.removeAll() + isEditing = false + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 71e4f96e..dff381c6 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -109,23 +109,17 @@ struct ServerUserDetailsView: View { } Section(L10n.parentalControls) { + ChevronButton(L10n.ratings) + .onSelect { + router.route(to: \.userParentalRatings, viewModel) + } ChevronButton(L10n.accessSchedules) .onSelect { router.route(to: \.userEditAccessSchedules, viewModel) } - // TODO: Allow items SDK 10.10 - allowedTags - /* ChevronButton("Allow items") - .onSelect { - router.route(to: \.userAllowedTags, viewModel) - } - // TODO: Block items - blockedTags - ChevronButton("Block items") - .onSelect { - router.route(to: \.userBlockedTags, viewModel) - } */ - ChevronButton(L10n.ratings) + ChevronButton(L10n.accessTags) .onSelect { - router.route(to: \.userParentalRatings, viewModel) + router.route(to: \.userEditAccessTags, viewModel) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index e6848816..3433af75 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -22,6 +22,21 @@ /// Define the allowed hours for usage and restrict access outside those times. "accessSchedulesDescription" = "Define the allowed hours for usage and restrict access outside those times."; +/// User will have access to no media unless it contains at least one allowed tag. +"accessTagAllowDescription" = "User will have access to no media unless it contains at least one allowed tag."; + +/// Access tag already exists +"accessTagAlreadyExists" = "Access tag already exists"; + +/// User will have access to all media except when it contains any blocked tag. +"accessTagBlockDescription" = "User will have access to all media except when it contains any blocked tag."; + +/// Access Tags +"accessTags" = "Access Tags"; + +/// Use tags to grant or restrict this user's access to media. +"accessTagsDescription" = "Use tags to grant or restrict this user's access to media."; + /// Active "active" = "Active"; @@ -34,8 +49,11 @@ /// Add "add" = "Add"; -/// Add Access Schedule -"addAccessSchedule" = "Add Access Schedule"; +/// Add access schedule +"addAccessSchedule" = "Add access schedule"; + +/// Add access tag +"addAccessTag" = "Add access tag"; /// Add API key "addAPIKey" = "Add API key"; @@ -88,6 +106,9 @@ /// Allow collection management "allowCollectionManagement" = "Allow collection management"; +/// Allowed +"allowed" = "Allowed"; + /// Allow media item deletion "allowItemDeletion" = "Allow media item deletion"; @@ -271,6 +292,9 @@ /// bps "bitsPerSecond" = "bps"; +/// Blocked +"blocked" = "Blocked"; + /// Block unrated items "blockUnratedItems" = "Block unrated items"; @@ -1717,6 +1741,9 @@ /// System "system" = "System"; +/// Tag +"tag" = "Tag"; + /// Tagline "tagline" = "Tagline";