diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 6709e254..7dd1ee7c 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -17,6 +17,13 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { @Root var start = makeStart + // MARK: - Route: User Activity + + @Route(.push) + var activity = makeActivityLogs + @Route(.push) + var activityDetails = makeActivityDetails + // MARK: - Route: Active Sessions @Route(.push) @@ -84,6 +91,18 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { @Route(.push) var apiKeys = makeAPIKeys + // MARK: - Views: User Activity + + @ViewBuilder + func makeActivityLogs() -> some View { + ServerActivityView() + } + + @ViewBuilder + func makeActivityDetails(viewModel: ServerActivityDetailViewModel) -> some View { + ServerActivityDetailsView(viewModel: viewModel) + } + // MARK: - Views: Active Sessions @ViewBuilder diff --git a/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift b/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift new file mode 100644 index 00000000..4b493709 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift @@ -0,0 +1,24 @@ +// +// 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 ActivityLogEntry: Poster { + var displayTitle: String { + name ?? L10n.unknown + } + + var unwrappedIDHashOrZero: Int { + id?.hashValue ?? 0 + } + + var systemImage: String { + "text.document" + } +} diff --git a/Shared/Extensions/JellyfinAPI/LogLevel.swift b/Shared/Extensions/JellyfinAPI/LogLevel.swift new file mode 100644 index 00000000..21e0f223 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/LogLevel.swift @@ -0,0 +1,54 @@ +// +// 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 LogLevel: SystemImageable, Displayable { + public var color: Color { + switch self { + case .trace: + return .gray.opacity(0.7) + case .debug: + return .gray + case .information: + return .blue + case .warning: + return .orange + case .error: + return .red + case .critical: + return .purple + case .none: + return .secondary + } + } + + public var systemImage: String { + switch self { + case .trace: + return "ant" + case .debug: + return "ladybug" + case .information: + return "info.circle" + case .warning: + return "exclamationmark.triangle" + case .error: + return "exclamationmark.circle" + case .critical: + return "xmark.octagon" + case .none: + return "questionmark.circle" + } + } + + public var displayTitle: String { + rawValue + } +} diff --git a/Shared/Extensions/Sequence.swift b/Shared/Extensions/Sequence.swift index 525adbc0..f5c57cc5 100644 --- a/Shared/Extensions/Sequence.swift +++ b/Shared/Extensions/Sequence.swift @@ -14,6 +14,10 @@ extension Sequence { filter { $0[keyPath: keyPath] != nil } } + func first(property: (Element) -> V, equalTo value: V) -> Element? { + first { property($0) == value } + } + func intersection(_ other: some Sequence, using keyPath: KeyPath) -> [Element] { filter { other.contains($0[keyPath: keyPath]) } } @@ -58,6 +62,10 @@ extension Sequence { extension Sequence where Element: Equatable { + func first(equalTo other: Element) -> Element? { + first { $0 == other } + } + /// Returns an array containing the elements of the sequence that /// are also within the given sequence. func intersection(_ other: some Sequence) -> [Element] { diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index aa62727f..7797a308 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -40,6 +40,8 @@ internal enum L10n { internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// Activity internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity") + /// Activity log + internal static let activityLog = L10n.tr("Localizable", "activityLog", fallback: "Activity log") /// Actor internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor") /// Add @@ -392,6 +394,8 @@ internal enum L10n { internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") /// Perform administrative tasks for your Jellyfin server. internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") + /// Date + internal static let date = L10n.tr("Localizable", "date", fallback: "Date") /// Date Added internal static let dateAdded = L10n.tr("Localizable", "dateAdded", fallback: "Date Added") /// Date created @@ -760,6 +764,8 @@ internal enum L10n { internal static let letterer = L10n.tr("Localizable", "letterer", fallback: "Letterer") /// Letter Picker internal static let letterPicker = L10n.tr("Localizable", "letterPicker", fallback: "Letter Picker") + /// Level + internal static let level = L10n.tr("Localizable", "level", fallback: "Level") /// Library internal static let library = L10n.tr("Localizable", "library", fallback: "Library") /// Light @@ -1130,6 +1136,8 @@ internal enum L10n { internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset") /// Reset all settings back to defaults. internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.") + /// Reset the filter values to none. + internal static let resetFilterFooter = L10n.tr("Localizable", "resetFilterFooter", fallback: "Reset the filter values to none.") /// Reset Settings internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings") /// Reset Swiftfin user settings diff --git a/Shared/ViewModels/AdminDashboard/ServerActivityDetailViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerActivityDetailViewModel.swift new file mode 100644 index 00000000..2a83a2b5 --- /dev/null +++ b/Shared/ViewModels/AdminDashboard/ServerActivityDetailViewModel.swift @@ -0,0 +1,104 @@ +// +// 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 Combine +import JellyfinAPI + +final class ServerActivityDetailViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case refresh + } + + // MARK: - State + + enum State: Hashable { + case error(JellyfinAPIError) + case initial + } + + // MARK: - Stateful Variables + + @Published + var backgroundStates: Set = [] + @Published + var state: State = .initial + + // MARK: - Published Variables + + @Published + var log: ActivityLogEntry + @Published + var user: UserDto? + @Published + var item: BaseItemDto? + + // MARK: - Cancellable + + private var getActivityCancellable: AnyCancellable? + + // MARK: - Initialize + + init(log: ActivityLogEntry, user: UserDto?) { + self.log = log + self.user = user + } + + // MARK: - Respond + + func respond(to action: Action) -> State { + switch action { + case .refresh: + getActivityCancellable?.cancel() + getActivityCancellable = Task { + do { + + if let itemID = log.itemID { + self.item = try await getItem(for: itemID) + } else { + self.item = nil + } + + if let userID = log.userID { + self.user = try await getUser(for: userID) + } else { + self.user = nil + } + + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .initial + } + } + + // MARK: - Get the Activity's Item + + private func getItem(for itemID: String) async throws -> BaseItemDto? { + let request = Paths.getItem(itemID: itemID) + let response = try await userSession.client.send(request) + + return response.value + } + + // MARK: - Get the Activity's User + + private func getUser(for userID: String) async throws -> UserDto? { + let request = Paths.getUserByID(userID: userID) + let response = try await userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift new file mode 100644 index 00000000..c6f88833 --- /dev/null +++ b/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift @@ -0,0 +1,83 @@ +// +// 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 Combine +import Foundation +import IdentifiedCollections +import JellyfinAPI + +@MainActor +final class ServerActivityViewModel: PagingLibraryViewModel { + + @Published + var hasUserId: Bool? { + didSet { + self.send(.refresh) + } + } + + @Published + var minDate: Date? { + didSet { + self.send(.refresh) + } + } + + private(set) var users: IdentifiedArrayOf = [] + + private var userTask: AnyCancellable? + + override func respond(to action: Action) -> State { + + switch action { + case .refresh: + userTask?.cancel() + userTask = Task { + do { + let users = try await getUsers() + + print("here") + + await MainActor.run { + self.users = users + _ = super.respond(to: action) + } + } catch { + await MainActor.run { + self.send(.error(.init(L10n.unknownError))) + } + } + } + .asAnyCancellable() + + return .refreshing + default: + return super.respond(to: action) + } + } + + override func get(page: Int) async throws -> [ActivityLogEntry] { + var parameters = Paths.GetLogEntriesParameters() + parameters.limit = pageSize + parameters.hasUserID = hasUserId + parameters.minDate = minDate + parameters.startIndex = page * pageSize + + let request = Paths.getLogEntries(parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } + + private func getUsers() async throws -> IdentifiedArrayOf { + let request = Paths.getUsers() + let response = try await userSession.client.send(request) + + return IdentifiedArray(uniqueElements: response.value) + } +} diff --git a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift index d63e1bb5..22f692b1 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUsersViewModel.swift @@ -10,7 +10,6 @@ import Combine import Foundation import IdentifiedCollections import JellyfinAPI -import OrderedCollections import SwiftUI final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 852e59e4..fac5f03b 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -59,6 +59,16 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; }; 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; + 4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */; }; + 4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; }; + 4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; }; + 4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE38D2DA427870004736A /* ServerActivityView.swift */; }; + 4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */; }; + 4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; }; + 4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; }; + 4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */; }; + 4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */; }; + 4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39E2DA496270004736A /* MediaItemSection.swift */; }; 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */; }; 4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */; }; 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; }; @@ -1318,6 +1328,14 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityViewModel.swift; sourceTree = ""; }; + 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogEntry.swift; sourceTree = ""; }; + 4E2CE38D2DA427870004736A /* ServerActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityView.swift; sourceTree = ""; }; + 4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityEntry.swift; sourceTree = ""; }; + 4E2CE3922DA432BC0004736A /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; + 4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailsView.swift; sourceTree = ""; }; + 4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailViewModel.swift; sourceTree = ""; }; + 4E2CE39E2DA496270004736A /* MediaItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemSection.swift; sourceTree = ""; }; 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementRow.swift; sourceTree = ""; }; 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementView.swift; sourceTree = ""; }; 4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = ""; }; @@ -2308,6 +2326,8 @@ 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */, E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */, 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */, + 4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */, + 4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */, E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */, 4EC50D602C934B3A00FC3D0E /* ServerTasksViewModel.swift */, 4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */, @@ -2370,6 +2390,40 @@ path = MediaComponents; sourceTree = ""; }; + 4E2CE38C2DA427790004736A /* ServerActivity */ = { + isa = PBXGroup; + children = ( + 4E2CE3962DA446810004736A /* ServerActivityDetailsView */, + 4E2CE3952DA446730004736A /* ServerActivityView */, + ); + path = ServerActivity; + sourceTree = ""; + }; + 4E2CE38F2DA42B250004736A /* Components */ = { + isa = PBXGroup; + children = ( + 4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E2CE3952DA446730004736A /* ServerActivityView */ = { + isa = PBXGroup; + children = ( + 4E2CE38F2DA42B250004736A /* Components */, + 4E2CE38D2DA427870004736A /* ServerActivityView.swift */, + ); + path = ServerActivityView; + sourceTree = ""; + }; + 4E2CE3962DA446810004736A /* ServerActivityDetailsView */ = { + isa = PBXGroup; + children = ( + 4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */, + ); + path = ServerActivityDetailsView; + sourceTree = ""; + }; 4E31EF972CFFB9B70053DFE7 /* Components */ = { isa = PBXGroup; children = ( @@ -2551,6 +2605,7 @@ 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */, 4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */, E1DE64902CC6F06C00E423B6 /* Components */, + 4E2CE38C2DA427790004736A /* ServerActivity */, 4EFE80852D3EF8270029CCB6 /* ServerDevices */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4ECF5D8C2D0A780F00F066B1 /* ServerTasks */, @@ -5073,6 +5128,7 @@ isa = PBXGroup; children = ( 4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */, + 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */, E1D37F5B2B9CF02600343D2B /* BaseItemDto */, E1343DAC2D4EE4C8003145A8 /* BaseItemKind.swift */, E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, @@ -5093,6 +5149,7 @@ E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, E12A9EF729499E0100731C3A /* JellyfinClient.swift */, 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */, + 4E2CE3922DA432BC0004736A /* LogLevel.swift */, 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */, E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, @@ -5383,6 +5440,7 @@ isa = PBXGroup; children = ( E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */, + 4E2CE39E2DA496270004736A /* MediaItemSection.swift */, 4E10C81C2CC0465F0012CC9F /* UserSection.swift */, ); path = Components; @@ -6004,6 +6062,7 @@ E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */, + 4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */, 4EF36F652D962A430065BB79 /* ItemSortBy.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */, @@ -6019,6 +6078,7 @@ E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */, E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1DD55382B6EE533007501C0 /* Task.swift in Sources */, + 4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, @@ -6503,6 +6563,7 @@ 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */, 4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */, 4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */, + 4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */, 4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */, 4E661A162CEFE46300025C99 /* ParentialRatingsSection.swift in Sources */, 4E661A172CEFE46300025C99 /* OverviewSection.swift in Sources */, @@ -6527,6 +6588,7 @@ 4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */, + 4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */, E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */, @@ -6673,9 +6735,11 @@ E168BD10289A4162001A6922 /* HomeView.swift in Sources */, 4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */, 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */, + 4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */, 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, + 4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */, 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */, E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, @@ -6716,6 +6780,7 @@ C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */, 4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */, + 4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */, E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, @@ -6783,6 +6848,7 @@ 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */, 4E49DED32CE54D6D00352DCD /* ActiveSessionsPolicy.swift in Sources */, 4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */, + 4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */, 4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */, 4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */, 4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */, @@ -6922,6 +6988,7 @@ E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */, + 4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */, 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */, E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */, E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */, @@ -6963,6 +7030,7 @@ E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */, + 4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */, E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift index 7d90fdbd..9b9c8419 100644 --- a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift @@ -53,7 +53,7 @@ struct ActiveSessionDetailView: View { ) -> some View { List { - nowPlayingSection(item: nowPlayingItem) + AdminDashboardView.MediaItemSection(item: nowPlayingItem) Section(L10n.progress) { ActiveSessionsView.ProgressSection( @@ -101,67 +101,6 @@ struct ActiveSessionDetailView: View { } } - // MARK: Now Playing Section - - @ViewBuilder - private func nowPlayingSection(item: BaseItemDto) -> some View { - Section { - HStack(alignment: .bottom, spacing: 12) { - Group { - if item.type == .audio { - ZStack { - Color.clear - - ImageView(item.squareImageSources(maxWidth: 60)) - .failure { - SystemImageContentView(systemName: item.systemImage) - } - } - .squarePosterStyle() - } else { - ZStack { - Color.clear - - ImageView(item.portraitImageSources(maxWidth: 60)) - .failure { - SystemImageContentView(systemName: item.systemImage) - } - } - .posterStyle(.portrait) - } - } - .frame(width: 100) - .accessibilityIgnoresInvertColors() - - VStack(alignment: .leading) { - - if let parent = item.parentTitle { - Text(parent) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - Text(item.displayTitle) - .font(.title2) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .lineLimit(2) - - if let subtitle = item.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.bottom) - } - } - .listRowBackground(Color.clear) - .listRowCornerRadius(0) - .listRowInsets(.zero) - } - var body: some View { ZStack { if let session = box.value { diff --git a/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift index 265de567..5fef5c71 100644 --- a/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift +++ b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift @@ -28,6 +28,9 @@ struct AdminDashboardView: View { } Section(L10n.activity) { + ChevronButton(L10n.activity) { + router.route(to: \.activity) + } ChevronButton(L10n.devices) { router.route(to: \.devices) } diff --git a/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift b/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift new file mode 100644 index 00000000..e5f408a3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift @@ -0,0 +1,76 @@ +// +// 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 AdminDashboardView { + + struct MediaItemSection: View { + + let item: BaseItemDto + + var body: some View { + Section { + HStack(alignment: .bottom, spacing: 12) { + Group { + if item.type == .audio { + ZStack { + Color.clear + + ImageView(item.squareImageSources(maxWidth: 60)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .squarePosterStyle() + } else { + ZStack { + Color.clear + + ImageView(item.portraitImageSources(maxWidth: 60)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .posterStyle(.portrait) + } + } + .frame(width: 100) + .accessibilityIgnoresInvertColors() + + VStack(alignment: .leading) { + + if let parent = item.parentTitle { + Text(parent) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(item.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + + if let subtitle = item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.bottom) + } + } + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift new file mode 100644 index 00000000..ed654cef --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift @@ -0,0 +1,87 @@ +// +// 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 ServerActivityDetailsView: View { + + // MARK: - Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + // MARK: - Activity Log Entry Variable + + @StateObject + var viewModel: ServerActivityDetailViewModel + + // MARK: - Body + + var body: some View { + List { + /// Item (If Available) + if let item = viewModel.item { + AdminDashboardView.MediaItemSection(item: item) + } + + /// User (If Available) + if let user = viewModel.user { + AdminDashboardView.UserSection( + user: user, + lastActivityDate: viewModel.log.date + ) { + router.route(to: \.userDetails, user) + } + } + + /// Event Name & Overview + Section(L10n.overview) { + if let name = viewModel.log.name, name.isNotEmpty { + Text(name) + } + if let overview = viewModel.log.overview, overview.isNotEmpty { + Text(overview) + } else if let shortOverview = viewModel.log.shortOverview, shortOverview.isNotEmpty { + Text(shortOverview) + } + } + + /// Event Details + Section(L10n.details) { + if let severity = viewModel.log.severity { + TextPairView( + leading: L10n.level, + trailing: severity.displayTitle + ) + } + if let type = viewModel.log.type { + TextPairView( + leading: L10n.type, + trailing: type + ) + } + if let date = viewModel.log.date { + TextPairView( + leading: L10n.date, + trailing: date.formatted(date: .long, time: .shortened) + ) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle( + L10n.activityLog + .localizedCapitalized + ) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.refresh) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift new file mode 100644 index 00000000..7e73d909 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift @@ -0,0 +1,102 @@ +// +// 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 ServerActivityView { + + struct LogEntry: View { + + // MARK: - Activity Log Entry Variable + + @StateObject + var viewModel: ServerActivityDetailViewModel + + // MARK: - Action Variable + + let action: () -> Void + + // MARK: - Body + + var body: some View { + ListRow { + userImage + } content: { + rowContent + .padding(.bottom, 8) + } + .onSelect(perform: action) + } + + // MARK: - User Image + + @ViewBuilder + private var userImage: some View { + let imageSource = viewModel.user?.profileImageSource(client: viewModel.userSession.client, maxWidth: 60) ?? .init() + + UserProfileImage( + userID: viewModel.log.userID ?? viewModel.userSession?.user.id, + source: imageSource + ) { + SystemImageContentView( + systemName: viewModel.user != nil ? "person.fill" : "gearshape.fill", + ratio: 0.5 + ) + } + .frame(width: 60, height: 60) + } + + // MARK: - User Image + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + /// Event Severity & Username / System + HStack(spacing: 8) { + Image(systemName: viewModel.log.severity?.systemImage ?? "questionmark.circle") + .foregroundStyle(viewModel.log.severity?.color ?? .gray) + + if viewModel.user != nil { + Text(viewModel.user?.name ?? L10n.unknown) + } else { + Text(L10n.system) + } + } + .font(.headline) + + /// Event Name + Text(viewModel.log.name ?? .emptyDash) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Group { + if let eventDate = viewModel.log.date { + Text(eventDate.formatted(date: .abbreviated, time: .standard)) + } else { + Text(String.emptyTime) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "chevron.right") + .padding() + .font(.body.weight(.regular)) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift new file mode 100644 index 00000000..5eb17dca --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift @@ -0,0 +1,198 @@ +// +// 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 CollectionVGrid +import JellyfinAPI +import SwiftUI + +struct ServerActivityView: View { + + // MARK: - Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + // MARK: - State Objects + + @StateObject + private var viewModel = ServerActivityViewModel() + + // MARK: - Dialog States + + @State + private var isDatePickerShowing: Bool = false + @State + private var tempDate: Date? + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.activity) + .navigationBarTitleDisplayMode(.inline) + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.gettingNextPage) + ) { + Section(L10n.filters) { + startDateButton + userFilterButton + } + } + .onFirstAppear { + viewModel.send(.refresh) + } + .sheet(isPresented: $isDatePickerShowing, onDismiss: { isDatePickerShowing = false }) { + startDatePickerSheet + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.elements.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + CollectionVGrid( + uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, + layout: .columns(1) + ) { log in + + let user = viewModel.users.first( + property: \.id, + equalTo: log.userID + ) + + let logViewModel = ServerActivityDetailViewModel( + log: log, + user: user + ) + + LogEntry(viewModel: logViewModel) { + router.route(to: \.activityDetails, logViewModel) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + .frame(maxWidth: .infinity) + } + } + + // MARK: - User Filter Button + + @ViewBuilder + private var userFilterButton: some View { + Menu( + L10n.type, + systemImage: viewModel.hasUserId == true ? "person.fill" : + viewModel.hasUserId == false ? "gearshape.fill" : "line.3.horizontal" + ) { + Picker(L10n.type, selection: $viewModel.hasUserId) { + Section { + Label( + L10n.all, + systemImage: "line.3.horizontal" + ) + .tag(nil as Bool?) + } + + Label( + L10n.users, + systemImage: "person" + ) + .tag(true as Bool?) + + Label( + L10n.system, + systemImage: "gearshape" + ) + .tag(false as Bool?) + } + } + } + + // MARK: - Start Date Button + + @ViewBuilder + private var startDateButton: some View { + Button(L10n.startDate, systemImage: "calendar") { + if let minDate = viewModel.minDate { + tempDate = minDate + } else { + tempDate = .now + } + isDatePickerShowing = true + } + } + + // MARK: - Start Date Picker Sheet + + @ViewBuilder + private var startDatePickerSheet: some View { + NavigationView { + List { + Section { + DatePicker( + L10n.date, + selection: $tempDate.coalesce(.now), + in: ...Date.now, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + .labelsHidden() + } + + /// Reset button to remove the filter + if viewModel.minDate != nil { + Section { + ListRowButton(L10n.reset, role: .destructive) { + viewModel.minDate = nil + isDatePickerShowing = false + } + } footer: { + Text(L10n.resetFilterFooter) + } + } + } + .navigationTitle(L10n.startDate.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + isDatePickerShowing = false + } + .topBarTrailing { + let startOfDay = Calendar.current + .startOfDay(for: tempDate ?? .now) + + Button(L10n.save) { + viewModel.minDate = startOfDay + isDatePickerShowing = false + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate) + } + } + } +} diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 182b7e2e..cae61aec 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ