mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge commit 'd33d383891c7b68ae5bc9e0f8922004af7556f44' into OMN-382
This commit is contained in:
commit
ecdbed4a5c
87 changed files with 3742 additions and 1076 deletions
|
|
@ -1156,7 +1156,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp";
|
||||
|
|
@ -1182,7 +1182,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1200,7 +1200,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/iOSUITests.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.5;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1225,7 +1225,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/iOSUITests.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.5;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1260,7 +1260,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
|
@ -1291,7 +1291,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
|
||||
|
|
@ -1316,7 +1316,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac";
|
||||
|
|
@ -1341,7 +1341,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.3;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1373,7 +1373,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
|
@ -1407,7 +1407,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1458,7 +1458,7 @@
|
|||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = InfoPlists/Omnivore.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1572,7 +1572,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
|
@ -1610,7 +1610,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1674,8 +1674,8 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
|
|
@ -1690,7 +1690,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1745,7 +1745,7 @@
|
|||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = InfoPlists/Omnivore.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1772,7 +1772,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
@ -1843,8 +1843,8 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ let package = Package(
|
|||
name: "OmnivoreKit",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v14),
|
||||
.macOS(.v11)
|
||||
.iOS(.v15),
|
||||
.macOS(.v12)
|
||||
],
|
||||
products: [
|
||||
.library(name: "App", targets: ["App"]),
|
||||
|
|
@ -17,7 +17,7 @@ let package = Package(
|
|||
.library(name: "Utils", targets: ["Utils"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "AppAuth", url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
|
||||
.package(url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
|
||||
.package(url: "https://github.com/Square/Valet", from: "4.1.2"),
|
||||
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"),
|
||||
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4")
|
||||
|
|
@ -37,7 +37,7 @@ let package = Package(
|
|||
.target(
|
||||
name: "Services",
|
||||
dependencies: [
|
||||
"AppAuth",
|
||||
.product(name: "AppAuth", package: "AppAuth-iOS"),
|
||||
"Valet",
|
||||
.product(name: "SwiftGraphQL", package: "swift-graphql"),
|
||||
"Models",
|
||||
|
|
|
|||
|
|
@ -14,63 +14,43 @@ import Views
|
|||
|
||||
var body: some View {
|
||||
Group {
|
||||
if #available(iOS 15.0, *) {
|
||||
HomeFeedView(
|
||||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.refreshable {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
HomeFeedView(
|
||||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.refreshable {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.searchable(
|
||||
text: $viewModel.searchTerm,
|
||||
placement: .navigationBarDrawer
|
||||
) {
|
||||
if viewModel.searchTerm.isEmpty {
|
||||
Text("Inbox").searchCompletion("in:inbox ")
|
||||
Text("All").searchCompletion("in:all ")
|
||||
Text("Archived").searchCompletion("in:archive ")
|
||||
Text("Files").searchCompletion("type:file ")
|
||||
}
|
||||
.searchable(
|
||||
text: $viewModel.searchQuery,
|
||||
placement: .sidebar
|
||||
) {
|
||||
if viewModel.searchQuery.isEmpty {
|
||||
Text("Inbox").searchCompletion("in:inbox ")
|
||||
Text("All").searchCompletion("in:all ")
|
||||
Text("Archived").searchCompletion("in:archive ")
|
||||
Text("Files").searchCompletion("type:file ")
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.searchQuery) { _ in
|
||||
// Maybe we should debounce this, but
|
||||
// it feels like it works ok without
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(item: item) { labels in
|
||||
viewModel.updateLabels(itemID: item.id, labels: labels)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeFeedView(
|
||||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(item: item) { labels in
|
||||
viewModel.updateLabels(itemID: item.id, labels: labels)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
if viewModel.isLoading {
|
||||
Button(action: {}, label: { ProgressView() })
|
||||
} else {
|
||||
Button(
|
||||
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
|
||||
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.searchTerm) { _ in
|
||||
// Maybe we should debounce this, but
|
||||
// it feels like it works ok without
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.selectedLabels) { _ in
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(mode: .item(item)) { labels in
|
||||
viewModel.updateLabels(itemID: item.id, labels: labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
// Don't refresh the list if the user is currently reading an article
|
||||
if viewModel.selectedLinkItem == nil {
|
||||
|
|
@ -99,26 +79,44 @@ import Views
|
|||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedLinkItem) { _ in
|
||||
viewModel.commitProgressUpdates()
|
||||
viewModel.commitItemUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeFeedView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@Binding var prefersListLayout: Bool
|
||||
|
||||
@State private var showLabelsSheet = false
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
if prefersListLayout {
|
||||
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
if #available(iOS 15.0, *) {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
TextChipButton.makeAddLabelButton {
|
||||
showLabelsSheet = true
|
||||
}
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.sheet(isPresented: $showLabelsSheet) {
|
||||
ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in
|
||||
viewModel.selectedLabels = labels
|
||||
}
|
||||
}
|
||||
}
|
||||
if prefersListLayout {
|
||||
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button("", action: {})
|
||||
.disabled(true)
|
||||
.overlay {
|
||||
|
|
@ -126,28 +124,19 @@ import Views
|
|||
ProgressView()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if viewModel.isLoading {
|
||||
Button(action: {}, label: { ProgressView() })
|
||||
} else {
|
||||
}
|
||||
ToolbarItem {
|
||||
if UIDevice.isIPad {
|
||||
Button(
|
||||
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
|
||||
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem {
|
||||
if UIDevice.isIPad {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,11 +154,15 @@ import Views
|
|||
List {
|
||||
Section {
|
||||
ForEach(viewModel.items) { item in
|
||||
let link = FeedCardNavigationLink(
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
|
||||
|
|
@ -196,61 +189,56 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
if #available(iOS 15.0, *) {
|
||||
link
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !item.isArchived {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
|
||||
}
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}.tint(.green)
|
||||
} else {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
|
||||
}.tint(.indigo)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !item.isArchived {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
|
||||
}
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}.tint(.green)
|
||||
} else {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
|
||||
}.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(
|
||||
role: .destructive,
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
)
|
||||
}.alert("Are you sure?", isPresented: $confirmationShown) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(
|
||||
role: .destructive,
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
)
|
||||
}.alert("Are you sure?", isPresented: $confirmationShown) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnooze = item
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
}.tint(.appYellow48)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
link
|
||||
self.itemToRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnooze = item
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
}.tint(.appYellow48)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -303,27 +291,22 @@ import Views
|
|||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) {
|
||||
ForEach(viewModel.items) { item in
|
||||
let link = GridCardNavigationLink(
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
if #available(iOS 15.0, *) {
|
||||
link
|
||||
.alert("Are you sure?", isPresented: $confirmationShown) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
.alert("Are you sure?", isPresented: $confirmationShown) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
} else {
|
||||
link
|
||||
self.itemToRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ import Views
|
|||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
if #available(macOS 12.0, *) {
|
||||
innerBody
|
||||
} else {
|
||||
innerBodyMac11
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.0, *)
|
||||
var innerBody: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(viewModel.items) { item in
|
||||
|
|
@ -77,17 +68,17 @@ import Views
|
|||
.listStyle(PlainListStyle())
|
||||
.navigationTitle("Home")
|
||||
.searchable(
|
||||
text: $viewModel.searchQuery,
|
||||
text: $viewModel.searchTerm,
|
||||
placement: .toolbar
|
||||
) {
|
||||
if viewModel.searchQuery.isEmpty {
|
||||
if viewModel.searchTerm.isEmpty {
|
||||
Text("Inbox").searchCompletion("in:inbox ")
|
||||
Text("All").searchCompletion("in:all ")
|
||||
Text("Archived").searchCompletion("in:archive ")
|
||||
Text("Files").searchCompletion("type:file ")
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.searchQuery) { _ in
|
||||
.onChange(of: viewModel.searchTerm) { _ in
|
||||
// Maybe we should debounce this, but
|
||||
// it feels like it works ok without
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
|
|
@ -118,40 +109,6 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var innerBodyMac11: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoading {
|
||||
LoadingSection()
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.navigationTitle("Home")
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
},
|
||||
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel.items.isEmpty {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
/// Track progress updates to be committed when user navigates back to grid view
|
||||
var uncommittedReadingProgressUpdates = [String: Double]()
|
||||
|
||||
/// Track label updates to be committed when user navigates back to grid view
|
||||
var uncommittedLabelUpdates = [String: [FeedItemLabel]]()
|
||||
|
||||
@Published var items = [FeedItem]()
|
||||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: FeedItem?
|
||||
@Published var searchQuery = ""
|
||||
@Published var searchTerm = ""
|
||||
@Published var selectedLabels = [FeedItemLabel]()
|
||||
@Published var snoozePresented = false
|
||||
@Published var itemToSnooze: FeedItem?
|
||||
@Published var selectedLinkItem: FeedItem?
|
||||
|
|
@ -68,7 +72,7 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
dataService.libraryItemsPublisher(
|
||||
limit: 10,
|
||||
sortDescending: true,
|
||||
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
|
||||
searchQuery: searchQuery,
|
||||
cursor: isRefresh ? nil : cursor
|
||||
)
|
||||
.sink(
|
||||
|
|
@ -172,14 +176,18 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
/// Update `FeedItem`s with the cached reading progress values so it can animate when the
|
||||
/// Update `FeedItem`s with the cached reading progress and label values so it can animate when the
|
||||
/// user navigates back to the grid view (and also avoid mutations of the grid items
|
||||
/// that can cause the `NavigationView` to pop.
|
||||
func commitProgressUpdates() {
|
||||
func commitItemUpdates() {
|
||||
for (key, value) in uncommittedReadingProgressUpdates {
|
||||
updateProgress(itemID: key, progress: value)
|
||||
}
|
||||
for (key, value) in uncommittedLabelUpdates {
|
||||
updateLabels(itemID: key, labels: value)
|
||||
}
|
||||
uncommittedReadingProgressUpdates = [:]
|
||||
uncommittedLabelUpdates = [:]
|
||||
}
|
||||
|
||||
private func updateProgress(itemID: String, progress: Double) {
|
||||
|
|
@ -190,9 +198,31 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
|
||||
// If item is being being displayed then delay the state update of labels until
|
||||
// user is no longer reading the item.
|
||||
if selectedLinkItem != nil {
|
||||
uncommittedLabelUpdates[itemID] = labels
|
||||
return
|
||||
}
|
||||
|
||||
guard let item = items.first(where: { $0.id == itemID }) else { return }
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items[index].labels = labels
|
||||
}
|
||||
}
|
||||
|
||||
private var searchQuery: String? {
|
||||
if searchTerm.isEmpty, selectedLabels.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
var query = searchTerm
|
||||
|
||||
if !selectedLabels.isEmpty {
|
||||
query.append(" label:")
|
||||
query.append(selectedLabels.map(\.name).joined(separator: ","))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,30 @@ import SwiftUI
|
|||
import Views
|
||||
|
||||
struct ApplyLabelsView: View {
|
||||
let item: FeedItem
|
||||
enum Mode {
|
||||
case item(FeedItem)
|
||||
case list([FeedItemLabel])
|
||||
|
||||
var navTitle: String {
|
||||
switch self {
|
||||
case .item:
|
||||
return "Assign Labels"
|
||||
case .list:
|
||||
return "Apply Label Filters"
|
||||
}
|
||||
}
|
||||
|
||||
var confirmButtonText: String {
|
||||
switch self {
|
||||
case .item:
|
||||
return "Save"
|
||||
case .list:
|
||||
return "Apply"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let commitLabelChanges: ([FeedItemLabel]) -> Void
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
|
@ -15,10 +38,10 @@ struct ApplyLabelsView: View {
|
|||
var innerBody: some View {
|
||||
List {
|
||||
Section(header: Text("Assigned Labels")) {
|
||||
if viewModel.selectedLabelsForItemInContext.isEmpty {
|
||||
if viewModel.selectedLabels.isEmpty {
|
||||
Text("No labels are currently assigned.")
|
||||
}
|
||||
ForEach(viewModel.selectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
|
|
@ -28,13 +51,13 @@ struct ApplyLabelsView: View {
|
|||
viewModel.removeLabelFromItem(label)
|
||||
}
|
||||
},
|
||||
label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) }
|
||||
label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Available Labels")) {
|
||||
ForEach(viewModel.unselectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
|
|
@ -63,7 +86,7 @@ struct ApplyLabelsView: View {
|
|||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Assign Labels")
|
||||
.navigationTitle(mode.navTitle)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
@ -76,12 +99,18 @@ struct ApplyLabelsView: View {
|
|||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in
|
||||
commitLabelChanges(labels)
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in
|
||||
commitLabelChanges(labels)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
case .list:
|
||||
commitLabelChanges(viewModel.selectedLabels)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
},
|
||||
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
|
||||
label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -97,22 +126,23 @@ struct ApplyLabelsView: View {
|
|||
EmptyView()
|
||||
} else {
|
||||
#if os(iOS)
|
||||
if #available(iOS 15.0, *) {
|
||||
innerBody
|
||||
.searchable(
|
||||
text: $labelSearchFilter,
|
||||
placement: .navigationBarDrawer(displayMode: .always)
|
||||
)
|
||||
} else {
|
||||
innerBody
|
||||
}
|
||||
innerBody
|
||||
.searchable(
|
||||
text: $labelSearchFilter,
|
||||
placement: .navigationBarDrawer(displayMode: .always)
|
||||
)
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadLabels(dataService: dataService, item: item)
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
viewModel.loadLabels(dataService: dataService, item: feedItem)
|
||||
case let .list(labels):
|
||||
viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Combine
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct LabelsView: View {
|
||||
|
|
@ -15,25 +16,20 @@ struct LabelsView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if #available(iOS 15.0, *) {
|
||||
Form {
|
||||
innerBody
|
||||
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
if let labelID = labelToRemoveID {
|
||||
withAnimation {
|
||||
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
|
||||
}
|
||||
Form {
|
||||
innerBody
|
||||
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Delete Label", role: .destructive) {
|
||||
if let labelID = labelToRemoveID {
|
||||
withAnimation {
|
||||
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
|
||||
}
|
||||
self.labelToRemoveID = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
|
||||
self.labelToRemoveID = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Form { innerBody }
|
||||
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
|
|
@ -92,49 +88,141 @@ struct CreateLabelView: View {
|
|||
@State private var newLabelName = ""
|
||||
@State private var newLabelColor = Color.clear
|
||||
|
||||
var shouldDisableCreateButton: Bool {
|
||||
viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear
|
||||
}
|
||||
|
||||
let rows = [
|
||||
GridItem(.fixed(60)),
|
||||
GridItem(.fixed(60)),
|
||||
GridItem(.fixed(70))
|
||||
]
|
||||
|
||||
let swatches: [Color] = {
|
||||
let webSwatches = webSwatchHexes.map { Color(hex: $0) ?? .clear }
|
||||
var additionalSwatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled()
|
||||
let firstSwatch = additionalSwatches.remove(at: 0)
|
||||
return [firstSwatch] + webSwatches + additionalSwatches
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 16) {
|
||||
VStack {
|
||||
HStack {
|
||||
if !newLabelName.isEmpty, newLabelColor != .clear {
|
||||
TextChip(text: newLabelName, color: newLabelColor)
|
||||
} else {
|
||||
Text("Assign a name and color.").font(.appBody)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
TextField("Label Name", text: $newLabelName)
|
||||
#if os(iOS)
|
||||
.keyboardType(.alphabet)
|
||||
#endif
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
ColorPicker(
|
||||
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
|
||||
selection: $newLabelColor
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
viewModel.createLabel(
|
||||
dataService: dataService,
|
||||
name: newLabelName,
|
||||
color: newLabelColor,
|
||||
description: nil
|
||||
)
|
||||
},
|
||||
label: { Text("Create") }
|
||||
)
|
||||
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
|
||||
.disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: rows, alignment: .top, spacing: 20) {
|
||||
ForEach(swatches, id: \.self) { swatch in
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(swatch)
|
||||
.frame(width: 50, height: 50)
|
||||
.onTapGesture {
|
||||
newLabelColor = swatch
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
if newLabelColor == swatch {
|
||||
Circle()
|
||||
.stroke(swatch, lineWidth: 5)
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
Button(
|
||||
action: { viewModel.showCreateEmailModal = false },
|
||||
label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.createLabel(
|
||||
dataService: dataService,
|
||||
name: newLabelName,
|
||||
color: newLabelColor,
|
||||
description: nil
|
||||
)
|
||||
},
|
||||
label: { Text("Create").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
.opacity(shouldDisableCreateButton ? 0.2 : 1)
|
||||
.disabled(shouldDisableCreateButton)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create New Label")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
newLabelColor = swatches.first ?? .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let webSwatchHexes = [
|
||||
"#FF5D99",
|
||||
"#7CFF7B",
|
||||
"#FFD234",
|
||||
"#7BE4FF",
|
||||
"#CE88EF",
|
||||
"#EF8C43"
|
||||
]
|
||||
|
||||
private let swatchHexes = [
|
||||
"#fff034",
|
||||
"#efff34",
|
||||
"#d1ff34",
|
||||
"#b2ff34",
|
||||
"#94ff34",
|
||||
"#75ff34",
|
||||
"#57ff34",
|
||||
"#38ff34",
|
||||
"#34ff4e",
|
||||
"#34ff6d",
|
||||
"#34ff8b",
|
||||
"#34ffa9",
|
||||
"#34ffc8",
|
||||
"#34ffe6",
|
||||
"#34f9ff",
|
||||
"#34dbff",
|
||||
"#34bcff",
|
||||
"#349eff",
|
||||
"#347fff",
|
||||
"#3461ff",
|
||||
"#3443ff",
|
||||
"#4434ff",
|
||||
"#6234ff",
|
||||
"#8134ff",
|
||||
"#9f34ff",
|
||||
"#be34ff",
|
||||
"#dc34ff",
|
||||
"#fb34ff",
|
||||
"#ff34e5",
|
||||
"#ff34c7",
|
||||
"#ff34a8",
|
||||
"#ff348a",
|
||||
"#ff346b"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,14 +7,19 @@ import Views
|
|||
final class LabelsViewModel: ObservableObject {
|
||||
private var hasLoadedInitialLabels = false
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabelsForItemInContext = [FeedItemLabel]()
|
||||
@Published var unselectedLabelsForItemInContext = [FeedItemLabel]()
|
||||
@Published var selectedLabels = [FeedItemLabel]()
|
||||
@Published var unselectedLabels = [FeedItemLabel]()
|
||||
@Published var labels = [FeedItemLabel]()
|
||||
@Published var showCreateEmailModal = false
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
func loadLabels(dataService: DataService, item: FeedItem?) {
|
||||
/// Loads initial set of labels when a edit labels list is displayed
|
||||
/// - Parameters:
|
||||
/// - dataService: `DataService` reference
|
||||
/// - item: Optional `FeedItem` for applying labels to a single item
|
||||
/// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items
|
||||
func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) {
|
||||
guard !hasLoadedInitialLabels else { return }
|
||||
isLoading = true
|
||||
|
||||
|
|
@ -25,11 +30,17 @@ final class LabelsViewModel: ObservableObject {
|
|||
self?.labels = allLabels
|
||||
self?.hasLoadedInitialLabels = true
|
||||
if let item = item {
|
||||
self?.selectedLabelsForItemInContext = item.labels
|
||||
self?.unselectedLabelsForItemInContext = allLabels.filter { label in
|
||||
self?.selectedLabels = item.labels
|
||||
self?.unselectedLabels = allLabels.filter { label in
|
||||
!item.labels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
}
|
||||
if let initiallySelectedLabels = initiallySelectedLabels {
|
||||
self?.selectedLabels = initiallySelectedLabels
|
||||
self?.unselectedLabels = allLabels.filter { label in
|
||||
!initiallySelectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
|
|
@ -49,7 +60,7 @@ final class LabelsViewModel: ObservableObject {
|
|||
receiveValue: { [weak self] result in
|
||||
self?.isLoading = false
|
||||
self?.labels.insert(result, at: 0)
|
||||
self?.unselectedLabelsForItemInContext.insert(result, at: 0)
|
||||
self?.unselectedLabels.insert(result, at: 0)
|
||||
self?.showCreateEmailModal = false
|
||||
}
|
||||
)
|
||||
|
|
@ -73,7 +84,7 @@ final class LabelsViewModel: ObservableObject {
|
|||
|
||||
func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
|
||||
isLoading = true
|
||||
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabelsForItemInContext.map(\.id)).sink(
|
||||
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
},
|
||||
|
|
@ -83,12 +94,12 @@ final class LabelsViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func addLabelToItem(_ label: FeedItemLabel) {
|
||||
selectedLabelsForItemInContext.insert(label, at: 0)
|
||||
unselectedLabelsForItemInContext.removeAll { $0.id == label.id }
|
||||
selectedLabels.insert(label, at: 0)
|
||||
unselectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
|
||||
func removeLabelFromItem(_ label: FeedItemLabel) {
|
||||
unselectedLabelsForItemInContext.insert(label, at: 0)
|
||||
selectedLabelsForItemInContext.removeAll { $0.id == label.id }
|
||||
unselectedLabels.insert(label, at: 0)
|
||||
selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,39 +157,6 @@ struct LinkItemDetailView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
var navBariOS14: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
action: { self.presentationMode.wrappedValue.dismiss() },
|
||||
label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appTitleTwo)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.0, *)
|
||||
@available(iOS 15.0, *)
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
|
|
@ -286,47 +253,25 @@ struct LinkItemDetailView: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
if #available(iOS 15.0, *) {
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
navBariOS14
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
if #available(iOS 15.0, *) {
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadWebAppWrapper(
|
||||
dataService: dataService,
|
||||
rawAuthCookie: authenticator.omnivoreAuthCookieString
|
||||
)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
navBariOS14
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadWebAppWrapper(
|
||||
dataService: dataService,
|
||||
rawAuthCookie: authenticator.omnivoreAuthCookieString
|
||||
)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadWebAppWrapper(
|
||||
dataService: dataService,
|
||||
rawAuthCookie: authenticator.omnivoreAuthCookieString
|
||||
)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -51,10 +51,8 @@ public struct PrimaryContentView: View {
|
|||
.introspectSplitViewController {
|
||||
$0.preferredSplitBehavior = .tile
|
||||
$0.preferredPrimaryColumnWidth = 200
|
||||
if #available(iOS 14.5, *) {
|
||||
$0.presentsWithGesture = false
|
||||
$0.displayModeButtonVisibility = .always
|
||||
}
|
||||
$0.presentsWithGesture = false
|
||||
$0.displayModeButtonVisibility = .always
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -74,39 +74,6 @@ import WebKit
|
|||
}
|
||||
}
|
||||
|
||||
var navBariOS14: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
action: { self.presentationMode.wrappedValue.dismiss() },
|
||||
label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appTitleTwo)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.0, *)
|
||||
@available(iOS 15.0, *)
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
|
|
@ -132,6 +99,10 @@ import WebKit
|
|||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { homeFeedViewModel.itemUnderLabelEdit = item },
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
homeFeedViewModel.setLinkArchived(
|
||||
|
|
@ -260,19 +231,11 @@ import WebKit
|
|||
}
|
||||
)
|
||||
}
|
||||
if #available(iOS 15.0, *) {
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
navBariOS14
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
VStack(spacing: 0) {
|
||||
navBar
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
||||
}.onDisappear {
|
||||
// Clear the shared webview content when exiting
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public enum PageScraper {
|
|||
}
|
||||
|
||||
var pageScrapePayload: PageScrapePayload?
|
||||
let propertyListKey = String(kUTTypePropertyList)
|
||||
let propertyListKey = UTType.propertyList.identifier
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ public enum PageScraper {
|
|||
extensionContext: NSExtensionContext?,
|
||||
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
|
||||
) {
|
||||
let urlKey = String(kUTTypeURL)
|
||||
let urlKey = UTType.url.identifier
|
||||
|
||||
// First look for a URL type
|
||||
let urlFound = extensionContext?.inputItems.first { inputItem in
|
||||
|
|
@ -108,7 +108,7 @@ public enum PageScraper {
|
|||
return
|
||||
}
|
||||
|
||||
let textKey = String(kUTTypeText)
|
||||
let textKey = UTType.text.identifier
|
||||
// We didn't find a URL type, so check for a string that contains a URL
|
||||
let textUrlFound = extensionContext?.inputItems.first { inputItem in
|
||||
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
|
||||
|
|
@ -150,8 +150,8 @@ public enum PageScraper {
|
|||
extensionContext: NSExtensionContext?,
|
||||
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
|
||||
) {
|
||||
let urlKey = String(kUTTypeURL)
|
||||
let textKey = String(kUTTypeUTF16PlainText)
|
||||
let urlKey = UTType.url.identifier
|
||||
let textKey = UTType.utf16PlainText.identifier
|
||||
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem
|
||||
let itemProvider = inputItem?.attachments?.first(where: { attachment in
|
||||
attachment.hasItemConformingToTypeIdentifier(urlKey)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,7 @@ public extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
linkId: itemID,
|
||||
pageId: itemID,
|
||||
labelIds: labelIDs
|
||||
),
|
||||
selection: selection
|
||||
|
|
|
|||
|
|
@ -22,18 +22,14 @@ public extension Color {
|
|||
static var systemBackground: Color { Color(.systemBackground) }
|
||||
static var systemPlaceholder: Color { Color(.placeholderText) }
|
||||
static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) }
|
||||
static var systemLabel: Color {
|
||||
if #available(iOS 15.0, *) {
|
||||
return Color(uiColor: .label)
|
||||
} else {
|
||||
return Color.appGrayTextContrast
|
||||
}
|
||||
}
|
||||
static var systemGray6: Color { Color(.systemGray6) }
|
||||
static var systemLabel: Color { Color(uiColor: .label) }
|
||||
|
||||
#elseif os(macOS)
|
||||
static var systemBackground: Color { Color(.windowBackgroundColor) }
|
||||
static var systemPlaceholder: Color { Color(.placeholderTextColor) }
|
||||
static var systemLabel: Color { Color(.labelColor) }
|
||||
static var systemGray6: Color { Color(NSColor.systemGray) }
|
||||
|
||||
// Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS
|
||||
static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) }
|
||||
|
|
|
|||
|
|
@ -68,21 +68,9 @@ public struct GridCard: View {
|
|||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Progress Bar
|
||||
Group {
|
||||
// Remove os check when dropping macOS 11
|
||||
#if os(iOS)
|
||||
if #available(iOS 15.0, *) {
|
||||
ProgressView(value: min(abs(item.readingProgress) / 100, 1))
|
||||
.tint(.appYellow48)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#else
|
||||
ProgressView(value: max(abs(item.readingProgress) / 100, 1))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
ProgressView(value: min(abs(item.readingProgress) / 100, 1))
|
||||
.tint(.appYellow48)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onTapGesture { tapHandler() }
|
||||
|
||||
|
|
|
|||
|
|
@ -30,14 +30,13 @@ import SwiftUI
|
|||
let controller = UIHostingController(rootView: content())
|
||||
|
||||
if controller.traitCollection.userInterfaceIdiom == .phone {
|
||||
if #available(iOS 15, *) {
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.preferredCornerRadius = 16
|
||||
sheet.prefersGrabberVisible = false
|
||||
sheet.detents = [.medium()]
|
||||
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
|
||||
}
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.preferredCornerRadius = 16
|
||||
sheet.prefersGrabberVisible = false
|
||||
sheet.detents = [.medium()]
|
||||
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
|
||||
}
|
||||
|
||||
controller.modalPresentationStyle = .pageSheet
|
||||
} else {
|
||||
controller.view.sizeToFit()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import SwiftUI
|
|||
self.denyAction = denyAction
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Image
|
||||
|
|
@ -45,14 +45,7 @@ import SwiftUI
|
|||
.foregroundColor(Color.appTextDefault)
|
||||
.cornerRadius(8)
|
||||
.frame(maxWidth: min(UIScreen.main.bounds.width - 20, 320))
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
content.interactiveDismissDisabled()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,3 +30,84 @@ public struct TextChip: View {
|
|||
.cornerRadius(cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
public struct TextChipButton: View {
|
||||
public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton {
|
||||
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
|
||||
TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeRemovableLabelButton(
|
||||
feedItemLabel: FeedItemLabel,
|
||||
onTap: @escaping () -> Void
|
||||
) -> TextChipButton {
|
||||
TextChipButton(
|
||||
title: feedItemLabel.name,
|
||||
color: Color(hex: feedItemLabel.color) ?? .appButtonBackground,
|
||||
actionType: .remove,
|
||||
onTap: onTap
|
||||
)
|
||||
}
|
||||
|
||||
public enum ActionType {
|
||||
case remove
|
||||
case add
|
||||
case show
|
||||
|
||||
var systemIconName: String {
|
||||
switch self {
|
||||
case .remove:
|
||||
return "xmark"
|
||||
case .add:
|
||||
return "plus"
|
||||
case .show:
|
||||
return "chevron.down"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) {
|
||||
self.text = title
|
||||
self.color = color
|
||||
self.onTap = onTap
|
||||
self.actionType = actionType
|
||||
self.foregroundColor = {
|
||||
if actionType == .show {
|
||||
return .appGrayText
|
||||
}
|
||||
return color.isDark ? .white : .black
|
||||
}()
|
||||
}
|
||||
|
||||
let text: String
|
||||
let color: Color
|
||||
let onTap: () -> Void
|
||||
let actionType: ActionType
|
||||
let cornerRadius = 20.0
|
||||
let foregroundColor: Color
|
||||
|
||||
public var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(text)
|
||||
.padding(.leading, 3)
|
||||
Image(systemName: actionType.systemIconName)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(foregroundColor)
|
||||
.lineLimit(1)
|
||||
.background(color)
|
||||
.cornerRadius(cornerRadius)
|
||||
|
||||
Color.clear.contentShape(Rectangle()).frame(height: 15)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension ToolbarItemPlacement {
|
||||
static var barLeading: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
.navigationBarLeading
|
||||
#else
|
||||
.automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
static var barTrailing: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
.navigationBarTrailing
|
||||
#else
|
||||
.automatic
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,12 @@ scalars:
|
|||
SanitizedString_undefined_15: String
|
||||
SanitizedString_undefined_40: String
|
||||
SanitizedString_undefined_50: String
|
||||
SanitizedString_undefined_64: String
|
||||
SanitizedString_undefined_95: String
|
||||
SanitizedString_undefined_100: String
|
||||
SanitizedString_undefined_300: String
|
||||
SanitizedString_undefined_400: String
|
||||
SanitizedString_undefined_2000: String
|
||||
SanitizedString_undefined_4000: String
|
||||
SanitizedString_undefined_8000: String
|
||||
SanitizedString_undefined_undefined: String
|
||||
SanitizedString_undefined_undefined: String
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function makeApolloServer(): ApolloServer {
|
|||
schema: schema,
|
||||
context: contextFunc,
|
||||
formatError: (err) => {
|
||||
console.log('server error', err)
|
||||
Sentry.captureException(err)
|
||||
// hide error messages from frontend on prod
|
||||
return new Error('Unexpected server error')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { PubSub } from '@google-cloud/pubsub'
|
|||
import { env } from '../env'
|
||||
import { ReportType } from '../generated/graphql'
|
||||
import express from 'express'
|
||||
import { Page } from '../elastic/types'
|
||||
|
||||
export const createPubSubClient = (): PubsubClient => {
|
||||
const client = new PubSub()
|
||||
|
|
@ -37,17 +36,35 @@ export const createPubSubClient = (): PubsubClient => {
|
|||
Buffer.from(JSON.stringify({ userId, email, name, username }))
|
||||
)
|
||||
},
|
||||
pageUpdated: (page: Partial<Page>, userId: string): Promise<void> => {
|
||||
entityCreated: <T>(
|
||||
type: EntityType,
|
||||
data: T,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'pageUpdated',
|
||||
Buffer.from(JSON.stringify({ ...page, userId }))
|
||||
'entityCreated',
|
||||
Buffer.from(JSON.stringify({ type, userId, ...data }))
|
||||
)
|
||||
},
|
||||
pageCreated: (page: Page): Promise<void> => {
|
||||
return publish('pageCreated', Buffer.from(JSON.stringify(page)))
|
||||
entityUpdated: <T>(
|
||||
type: EntityType,
|
||||
data: T,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'entityUpdated',
|
||||
Buffer.from(JSON.stringify({ type, userId, ...data }))
|
||||
)
|
||||
},
|
||||
pageDeleted: (id: string, userId: string): Promise<void> => {
|
||||
return publish('pageDeleted', Buffer.from(JSON.stringify({ id, userId })))
|
||||
entityDeleted: (
|
||||
type: EntityType,
|
||||
id: string,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'entityDeleted',
|
||||
Buffer.from(JSON.stringify({ type, id, userId }))
|
||||
)
|
||||
},
|
||||
reportSubmitted: (
|
||||
submitterId: string,
|
||||
|
|
@ -65,6 +82,12 @@ export const createPubSubClient = (): PubsubClient => {
|
|||
}
|
||||
}
|
||||
|
||||
export enum EntityType {
|
||||
PAGE = 'page',
|
||||
HIGHLIGHT = 'highlight',
|
||||
LABEL = 'label',
|
||||
}
|
||||
|
||||
export interface PubsubClient {
|
||||
userCreated: (
|
||||
userId: string,
|
||||
|
|
@ -72,9 +95,9 @@ export interface PubsubClient {
|
|||
name: string,
|
||||
username: string
|
||||
) => Promise<void>
|
||||
pageCreated: (page: Page) => Promise<void>
|
||||
pageUpdated: (page: Partial<Page>, userId: string) => Promise<void>
|
||||
pageDeleted: (id: string, userId: string) => Promise<void>
|
||||
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
|
||||
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
|
||||
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
|
||||
reportSubmitted(
|
||||
submitterId: string | undefined,
|
||||
itemUrl: string,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface ReminderData {
|
|||
createdAt: Date
|
||||
updatedAt?: Date
|
||||
remindAt: Date
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
|
|
@ -46,6 +47,7 @@ export const keys = [
|
|||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'elasticPageId',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'updatedAt', 'status'] as const
|
||||
|
|
|
|||
|
|
@ -1,32 +1,41 @@
|
|||
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'
|
||||
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
|
||||
import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql'
|
||||
import { SanitizedString } from './scalars'
|
||||
|
||||
export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => {
|
||||
return mapSchema(schema, {
|
||||
[MapperKind.FIELD]: (fieldConfig) => {
|
||||
const sanitizeDirective = getDirective(schema, fieldConfig, 'sanitize')
|
||||
if (!sanitizeDirective || sanitizeDirective.length < 1) {
|
||||
const sanitizeDirective = getDirective(
|
||||
schema,
|
||||
fieldConfig,
|
||||
'sanitize'
|
||||
)?.[0]
|
||||
if (!sanitizeDirective) {
|
||||
return fieldConfig
|
||||
}
|
||||
|
||||
const maxLength = sanitizeDirective[0].maxLength as number | undefined
|
||||
const allowedTags = sanitizeDirective[0].allowedTags as
|
||||
| string[]
|
||||
| undefined
|
||||
const maxLength = sanitizeDirective.maxLength as number | undefined
|
||||
const allowedTags = sanitizeDirective.allowedTags as string[] | undefined
|
||||
const pattern = sanitizeDirective.pattern as string | undefined
|
||||
|
||||
if (
|
||||
fieldConfig.type instanceof GraphQLNonNull &&
|
||||
fieldConfig.type.ofType instanceof GraphQLScalarType
|
||||
) {
|
||||
fieldConfig.type = new GraphQLNonNull(
|
||||
new SanitizedString(fieldConfig.type.ofType, allowedTags, maxLength)
|
||||
new SanitizedString(
|
||||
fieldConfig.type.ofType,
|
||||
allowedTags,
|
||||
maxLength,
|
||||
pattern
|
||||
)
|
||||
)
|
||||
} else if (fieldConfig.type instanceof GraphQLScalarType) {
|
||||
fieldConfig.type = new SanitizedString(
|
||||
fieldConfig.type,
|
||||
allowedTags,
|
||||
maxLength
|
||||
maxLength,
|
||||
pattern
|
||||
)
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { ResponseError } from '@elastic/elasticsearch/lib/errors'
|
||||
import { client, INDEX_ALIAS } from './index'
|
||||
import { SortBy, SortOrder, SortParams } from '../utils/search'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
|
||||
export const addHighlightToPage = async (
|
||||
id: string,
|
||||
|
|
@ -35,7 +36,15 @@ export const addHighlightToPage = async (
|
|||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
return body.result === 'updated'
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
await ctx.pubsub.entityCreated<Highlight>(
|
||||
EntityType.HIGHLIGHT,
|
||||
highlight,
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof ResponseError &&
|
||||
|
|
@ -125,7 +134,11 @@ export const deleteHighlight = async (
|
|||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
return !!body.updated
|
||||
if (body.updated === 0) return false
|
||||
|
||||
await ctx.pubsub.entityDeleted(EntityType.HIGHLIGHT, highlightId, ctx.uid)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('failed to delete a highlight in elastic', e)
|
||||
|
||||
|
|
@ -266,7 +279,15 @@ export const updateHighlight = async (
|
|||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
return !!body.updated
|
||||
if (body.updated === 0) return false
|
||||
|
||||
await ctx.pubsub.entityUpdated<Highlight>(
|
||||
EntityType.HIGHLIGHT,
|
||||
highlight,
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof ResponseError &&
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { Label, PageContext } from './types'
|
||||
import { client, INDEX_ALIAS } from './index'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
|
||||
export const addLabelInPage = async (
|
||||
id: string,
|
||||
pageId: string,
|
||||
label: Label,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
id: pageId,
|
||||
body: {
|
||||
script: {
|
||||
source: `if (ctx._source.labels == null) {
|
||||
|
|
@ -27,9 +28,53 @@ export const addLabelInPage = async (
|
|||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
return body.result === 'updated'
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
|
||||
EntityType.LABEL,
|
||||
{ pageId, ...label },
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('failed to update a page in elastic', e)
|
||||
console.error('failed to add a label in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const updateLabelsInPage = async (
|
||||
pageId: string,
|
||||
labels: Label[],
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id: pageId,
|
||||
body: {
|
||||
doc: {
|
||||
labels: labels,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
for (const label of labels) {
|
||||
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
|
||||
EntityType.LABEL,
|
||||
{ pageId, ...label },
|
||||
ctx.uid
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('failed to update labels in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -38,9 +83,9 @@ export const deleteLabelInPages = async (
|
|||
userId: string,
|
||||
label: string,
|
||||
ctx: PageContext
|
||||
): Promise<void> => {
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await client.updateByQuery({
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
|
|
@ -75,7 +120,14 @@ export const deleteLabelInPages = async (
|
|||
},
|
||||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
if (body.updated === 0) return false
|
||||
|
||||
await ctx.pubsub.entityDeleted(EntityType.LABEL, label, ctx.uid)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('failed to delete a page in elastic', e)
|
||||
console.error('failed to delete a label in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
SortParams,
|
||||
} from '../utils/search'
|
||||
import { client, INDEX_ALIAS } from './index'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
|
||||
const appendQuery = (body: SearchBody, query: string): void => {
|
||||
body.query.bool.should.push({
|
||||
|
|
@ -189,7 +190,7 @@ export const createPage = async (
|
|||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
await ctx.pubsub.pageCreated(page)
|
||||
await ctx.pubsub.entityCreated<Page>(EntityType.PAGE, page, ctx.uid)
|
||||
|
||||
return body._id as string
|
||||
} catch (e) {
|
||||
|
|
@ -219,7 +220,11 @@ export const updatePage = async (
|
|||
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
await ctx.pubsub.pageUpdated({ ...page, id }, ctx.uid)
|
||||
await ctx.pubsub.entityUpdated<Partial<Page>>(
|
||||
EntityType.PAGE,
|
||||
{ ...page, id },
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
@ -241,7 +246,7 @@ export const deletePage = async (
|
|||
|
||||
if (body.deleted === 0) return false
|
||||
|
||||
await ctx.pubsub.pageDeleted(id, ctx.uid)
|
||||
await ctx.pubsub.entityDeleted(EntityType.PAGE, id, ctx.uid)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -44,4 +44,7 @@ export class Reminder {
|
|||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@Column('text')
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2501,6 +2501,7 @@ export type ResolversParentTypes = {
|
|||
export type SanitizeDirectiveArgs = {
|
||||
allowedTags?: Maybe<Array<Maybe<Scalars['String']>>>;
|
||||
maxLength?: Maybe<Scalars['Int']>;
|
||||
pattern?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type SanitizeDirectiveResolver<Result, Parent, ContextType = ResolverContext, Args = SanitizeDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
directive @sanitize(allowedTags: [String], maxLength: Int) on INPUT_FIELD_DEFINITION
|
||||
directive @sanitize(allowedTags: [String], maxLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
|
||||
|
||||
type ArchiveLinkError {
|
||||
errorCodes: [ArchiveLinkErrorCode!]!
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ import { ILike, In } from 'typeorm'
|
|||
import { getRepository, setClaims } from '../../entity/utils'
|
||||
import { createPubSubClient } from '../../datalayer/pubsub'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { getPageById, updatePage } from '../../elastic/pages'
|
||||
import { deleteLabelInPages } from '../../elastic/labels'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { deleteLabelInPages, updateLabelsInPage } from '../../elastic/labels'
|
||||
|
||||
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
|
||||
async (_obj, _params, { claims: { uid }, log }) => {
|
||||
|
|
@ -108,7 +108,7 @@ export const createLabelResolver = authorized<
|
|||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'createLabel',
|
||||
event: 'label_created',
|
||||
properties: {
|
||||
name,
|
||||
color,
|
||||
|
|
@ -178,7 +178,7 @@ export const deleteLabelResolver = authorized<
|
|||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'deleteLabel',
|
||||
event: 'label_deleted',
|
||||
properties: {
|
||||
labelId,
|
||||
env: env.server.apiEnv,
|
||||
|
|
@ -231,17 +231,19 @@ export const setLabelsResolver = authorized<
|
|||
}
|
||||
|
||||
// update labels in the page
|
||||
await updatePage(
|
||||
pageId,
|
||||
{
|
||||
labels,
|
||||
},
|
||||
{ pubsub, uid }
|
||||
)
|
||||
const updated = await updateLabelsInPage(pageId, labels, {
|
||||
pubsub,
|
||||
uid,
|
||||
})
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'setLabels',
|
||||
event: 'labels_set',
|
||||
properties: {
|
||||
pageId,
|
||||
labelIds,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,10 @@ import { env } from '../../env'
|
|||
import { ReminderData } from '../../datalayer/reminders/model'
|
||||
import { DataModels } from '../types'
|
||||
import { DateTime } from 'luxon'
|
||||
import { UserArticleData } from '../../datalayer/links/model'
|
||||
import { ArticleSavingRequestData } from '../../datalayer/article_saving_request/model'
|
||||
import { setLinkArchived } from '../../services/archive_link'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { Page } from '../../elastic/types'
|
||||
|
||||
const validScheduleTime = (str: string): Date | undefined => {
|
||||
const scheduleTime = DateTime.fromISO(str, { setZone: true }).set({
|
||||
|
|
@ -87,17 +88,25 @@ export const createReminderResolver = authorized<
|
|||
try {
|
||||
if (articleId) {
|
||||
// saving from web
|
||||
const link = await models.userArticle.getByArticleId(uid, articleId)
|
||||
if (!link) {
|
||||
log.error('link not found', articleId)
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
log.error('page not found', articleId)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateReminderErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
linkId = link.id
|
||||
|
||||
if (page.userId !== uid) {
|
||||
log.error('user not authorized', uid)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateReminderErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
linkId = page.id
|
||||
if (archiveUntil) {
|
||||
await archiveLinkOrRequest(uid, link, undefined, models)
|
||||
await archiveLinkOrRequest(uid, page, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +125,7 @@ export const createReminderResolver = authorized<
|
|||
|
||||
articleSavingRequestId = articleSavingRequest.id
|
||||
if (articleSavingRequest.articleId) {
|
||||
await archiveLinkOrRequest(uid, undefined, articleSavingRequest, models)
|
||||
await archiveLinkOrRequest(uid, undefined, articleSavingRequest)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +135,6 @@ export const createReminderResolver = authorized<
|
|||
// insert reminder to db
|
||||
const reminder = await models.reminder.create({
|
||||
userId: uid,
|
||||
linkId: linkId ? linkId : undefined,
|
||||
articleSavingRequestId: articleSavingRequestId
|
||||
? articleSavingRequestId
|
||||
: undefined,
|
||||
|
|
@ -135,6 +143,7 @@ export const createReminderResolver = authorized<
|
|||
sendNotification: sendNotification,
|
||||
createdAt: new Date(),
|
||||
remindAt: scheduledTime,
|
||||
elasticPageId: linkId,
|
||||
})
|
||||
console.log('created reminder', reminder)
|
||||
|
||||
|
|
@ -161,16 +170,13 @@ export const createReminderResolver = authorized<
|
|||
// case it will be archived when the link is created.
|
||||
const archiveLinkOrRequest = async (
|
||||
uid: string,
|
||||
link: UserArticleData | undefined,
|
||||
request: ArticleSavingRequestData | undefined,
|
||||
models: DataModels
|
||||
page: Page | undefined,
|
||||
request: ArticleSavingRequestData | undefined
|
||||
) => {
|
||||
let target: UserArticleData | undefined = link || undefined
|
||||
if (!link && request && request.articleId) {
|
||||
let target: Page | undefined = page || undefined
|
||||
if (!page && request && request.articleId) {
|
||||
// this linkId is actually an article.id
|
||||
target =
|
||||
(await models.userArticle.getByArticleId(uid, request.articleId)) ||
|
||||
undefined
|
||||
target = (await getPageById(request.articleId)) || undefined
|
||||
}
|
||||
console.log('archiving target', target)
|
||||
if (target) {
|
||||
|
|
@ -200,9 +206,9 @@ export const reminderResolver = authorized<
|
|||
|
||||
try {
|
||||
let reminder: ReminderData | null
|
||||
// get link from articleId
|
||||
const link = await models.userArticle.getByArticleId(uid, articleId)
|
||||
if (!link) {
|
||||
// get page from articleId
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
// link may not be saved yet
|
||||
// check savingArticleRequest table
|
||||
const articleSavingRequest =
|
||||
|
|
@ -224,7 +230,7 @@ export const reminderResolver = authorized<
|
|||
})
|
||||
} else {
|
||||
reminder = await models.reminder.getCreatedByParameters(uid, {
|
||||
linkId: link.id,
|
||||
elasticPageId: page.id,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function pdfAttachmentsRouter() {
|
|||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'pdf-attachment-upload',
|
||||
event: 'pdf_attachment_upload',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
|
|
@ -107,7 +107,7 @@ export function pdfAttachmentsRouter() {
|
|||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'pdf-attachment-create-article',
|
||||
event: 'pdf_attachment_create_article',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@ import { readPushSubscription } from '../../datalayer/pubsub'
|
|||
import { generateUploadSignedUrl, uploadToSignedUrl } from '../../utils/uploads'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env } from '../../env'
|
||||
import { Page } from '../../elastic/types'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export function pageServiceRouter() {
|
||||
export function uploadServiceRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
router.post('/upload/:folder', async (req, res) => {
|
||||
console.log('upload page data req', req.params.folder)
|
||||
router.post('/:folder', async (req, res) => {
|
||||
console.log('upload data to folder', req.params.folder)
|
||||
const { message: msgStr, expired } = readPushSubscription(req)
|
||||
|
||||
if (!msgStr) {
|
||||
|
|
@ -28,9 +27,9 @@ export function pageServiceRouter() {
|
|||
}
|
||||
|
||||
try {
|
||||
const data: Partial<Page> = JSON.parse(msgStr)
|
||||
if (!data.userId) {
|
||||
console.log('No userId found in message')
|
||||
const data: { userId: string; type: string } = JSON.parse(msgStr)
|
||||
if (!data.userId || !data.type) {
|
||||
console.log('No userId or type found in message')
|
||||
res.status(400).send('Bad Request')
|
||||
return
|
||||
}
|
||||
|
|
@ -41,9 +40,9 @@ export function pageServiceRouter() {
|
|||
console.log('generate upload url')
|
||||
|
||||
const uploadUrl = await generateUploadSignedUrl(
|
||||
`${req.params.folder}/${data.userId}/${DateTime.now().toFormat(
|
||||
'yyyy-LL-dd'
|
||||
)}/${uuidv4()}.json`,
|
||||
`${req.params.folder}/${data.type}/${
|
||||
data.userId
|
||||
}/${DateTime.now().toFormat('yyyy-LL-dd')}/${uuidv4()}.json`,
|
||||
contentType,
|
||||
bucketName
|
||||
)
|
||||
|
|
@ -8,10 +8,15 @@ export class SanitizedString extends GraphQLScalarType {
|
|||
constructor(
|
||||
type: GraphQLScalarType,
|
||||
allowedTags?: string[],
|
||||
maxLength?: number
|
||||
maxLength?: number,
|
||||
pattern?: string
|
||||
) {
|
||||
super({
|
||||
name: `SanitizedString_${allowedTags}_${maxLength}`,
|
||||
// Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ as per graphql-js
|
||||
name: `SanitizedString_${allowedTags}_${maxLength}_${pattern}`.replace(
|
||||
/\W/g,
|
||||
''
|
||||
),
|
||||
description: 'Source string that was sanitized',
|
||||
|
||||
serialize(value: string) {
|
||||
|
|
@ -25,6 +30,9 @@ export class SanitizedString extends GraphQLScalarType {
|
|||
`Specified value cannot be longer than ${maxLength} characters`
|
||||
)
|
||||
}
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
throw new Error(`Specified value does not match pattern`)
|
||||
}
|
||||
return sanitize(value, { allowedTags: allowedTags || [] })
|
||||
},
|
||||
|
||||
|
|
@ -36,6 +44,9 @@ export class SanitizedString extends GraphQLScalarType {
|
|||
`Specified value cannot be longer than ${maxLength} characters`
|
||||
)
|
||||
}
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
throw new Error(`Specified value does not match pattern`)
|
||||
}
|
||||
return sanitize(value, { allowedTags: allowedTags || [] })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const schema = gql`
|
|||
directive @sanitize(
|
||||
allowedTags: [String]
|
||||
maxLength: Int
|
||||
pattern: String
|
||||
) on INPUT_FIELD_DEFINITION
|
||||
|
||||
enum SortOrder {
|
||||
|
|
@ -1273,7 +1274,7 @@ const schema = gql`
|
|||
|
||||
input CreateLabelInput {
|
||||
name: String! @sanitize(maxLength: 64)
|
||||
color: String!
|
||||
color: String! @sanitize(pattern: "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
|
||||
description: String @sanitize(maxLength: 100)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { ApolloServer } from 'apollo-server-express'
|
|||
import { pdfAttachmentsRouter } from './routers/svc/pdf_attachments'
|
||||
import { corsConfig } from './utils/corsConfig'
|
||||
import { initElasticsearch } from './elastic'
|
||||
import { pageServiceRouter } from './routers/svc/pages'
|
||||
import { uploadServiceRouter } from './routers/svc/upload'
|
||||
|
||||
const PORT = process.env.PORT || 4000
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ export const createApp = (): {
|
|||
app.use('/svc/pubsub/links', linkServiceRouter())
|
||||
app.use('/svc/pubsub/newsletters', newsletterServiceRouter())
|
||||
app.use('/svc/pubsub/emails', emailsServiceRouter())
|
||||
app.use('/svc/pubsub/pages', pageServiceRouter())
|
||||
app.use('/svc/pubsub/upload', uploadServiceRouter())
|
||||
app.use('/svc/reminders', remindersServiceRouter())
|
||||
app.use('/svc/pdf-attachments', pdfAttachmentsRouter())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import Postgrator from 'postgrator'
|
||||
import { User } from '../src/entity/user'
|
||||
import { Profile } from '../src/entity/profile'
|
||||
import { Page } from '../src/entity/page'
|
||||
import { Link } from '../src/entity/link'
|
||||
import { Reminder } from '../src/entity/reminder'
|
||||
import { NewsletterEmail } from '../src/entity/newsletter_email'
|
||||
import { UserDeviceToken } from '../src/entity/user_device_tokens'
|
||||
import { Label } from '../src/entity/label'
|
||||
import { AppDataSource } from '../src/server'
|
||||
import { getRepository } from '../src/entity/utils'
|
||||
import { createUser } from '../src/services/create_user'
|
||||
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
|
||||
import Postgrator from "postgrator";
|
||||
import { User } from "../src/entity/user";
|
||||
import { Profile } from "../src/entity/profile";
|
||||
import { Page } from "../src/entity/page";
|
||||
import { Link } from "../src/entity/link";
|
||||
import { Reminder } from "../src/entity/reminder";
|
||||
import { NewsletterEmail } from "../src/entity/newsletter_email";
|
||||
import { UserDeviceToken } from "../src/entity/user_device_tokens";
|
||||
import { Label } from "../src/entity/label";
|
||||
import { AppDataSource } from "../src/server";
|
||||
import { getRepository } from "../src/entity/utils";
|
||||
import { createUser } from "../src/services/create_user";
|
||||
import { SnakeNamingStrategy } from "typeorm-naming-strategies";
|
||||
|
||||
const runMigrations = async () => {
|
||||
const migrationDirectory = __dirname + '/../../db/migrations'
|
||||
|
|
@ -122,11 +122,11 @@ export const createTestLink = async (user: User, page: Page): Promise<Link> => {
|
|||
|
||||
export const createTestReminder = async (
|
||||
user: User,
|
||||
link?: string
|
||||
pageId?: string
|
||||
): Promise<Reminder> => {
|
||||
return getRepository(Reminder).save({
|
||||
user: user,
|
||||
link: link,
|
||||
elasticPageId: pageId,
|
||||
remindAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { generateFakeUuid, graphqlRequest, request } from '../util'
|
||||
import {
|
||||
createTestLink,
|
||||
createTestPage,
|
||||
createTestElasticPage,
|
||||
generateFakeUuid,
|
||||
graphqlRequest,
|
||||
request,
|
||||
} from '../util'
|
||||
import {
|
||||
createTestReminder,
|
||||
createTestUser,
|
||||
deleteTestUser,
|
||||
|
|
@ -14,17 +17,15 @@ import {
|
|||
ReminderErrorCode,
|
||||
UpdateReminderErrorCode,
|
||||
} from '../../src/generated/graphql'
|
||||
import { Page } from '../../src/entity/page'
|
||||
import { Link } from '../../src/entity/link'
|
||||
import { DateTime } from 'luxon'
|
||||
import 'mocha'
|
||||
import { Page } from '../../src/elastic/types'
|
||||
|
||||
describe('Reminders API', () => {
|
||||
const username = 'fakeUser'
|
||||
|
||||
let authToken: string
|
||||
let page: Page
|
||||
let link: Link
|
||||
let reminder: Reminder
|
||||
|
||||
before(async () => {
|
||||
|
|
@ -37,9 +38,8 @@ describe('Reminders API', () => {
|
|||
authToken = res.body.authToken
|
||||
|
||||
// create page, link and reminders test data
|
||||
page = await createTestPage()
|
||||
link = await createTestLink(user, page)
|
||||
reminder = await createTestReminder(user, link.id)
|
||||
page = await createTestElasticPage(user)
|
||||
reminder = await createTestReminder(user, page.id)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { request } from '../util'
|
||||
import 'mocha'
|
||||
|
||||
describe('Pages Router', () => {
|
||||
describe('Upload Router', () => {
|
||||
const token = process.env.PUBSUB_VERIFICATION_TOKEN || ''
|
||||
|
||||
describe('upload', () => {
|
||||
it('upload data to GCS', async () => {
|
||||
const data = {
|
||||
message: {
|
||||
data: Buffer.from(JSON.stringify({ userId: 'userId' })).toString(
|
||||
data: Buffer.from(JSON.stringify({ userId: 'userId', type: 'page' })).toString(
|
||||
'base64'
|
||||
),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await request
|
||||
.post(`/svc/pubsub/pages/upload/createdPage?token=${token}`)
|
||||
.post(`/svc/pubsub/upload/createdEntity?token=${token}`)
|
||||
.send(data)
|
||||
.expect(200)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -504,15 +504,17 @@ Readability.prototype = {
|
|||
});
|
||||
},
|
||||
|
||||
/** Creates imageproxy links for all article images */
|
||||
/** Creates imageproxy links for all article images with href source */
|
||||
_createImageProxyLinks: function (articleContent) {
|
||||
if (this.createImageProxyUrl !== undefined) {
|
||||
// replace all image src's
|
||||
// replace all images' href source
|
||||
const images = articleContent.getElementsByTagName('img');
|
||||
Array.from(images).forEach(image => {
|
||||
const src = image.getAttribute("src");
|
||||
const dataUriRegex = /^data:image\/(?:png|jpe?g|gif);base64,/;
|
||||
|
||||
if (src) {
|
||||
// do not proxy data uri
|
||||
if (src && !dataUriRegex.test(src)) {
|
||||
const absoluteSrc = this.toAbsoluteURI(src);
|
||||
const attToNumber = (str) => {
|
||||
if (!str) { return 0; }
|
||||
|
|
|
|||
|
|
@ -270,6 +270,17 @@ describe("Readability API", function() {
|
|||
}).parse().content;
|
||||
expect(content).eql(expected_xhtml);
|
||||
});
|
||||
|
||||
it("should not proxy image with data uri", function() {
|
||||
var dom = new JSDOM("My cat: <img src=\"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA" +
|
||||
"AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==\"" +
|
||||
" alt=\"Red dot\" />");
|
||||
var expected_xhtml = "<div id=\"readability-page-1\" class=\"page\">My cat: <img src=\"data:image/png;base64," +
|
||||
" iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0" +
|
||||
"Y4OHwAAAABJRU5ErkJggg==\" alt=\"Red dot\"></div>";
|
||||
var content = new Readability(dom.window.document).parse().content;
|
||||
expect(content).eql(expected_xhtml);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ export const Button = styled('button', {
|
|||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
articleActionIcon: {
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
ghost: {
|
||||
color: 'transparent',
|
||||
border: 'none',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Arrow,
|
||||
Label,
|
||||
} from '@radix-ui/react-dropdown-menu'
|
||||
import { PopperContentProps } from '@radix-ui/react-popover';
|
||||
import { CSS } from '@stitches/react';
|
||||
import { styled } from './../tokens/stitches.config'
|
||||
|
||||
|
|
@ -49,12 +50,12 @@ export const DropdownContent = styled(Content, {
|
|||
backgroundColor: '$grayBg',
|
||||
borderRadius: '0.5em',
|
||||
padding: 5,
|
||||
border: '1px solid $grayBorder',
|
||||
outline: '1px solid $grayBorder',
|
||||
boxShadow: '$cardBoxShadow',
|
||||
})
|
||||
|
||||
const StyledArrow = styled(Arrow, {
|
||||
fill: '$grayBase',
|
||||
fill: '$grayBg',
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Label, {
|
||||
|
|
@ -65,6 +66,7 @@ const StyledLabel = styled(Label, {
|
|||
})
|
||||
|
||||
export type DropdownAlignment = 'start' | 'end' | 'center'
|
||||
export type DropdownSide = 'top' | 'right' | 'bottom' | 'left'
|
||||
|
||||
type DropdownProps = {
|
||||
labelText?: string
|
||||
|
|
@ -73,8 +75,11 @@ type DropdownProps = {
|
|||
children: React.ReactNode
|
||||
styledArrow?: boolean
|
||||
align?: DropdownAlignment
|
||||
side?: DropdownSide
|
||||
sideOffset?: number
|
||||
disabled?: boolean
|
||||
css?: CSS
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
export const DropdownSeparator = styled(Separator, {
|
||||
|
|
@ -101,17 +106,22 @@ export function DropdownOption(props: DropdownOptionProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
align,
|
||||
triggerElement,
|
||||
labelText,
|
||||
showArrow = true,
|
||||
disabled = false,
|
||||
css
|
||||
}: DropdownProps): JSX.Element {
|
||||
export function Dropdown(props: DropdownProps & PopperContentProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
align,
|
||||
triggerElement,
|
||||
labelText,
|
||||
showArrow = true,
|
||||
disabled = false,
|
||||
side = 'bottom',
|
||||
sideOffset = 0,
|
||||
alignOffset = 0,
|
||||
css,
|
||||
modal
|
||||
} = props
|
||||
return (
|
||||
<Root modal={false}>
|
||||
<Root modal={modal}>
|
||||
<DropdownTrigger disabled={disabled}>{triggerElement}</DropdownTrigger>
|
||||
<DropdownContent
|
||||
css={css}
|
||||
|
|
@ -119,11 +129,14 @@ export function Dropdown({
|
|||
// remove focus from dropdown
|
||||
;(document.activeElement as HTMLElement).blur()
|
||||
}}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align ? align : 'center'}
|
||||
alignOffset={alignOffset}
|
||||
>
|
||||
{labelText && <StyledLabel>{labelText}</StyledLabel>}
|
||||
{children}
|
||||
{showArrow && <StyledArrow offset={20} />}
|
||||
{showArrow && <StyledArrow offset={20} width={20} height={10} />}
|
||||
</DropdownContent>
|
||||
</Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import Link from 'next/link'
|
||||
import { SpanBox } from './LayoutPrimitives'
|
||||
import { StyledText } from './StyledText'
|
||||
|
||||
type LabelChipProps = {
|
||||
|
|
@ -16,19 +18,25 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
|
|||
}
|
||||
const color = hexToRgb(props.color)
|
||||
return (
|
||||
<StyledText
|
||||
css={{
|
||||
margin: '4px',
|
||||
borderRadius: '32px',
|
||||
color: props.color,
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
padding: '4px 8px 4px 8px',
|
||||
border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
|
||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</StyledText>
|
||||
<Link href={`/home?q=label:"${props.text}"`}>
|
||||
<SpanBox
|
||||
css={{
|
||||
display: 'inline-table',
|
||||
margin: '4px',
|
||||
borderRadius: '32px',
|
||||
color: props.color,
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
padding: '4px 8px 4px 8px',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
backgroundClip: 'padding-box',
|
||||
border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
|
||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</SpanBox>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,18 @@ import React, { useState } from 'react'
|
|||
import { styled } from '../tokens/stitches.config'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { HexColorPicker } from 'react-colorful'
|
||||
import { Button } from './Button'
|
||||
import { HStack, SpanBox } from './LayoutPrimitives'
|
||||
import { CaretDown } from 'phosphor-react'
|
||||
import { StyledText } from './StyledText'
|
||||
import {
|
||||
ColorDetailsProps,
|
||||
LabelColor,
|
||||
LabelColorDropdownProps,
|
||||
LabelColorHex,
|
||||
LabelColorObject,
|
||||
LabelOptionProps,
|
||||
} from '../../utils/settings-page/labels/types'
|
||||
import { labelColorObjects } from '../../utils/settings-page/labels/labelColorObjects'
|
||||
import { DropdownOption } from './DropdownElements'
|
||||
import { isDarkTheme } from '../../lib/themeUpdater'
|
||||
import { LabelColor } from '../../lib/networking/fragments/labelFragment'
|
||||
|
||||
const DropdownMenuContent = styled(DropdownMenuPrimitive.Content, {
|
||||
maxWidth: 190,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Root, Overlay, Content } from '@radix-ui/react-dialog'
|
||||
import { styled, keyframes } from '../tokens/stitches.config'
|
||||
import { styled, keyframes, theme } from '../tokens/stitches.config'
|
||||
|
||||
export const ModalRoot = styled(Root, {})
|
||||
|
||||
|
|
@ -25,8 +25,7 @@ const contentShow = keyframes({
|
|||
const Modal = styled(Content, {
|
||||
backgroundColor: '$grayBg',
|
||||
borderRadius: 6,
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
boxShadow: theme.shadows.cardBoxShadow.toString(),
|
||||
position: 'fixed',
|
||||
'&:focus': { outline: 'none' },
|
||||
})
|
||||
|
|
|
|||
39
packages/web/components/elements/TickedRangeSlider.tsx
Normal file
39
packages/web/components/elements/TickedRangeSlider.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
|
||||
|
||||
import { Box, HStack } from './LayoutPrimitives'
|
||||
import { styled, theme } from '../tokens/stitches.config'
|
||||
|
||||
type TickedRangeSliderProps = {
|
||||
ticks?: number,
|
||||
value: number,
|
||||
onChange: (value: number) => void,
|
||||
min?: number,
|
||||
max?: number,
|
||||
step?: number,
|
||||
}
|
||||
|
||||
const Tick = styled(Box, {
|
||||
background: theme.colors.grayBorderHover,
|
||||
width: 2,
|
||||
height: 8,
|
||||
})
|
||||
|
||||
export function TickedRangeSlider({
|
||||
ticks = 8,
|
||||
min = 10,
|
||||
max = 28,
|
||||
step = 1,
|
||||
value,
|
||||
onChange,
|
||||
} : TickedRangeSliderProps
|
||||
): JSX.Element {
|
||||
|
||||
return (
|
||||
<Box css={{zIndex: 2}}>
|
||||
<input onChange={(e) => onChange(e.target.value as any)} value={value} type="range" min={min} max={max} step={step} className='slider'/>
|
||||
<HStack distribution='between' css={{position: 'relative', bottom: 12.2, left: 2, zIndex: -1}}>
|
||||
{[...Array(ticks)].map((val, idx) => <Tick key={`ticks-${idx}`} />)}
|
||||
</HStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -62,6 +62,15 @@ type TooltipWrappedProps = {
|
|||
style?: TooltipPrimitive.TooltipContentProps['style']
|
||||
}
|
||||
|
||||
const DefaultTooltipStyle = {
|
||||
backgroundColor: '#F9D354',
|
||||
color: '#0A0806',
|
||||
}
|
||||
|
||||
const DefaultArrowStyle = {
|
||||
fill: '#F9D354'
|
||||
}
|
||||
|
||||
export const TooltipWrapped: FC<TooltipWrappedProps> = ({
|
||||
children,
|
||||
active,
|
||||
|
|
@ -73,9 +82,14 @@ export const TooltipWrapped: FC<TooltipWrappedProps> = ({
|
|||
return (
|
||||
<Tooltip open={active}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side={tooltipSide} {...props}>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
side={tooltipSide}
|
||||
style={DefaultTooltipStyle}
|
||||
{...props}
|
||||
>
|
||||
{tooltipContent}
|
||||
<TooltipArrow style={arrowStyles} />
|
||||
<TooltipArrow style={arrowStyles ?? DefaultArrowStyle} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
|
|||
14
packages/web/components/elements/images/AIcon.tsx
Normal file
14
packages/web/components/elements/images/AIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
type AIconProps = {
|
||||
size: number
|
||||
color: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function AIcon(props: AIconProps): JSX.Element {
|
||||
return (
|
||||
<svg style={props.style} width={props.size} height={props.size} viewBox={`0 0 20 20`} fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.5 18L11.75 5.25L5 18" stroke={props.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16.5133 14.25H6.98828" stroke={props.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { config } from '../../tokens/stitches.config'
|
|||
import Image from 'next/image'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import Link from 'next/link'
|
||||
import { SpanBox } from '../LayoutPrimitives'
|
||||
|
||||
export function OmnivoreNameLogoImage(): JSX.Element {
|
||||
return (
|
||||
|
|
@ -51,7 +52,7 @@ export function OmnivoreNameLogo(props: OmnivoreNameLogoProps): JSX.Element {
|
|||
<Link passHref href={href}>
|
||||
<a style={{ textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
|
||||
<OmnivoreLogoIcon size={27} strokeColor={fillColor}></OmnivoreLogoIcon>
|
||||
<StyledText style="logoTitle" css={{ color: fillColor, paddingLeft: '12px' }}>Omnivore</StyledText>
|
||||
{/* <StyledText style="logoTitle" css={{ color: fillColor, paddingLeft: '12px' }}>Omnivore</StyledText> */}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type HeaderDropdownAction =
|
|||
| 'apply-lighter-theme'
|
||||
| 'navigate-to-install'
|
||||
| 'navigate-to-emails'
|
||||
| 'navigate-to-labels'
|
||||
| 'navigate-to-profile'
|
||||
| 'increaseFontSize'
|
||||
| 'decreaseFontSize'
|
||||
|
|
@ -24,7 +25,6 @@ export type HeaderDropdownAction =
|
|||
type DropdownMenuProps = {
|
||||
username?: string
|
||||
triggerElement: ReactNode
|
||||
displayFontStepper?: boolean
|
||||
actionHandler: (action: HeaderDropdownAction) => void
|
||||
}
|
||||
|
||||
|
|
@ -53,24 +53,6 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
|
|||
{ isDark ? '✓' : '' }
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{props.displayFontStepper && (
|
||||
<>
|
||||
<HStack css={{ mt: '8px', width: '100%', height: '26px', gap: '8px', borderRadius: '6px', border: '1px solid $grayTextContrast', }}>
|
||||
<Button style='plainIcon' css={{ display: 'inline-block', verticalAlign: 'baseline', width: '50%', height: '100%', bg: 'unset' }} onClick={() => {
|
||||
props.actionHandler('decreaseFontSize')
|
||||
}}>
|
||||
<StyledText css={{ fontSize: '14px', m: '0px' }}>A</StyledText>
|
||||
</Button>
|
||||
<Box css={{ width: '1px', height: '100%', bg: '$grayTextContrast' }} />
|
||||
<Button style='plainIcon' css={{ display: 'inline-block', verticalAlign: 'baseline', width: '50%', height: '100%', bg: 'unset' }} onClick={() => {
|
||||
props.actionHandler('increaseFontSize')
|
||||
}}>
|
||||
<StyledText css={{ fontSize: '18px', m: '0px' }}>A</StyledText>
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
<DropdownSeparator />
|
||||
<DropdownOption
|
||||
|
|
@ -81,6 +63,10 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
|
|||
onSelect={() => props.actionHandler('navigate-to-emails')}
|
||||
title="Emails"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.actionHandler('navigate-to-labels')}
|
||||
title="Labels"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => window.Intercom('show')}
|
||||
title="Feedback"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function HighlightBar(props: HighlightBarProps): JSX.Element {
|
|||
background: '$grayBg',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid $grayBorder',
|
||||
boxShadow: '0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
|
||||
boxShadow: theme.shadows.cardBoxShadow.toString(),
|
||||
bottom: 'calc(38px + env(safe-area-inset-bottom, 40px))',
|
||||
'@smDown': {
|
||||
maxWidth: '80%',
|
||||
|
|
|
|||
|
|
@ -154,11 +154,11 @@ export function GridLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
|
|||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack css={{ mt: '8px' }}>
|
||||
{props.item.labels?.map(({ description, color }, index) => (
|
||||
<LabelChip key={index} text={description || ''} color={color} />
|
||||
{/* <HStack css={{ mt: '8px' }}>
|
||||
{props.item.labels?.map(({ name, color }, index) => (
|
||||
<LabelChip key={index} text={name || ''} color={color} />
|
||||
))}
|
||||
</HStack>
|
||||
</HStack> */}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
|
|||
import { setupAnalytics } from '../../lib/analytics'
|
||||
import { Button } from '../elements/Button'
|
||||
import Link from 'next/link'
|
||||
import { ArrowSquareOut } from 'phosphor-react'
|
||||
|
||||
type HeaderProps = {
|
||||
user?: UserBasicData
|
||||
|
|
@ -26,7 +25,7 @@ type HeaderProps = {
|
|||
profileImageURL?: string
|
||||
isFixedPosition: boolean
|
||||
scrollElementRef?: React.RefObject<HTMLDivElement>
|
||||
displayFontStepper?: boolean
|
||||
toolbarControl?: JSX.Element
|
||||
setShowLogoutConfirmation: (showShareModal: boolean) => void
|
||||
setShowKeyboardCommandsModal: (showShareModal: boolean) => void
|
||||
}
|
||||
|
|
@ -55,9 +54,6 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
|
|||
(changeset: ScrollOffsetChangeset) => {
|
||||
const isScrolledBeyondMinThreshold = changeset.current.y >= 50
|
||||
const isScrollingDown = changeset.current.y > changeset.previous.y
|
||||
|
||||
// setIsScrolled(isScrolledBeyondMinThreshold)
|
||||
// setShowHeader(!(isScrollingDown && isScrolledBeyondMinThreshold))
|
||||
},
|
||||
0
|
||||
)
|
||||
|
|
@ -106,6 +102,9 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
|
|||
case 'navigate-to-emails':
|
||||
router.push('/settings/emails')
|
||||
break
|
||||
case 'navigate-to-labels':
|
||||
router.push('/settings/labels')
|
||||
break
|
||||
case 'navigate-to-profile':
|
||||
if (props.user) {
|
||||
router.push(`/${props.user.profile.username}`)
|
||||
|
|
@ -128,7 +127,7 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
|
|||
isDisplayingShadow={isScrolled}
|
||||
isVisible={true}
|
||||
isFixedPosition={true}
|
||||
displayFontStepper={props.displayFontStepper}
|
||||
toolbarControl={props.toolbarControl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
@ -143,7 +142,7 @@ type NavHeaderProps = {
|
|||
isDisplayingShadow?: boolean
|
||||
isVisible?: boolean
|
||||
isFixedPosition: boolean
|
||||
displayFontStepper?: boolean
|
||||
toolbarControl?: JSX.Element
|
||||
}
|
||||
|
||||
function NavHeader(props: NavHeaderProps): JSX.Element {
|
||||
|
|
@ -159,16 +158,16 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
|
|||
zIndex: 5,
|
||||
width: '100%',
|
||||
boxShadow: props.isDisplayingShadow ? '$panelShadow' : 'unset',
|
||||
bg: '$grayBase',
|
||||
p: '0px $3 0px $3',
|
||||
height: '68px',
|
||||
height: '48px',
|
||||
position: 'fixed',
|
||||
minHeight: '68px',
|
||||
bg: 'transparent',
|
||||
'@smDown': {
|
||||
height: '48px',
|
||||
minHeight: '48px',
|
||||
p: '0px 18px 0px 16px',
|
||||
},
|
||||
'@lgDown': {
|
||||
bg: '$grayBase',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HStack alignment="center" distribution="start">
|
||||
|
|
@ -183,26 +182,26 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
|
|||
</Box>
|
||||
<NavLinks currentPath={currentPath} isLoggedIn={!!props.username} />
|
||||
</HStack>
|
||||
|
||||
{props.toolbarControl && (
|
||||
<HStack distribution="end" alignment="center" css={{
|
||||
height: '100%', width: '100%',
|
||||
mr: '16px',
|
||||
display: 'none',
|
||||
'@lgDown': {
|
||||
display: 'flex',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.toolbarControl}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{props.username ? (
|
||||
<HStack
|
||||
alignment="center"
|
||||
css={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Box css={{ '@smDown': { visibility: 'collapse' } }}>
|
||||
<a href="https://github.com/omnivore-app/omnivore" target='_blank' rel="noreferrer">
|
||||
<Button style="ctaLightGray" css={{ background: 'unset', mr: '32px' }}>
|
||||
<HStack css={{ height: '100%' }}>
|
||||
<svg version="1.1" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill={theme.colors.grayTextContrast.toString()} fillRule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
<SpanBox css={{ pl: '8px', color: '$grayTextContrast' }}>Follow us on GitHub</SpanBox>
|
||||
<Box className='ctaButtonIcon' css={{ ml: '4px' }}>
|
||||
<ArrowSquareOut size={16} />
|
||||
</Box>
|
||||
</HStack>
|
||||
</Button>
|
||||
</a>
|
||||
</Box>
|
||||
<DropdownMenu
|
||||
username={props.username}
|
||||
triggerElement={
|
||||
|
|
@ -212,7 +211,6 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
|
|||
/>
|
||||
}
|
||||
actionHandler={props.actionHandler}
|
||||
displayFontStepper={props.displayFontStepper}
|
||||
/>
|
||||
</HStack>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
ReactNode,
|
||||
MutableRefObject,
|
||||
useEffect,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { PrimaryHeader } from './../patterns/PrimaryHeader'
|
||||
|
|
@ -23,7 +22,7 @@ type PrimaryLayoutProps = {
|
|||
hideHeader?: boolean
|
||||
pageMetaDataProps?: PageMetaDataProps
|
||||
scrollElementRef?: MutableRefObject<HTMLDivElement | null>
|
||||
displayFontStepper?: boolean
|
||||
headerToolbarControl?: JSX.Element
|
||||
}
|
||||
|
||||
export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
|
||||
|
|
@ -62,23 +61,28 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
|
|||
{props.pageMetaDataProps ? (
|
||||
<PageMetaData {...props.pageMetaDataProps} />
|
||||
) : null}
|
||||
<Box css={{ bg: '$grayBase', height: '100vh', width: '100vw' }}>
|
||||
<Box css={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
bg: 'transparent',
|
||||
'@smDown': {
|
||||
bg: '$grayBase',
|
||||
}
|
||||
}}>
|
||||
<PrimaryHeader
|
||||
user={viewerData?.me}
|
||||
hideHeader={props.hideHeader}
|
||||
userInitials={viewerData?.me?.name.charAt(0) ?? ''}
|
||||
profileImageURL={viewerData?.me?.profile.pictureUrl}
|
||||
isFixedPosition={true}
|
||||
toolbarControl={props.headerToolbarControl}
|
||||
scrollElementRef={props.scrollElementRef}
|
||||
displayFontStepper={props.displayFontStepper}
|
||||
setShowLogoutConfirmation={setShowLogoutConfirmation}
|
||||
setShowKeyboardCommandsModal={setShowKeyboardCommandsModal}
|
||||
/>
|
||||
<Box
|
||||
ref={props.scrollElementRef}
|
||||
css={{
|
||||
top: '68px',
|
||||
'@smDown': { top: '48px' },
|
||||
position: 'fixed',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
|
|
@ -86,6 +90,13 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
|
|||
bg: '$grayBase',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={props.scrollElementRef}
|
||||
css={{
|
||||
height: '48px',
|
||||
bg: '$grayBase',
|
||||
}}
|
||||
></Box>
|
||||
{props.children}
|
||||
{showLogoutConfirmation ? (
|
||||
<ConfirmationModal
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ export function SettingsLayout(props: SettingsLayoutProps): JSX.Element {
|
|||
/>
|
||||
<Box
|
||||
css={{
|
||||
top: '68px',
|
||||
'@smDown': { top: '48px' },
|
||||
top: '48px',
|
||||
position: 'fixed',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
|
|
|
|||
154
packages/web/components/templates/article/ArticleActionsMenu.tsx
Normal file
154
packages/web/components/templates/article/ArticleActionsMenu.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { Separator } from "@radix-ui/react-separator"
|
||||
import { ArchiveBox, DotsThree, HighlighterCircle, TagSimple, TextAa } from "phosphor-react"
|
||||
import { ArticleAttributes } from "../../../lib/networking/queries/useGetArticleQuery"
|
||||
import { useGetUserPreferences } from "../../../lib/networking/queries/useGetUserPreferences"
|
||||
import { Button } from "../../elements/Button"
|
||||
import { Dropdown } from "../../elements/DropdownElements"
|
||||
import { Box, SpanBox } from "../../elements/LayoutPrimitives"
|
||||
import { TooltipWrapped } from "../../elements/Tooltip"
|
||||
import { styled, theme } from "../../tokens/stitches.config"
|
||||
import { SetLabelsControl } from "./SetLabelsControl"
|
||||
import { ReaderSettingsControl } from "./ReaderSettingsControl"
|
||||
import { usePersistedState } from "../../../lib/hooks/usePersistedState"
|
||||
|
||||
export type ArticleActionsMenuLayout = 'horizontal' | 'vertical'
|
||||
|
||||
type ArticleActionsMenuProps = {
|
||||
article: ArticleAttributes
|
||||
layout: ArticleActionsMenuLayout
|
||||
lineHeight: number
|
||||
marginWidth: number
|
||||
articleActionHandler: (action: string, arg?: unknown) => void
|
||||
}
|
||||
|
||||
type MenuSeparatorProps = {
|
||||
layout: ArticleActionsMenuLayout
|
||||
}
|
||||
|
||||
const MenuSeparator = (props: MenuSeparatorProps): JSX.Element => {
|
||||
const LineSeparator = styled(Separator, {
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
|
||||
my: '8px',
|
||||
})
|
||||
return (props.layout == 'vertical' ? <LineSeparator /> : <></>)
|
||||
}
|
||||
|
||||
type ActionDropdownProps = {
|
||||
layout: ArticleActionsMenuLayout
|
||||
triggerElement: JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
|
||||
return <Dropdown
|
||||
showArrow={true}
|
||||
css={{ m: '0px', p: '0px', overflow: 'hidden', width: '265px', maxWidth: '265px', '@smDown': { width: '230px' } }}
|
||||
side={props.layout == 'vertical' ? 'right' : 'bottom'}
|
||||
sideOffset={props.layout == 'vertical' ? 8 : 0}
|
||||
align={props.layout == 'vertical' ? 'start' : 'center'}
|
||||
alignOffset={props.layout == 'vertical' ? -18 : undefined}
|
||||
triggerElement={props.triggerElement}
|
||||
>
|
||||
{props.children}
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: props.layout == 'vertical' ? 'column' : 'row',
|
||||
justifyContent: props.layout == 'vertical' ? 'center' : 'flex-end',
|
||||
gap: props.layout == 'vertical' ? '8px' : '24px',
|
||||
paddingTop: '6px',
|
||||
}}
|
||||
>
|
||||
|
||||
<ActionDropdown
|
||||
layout={props.layout}
|
||||
triggerElement={
|
||||
<TooltipWrapped
|
||||
tooltipContent="Adjust Display Settings"
|
||||
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
|
||||
>
|
||||
<TextAa size={24} color={theme.colors.readerFont.toString()} />
|
||||
</TooltipWrapped>
|
||||
}
|
||||
>
|
||||
<ReaderSettingsControl
|
||||
lineHeight={props.lineHeight}
|
||||
marginWidth={props.marginWidth}
|
||||
articleActionHandler={props.articleActionHandler}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
|
||||
<MenuSeparator layout={props.layout} />
|
||||
|
||||
<SpanBox css={{
|
||||
'display': 'flex',
|
||||
'@smDown': {
|
||||
display: 'none',
|
||||
}}}
|
||||
>
|
||||
<ActionDropdown
|
||||
layout={props.layout}
|
||||
triggerElement={
|
||||
<TooltipWrapped
|
||||
tooltipContent="Edit labels"
|
||||
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
|
||||
>
|
||||
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
|
||||
</TooltipWrapped>
|
||||
}
|
||||
>
|
||||
<SetLabelsControl
|
||||
article={props.article}
|
||||
articleActionHandler={props.articleActionHandler}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</SpanBox>
|
||||
|
||||
<Button style='articleActionIcon'
|
||||
onClick={() => props.articleActionHandler('setLabels')}
|
||||
css={{
|
||||
'display': 'none',
|
||||
'@smDown': {
|
||||
display: 'flex',
|
||||
},
|
||||
}}>
|
||||
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
|
||||
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('showHighlights')}>
|
||||
<TooltipWrapped
|
||||
tooltipContent="View Highlights"
|
||||
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
|
||||
>
|
||||
<HighlighterCircle size={24} color={theme.colors.readerFont.toString()} />
|
||||
</TooltipWrapped>
|
||||
</Button>
|
||||
|
||||
<MenuSeparator layout={props.layout} />
|
||||
|
||||
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('archive')}>
|
||||
<TooltipWrapped
|
||||
tooltipContent="Archive"
|
||||
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
|
||||
>
|
||||
<ArchiveBox size={24} color={theme.colors.readerFont.toString()} />
|
||||
</TooltipWrapped>
|
||||
</Button>
|
||||
{/* <MenuSeparator layout={props.layout} />
|
||||
|
||||
<Button style='articleActionIcon'>
|
||||
<DotsThree size={24} color={theme.colors.readerFont.toString()} />
|
||||
</Button> */}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { Article } from './../../../components/templates/article/Article'
|
||||
import { Box, VStack } from './../../elements/LayoutPrimitives'
|
||||
import { Box, SpanBox, VStack } from './../../elements/LayoutPrimitives'
|
||||
import { StyledText } from './../../elements/StyledText'
|
||||
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { theme, ThemeId } from './../../tokens/stitches.config'
|
||||
|
|
@ -13,9 +13,12 @@ import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
|
|||
import { userPersonalizationMutation } from '../../../lib/networking/mutations/userPersonalizationMutation'
|
||||
import { updateThemeLocally } from '../../../lib/themeUpdater'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
import { LabelChip } from '../../elements/LabelChip'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
article: ArticleAttributes
|
||||
labels: Label[]
|
||||
articleMutations: ArticleMutations
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
isAppleAppEmbed: boolean
|
||||
|
|
@ -24,21 +27,22 @@ type ArticleContainerProps = {
|
|||
margin?: number
|
||||
fontSize?: number
|
||||
fontFamily?: string
|
||||
lineHeight?: number
|
||||
showHighlightsModal: boolean
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showLabelsModal, setShowLabelsModal] = useState(false)
|
||||
const [showNotesSidebar, setShowNotesSidebar] = useState(false)
|
||||
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
|
||||
const [showHighlightsModal, setShowHighlightsModal] = useState(props.showHighlightsModal)
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
|
||||
const [labels, setLabels] = useState(
|
||||
props.article.labels?.map((l) => l.id) || []
|
||||
)
|
||||
|
||||
const updateFontSize = async (newFontSize: number) => {
|
||||
setFontSize(newFontSize)
|
||||
await userPersonalizationMutation({ fontSize: newFontSize })
|
||||
if (fontSize !== newFontSize) {
|
||||
setFontSize(newFontSize)
|
||||
await userPersonalizationMutation({ fontSize: newFontSize })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -85,8 +89,9 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
}, [props.article])
|
||||
|
||||
const styles = {
|
||||
margin: props.margin ?? 140,
|
||||
fontSize,
|
||||
margin: props.margin ?? 360,
|
||||
lineHeight: props.lineHeight ?? 150,
|
||||
fontFamily: props.fontFamily ?? 'inter',
|
||||
readerFontColor: theme.colors.readerFont.toString(),
|
||||
readerFontColorTransparent: theme.colors.readerFontTransparent.toString(),
|
||||
|
|
@ -100,10 +105,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
id="article-container"
|
||||
css={{
|
||||
padding: '16px',
|
||||
maxWidth: '94%',
|
||||
maxWidth: '100%',
|
||||
background: props.isAppleAppEmbed ? 'unset' : theme.colors.grayBg.toString(),
|
||||
'--text-font-family': styles.fontFamily,
|
||||
'--text-font-size': `${styles.fontSize}px`,
|
||||
'--line-height': `150%`,
|
||||
'--line-height': `${styles.lineHeight}%`,
|
||||
'--blockquote-padding': '0.5em 1em',
|
||||
'--blockquote-icon-font-size': '1.3rem',
|
||||
'--figure-margin': '1.6rem auto',
|
||||
|
|
@ -117,15 +123,15 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
'--blockquote-icon-font-size': '1.7rem',
|
||||
'--figure-margin': '2.6875rem auto',
|
||||
'--hr-margin': '2em',
|
||||
margin: `30px ${styles.margin / 2}px`,
|
||||
margin: `30px 0px`,
|
||||
},
|
||||
'@md': {
|
||||
maxWidth: '92%',
|
||||
maxWidth: 1024 - (styles.margin),
|
||||
},
|
||||
'@lg': {
|
||||
margin: `30px 0`,
|
||||
width: 'auto',
|
||||
maxWidth: 1024 - styles.margin,
|
||||
maxWidth: 1024 - (styles.margin),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -143,13 +149,22 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
author={props.article.author}
|
||||
href={props.article.url}
|
||||
/>
|
||||
<ArticleHeaderToolbar
|
||||
articleTitle={props.article.title}
|
||||
articleShareURL={props.highlightsBaseURL}
|
||||
setShowNotesSidebar={setShowNotesSidebar}
|
||||
setShowShareArticleModal={setShowShareModal}
|
||||
hasHighlights={props.article.highlights?.length > 0}
|
||||
/>
|
||||
{props.labels ? (
|
||||
<SpanBox css={{ pb: '16px', width: '100%', '&:empty': { display: 'none' } }}>
|
||||
{props.labels?.map((label) =>
|
||||
<LabelChip key={label.id} text={label.name} color={label.color} />
|
||||
)}
|
||||
</SpanBox>
|
||||
) : null}
|
||||
{props.isAppleAppEmbed && (
|
||||
<ArticleHeaderToolbar
|
||||
articleTitle={props.article.title}
|
||||
articleShareURL={props.highlightsBaseURL}
|
||||
setShowShareArticleModal={setShowShareModal}
|
||||
setShowHighlightsModal={props.setShowHighlightsModal}
|
||||
hasHighlights={props.article.highlights?.length > 0}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
<Article
|
||||
articleId={props.article.id}
|
||||
|
|
@ -181,10 +196,10 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
articleAuthor={props.article.author ?? ''}
|
||||
articleId={props.article.id}
|
||||
isAppleAppEmbed={props.isAppleAppEmbed}
|
||||
highlightBarDisabled={props.highlightBarDisabled}
|
||||
showNotesSidebar={showNotesSidebar}
|
||||
highlightsBaseURL={props.highlightsBaseURL}
|
||||
setShowNotesSidebar={setShowNotesSidebar}
|
||||
highlightBarDisabled={props.highlightBarDisabled}
|
||||
showHighlightsModal={props.showHighlightsModal}
|
||||
setShowHighlightsModal={props.setShowHighlightsModal}
|
||||
articleMutations={props.articleMutations}
|
||||
/>
|
||||
{showReportIssuesModal ? (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type ArticleHeaderToolbarProps = {
|
|||
articleTitle: string
|
||||
articleShareURL: string
|
||||
hasHighlights: boolean
|
||||
setShowNotesSidebar: (showNotesSidebar: boolean) => void
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setShowShareArticleModal: (showShareModal: boolean) => void
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +46,12 @@ export function ArticleHeaderToolbar(
|
|||
return (
|
||||
<HStack distribution="between" alignment="center" css={{ gap: '$2' }}>
|
||||
{props.hasHighlights && (
|
||||
<Button style="plainIcon" onClick={() => props.setShowNotesSidebar(true)} title="View all your highlights and notes">
|
||||
<Button style="plainIcon" onClick={() => {
|
||||
if (props.setShowHighlightsModal) {
|
||||
props.setShowHighlightsModal(true)
|
||||
}
|
||||
}}
|
||||
title="View all your highlights and notes">
|
||||
<CommentIcon
|
||||
size={24}
|
||||
strokeColor={theme.colors.grayTextContrast.toString()}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { X } from 'phosphor-react'
|
||||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { UserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { CrossIcon } from '../../elements/images/CrossIcon'
|
||||
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import {
|
||||
ModalRoot,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
} from '../../elements/ModalPrimitives'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { ReaderSettingsControl } from './ReaderSettingsControl'
|
||||
|
||||
type DisplaySettingsModalProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
lineHeight: number
|
||||
marginWidth: number
|
||||
articleActionHandler: (action: string, arg?: number) => void
|
||||
}
|
||||
|
||||
export function DisplaySettingsModal(props: DisplaySettingsModalProps): JSX.Element {
|
||||
return (
|
||||
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
css={{ overflow: 'auto' }}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault()
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<VStack css={{ width: '100%' }}>
|
||||
<HStack
|
||||
distribution="between"
|
||||
alignment="center"
|
||||
css={{ width: '100%' }}
|
||||
>
|
||||
<StyledText style="modalHeadline" css={{ pl: '16px' }}>Labels</StyledText>
|
||||
<Button
|
||||
css={{ pt: '16px', pr: '16px' }}
|
||||
style="ghost"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<CrossIcon
|
||||
size={14}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
<ReaderSettingsControl
|
||||
lineHeight={props.lineHeight}
|
||||
marginWidth={props.marginWidth}
|
||||
articleActionHandler={props.articleActionHandler}
|
||||
/>
|
||||
</VStack>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import {
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalRoot,
|
||||
} from '../../elements/ModalPrimitives'
|
||||
import { HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { CrossIcon } from '../../elements/images/CrossIcon'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { Label, useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { ChangeEvent, useCallback, useState } from 'react'
|
||||
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
|
||||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { LabelChip } from '../../elements/LabelChip'
|
||||
|
||||
type EditLabelsModalProps = {
|
||||
labels: Label[]
|
||||
article: ArticleAttributes
|
||||
onOpenChange: (open: boolean) => void
|
||||
setLabels: (labels: Label[]) => void
|
||||
}
|
||||
|
||||
export function EditLabelsModal(props: EditLabelsModalProps): JSX.Element {
|
||||
const [selectedLabels, setSelectedLabels] = useState(props.labels)
|
||||
const { labels } = useGetLabelsQuery()
|
||||
|
||||
const saveAndExit = useCallback(async () => {
|
||||
const result = await setLabelsMutation(props.article.id, selectedLabels.map((l) => l.id))
|
||||
console.log('result of setting labels', result)
|
||||
props.onOpenChange(false)
|
||||
props.setLabels(selectedLabels)
|
||||
}, [props, selectedLabels])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
// const label = event.target.value
|
||||
// if (event.target.checked) {
|
||||
// setSelectedLabels([...selectedLabels, label])
|
||||
// } else {
|
||||
// setSelectedLabels(selectedLabels.filter((l) => l !== label))
|
||||
// }
|
||||
},
|
||||
[selectedLabels]
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalRoot defaultOpen onOpenChange={saveAndExit}>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
css={{ overflow: 'auto', p: '0' }}
|
||||
>
|
||||
<VStack distribution="start" css={{ p: '0' }}>
|
||||
<HStack
|
||||
distribution="between"
|
||||
alignment="center"
|
||||
css={{ width: '100%' }}
|
||||
>
|
||||
<StyledText style="modalHeadline" css={{ p: '16px' }}>
|
||||
Edit Labels
|
||||
</StyledText>
|
||||
<Button
|
||||
css={{ pt: '16px', pr: '16px' }}
|
||||
style="ghost"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<CrossIcon
|
||||
size={20}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
{labels &&
|
||||
labels.map((label) => (
|
||||
<HStack
|
||||
key={label.id}
|
||||
css={{ height: '50px', verticalAlign: 'middle' }}
|
||||
onClick={() => {
|
||||
// if (selectedLabels.includes(label.id)) {
|
||||
// setSelectedLabels(
|
||||
// selectedLabels.filter((id) => id !== label.id)
|
||||
// )
|
||||
// } else {
|
||||
// setSelectedLabels([...selectedLabels, label.id])
|
||||
// }
|
||||
}}
|
||||
>
|
||||
<LabelChip color={label.color} text={label.name} />
|
||||
<input
|
||||
type="checkbox"
|
||||
value={label.id}
|
||||
onChange={handleChange}
|
||||
checked={selectedLabels.includes(label)}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
<HStack css={{ width: '100%', mb: '16px' }} alignment="center">
|
||||
<Button style="ctaDarkYellow" onClick={saveAndExit}>
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
)
|
||||
}
|
||||
|
|
@ -25,9 +25,9 @@ type HighlightsLayerProps = {
|
|||
articleAuthor: string
|
||||
isAppleAppEmbed: boolean
|
||||
highlightBarDisabled: boolean
|
||||
showNotesSidebar: boolean
|
||||
showHighlightsModal: boolean
|
||||
highlightsBaseURL: string
|
||||
setShowNotesSidebar: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
|
||||
|
|
@ -466,11 +466,11 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
if (props.showNotesSidebar) {
|
||||
if (props.showHighlightsModal) {
|
||||
return (
|
||||
<HighlightsModal
|
||||
highlights={highlights}
|
||||
onOpenChange={() => props.setShowNotesSidebar(false)}
|
||||
onOpenChange={() => props.setShowHighlightsModal(false)}
|
||||
deleteHighlightAction={(highlightId: string) => {
|
||||
removeHighlightCallback(highlightId)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
|
|||
<ModalContent
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault()
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
css={{ overflow: 'auto', p: '0' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { styled, theme } from '../../tokens/stitches.config'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlignCenterHorizontalSimple, ArrowsInLineHorizontal, ArrowsOutLineHorizontal, Minus, Pen, Plus, Trash, X } from 'phosphor-react'
|
||||
import { AIcon } from '../../elements/images/AIcon'
|
||||
import { TickedRangeSlider } from '../../elements/TickedRangeSlider'
|
||||
import { showSuccessToast } from '../../../lib/toastHelpers'
|
||||
|
||||
|
||||
type ReaderSettingsProps = {
|
||||
marginWidth: number
|
||||
lineHeight: number
|
||||
articleActionHandler: (action: string, arg?: number) => void
|
||||
}
|
||||
|
||||
const VerticalDivider = styled(SpanBox, {
|
||||
width: '1px',
|
||||
height: '100%',
|
||||
background: `${theme.colors.grayLine.toString()}`,
|
||||
})
|
||||
|
||||
export function ReaderSettingsControl(props: ReaderSettingsProps): JSX.Element {
|
||||
const [lineHeight, setLineHeight] = useState(props.lineHeight)
|
||||
const [marginWidth, setMarginWidth] = useState(props.marginWidth)
|
||||
|
||||
useEffect(() => {
|
||||
setLineHeight(props.lineHeight)
|
||||
setMarginWidth(props.marginWidth)
|
||||
}, [props.lineHeight, props.marginWidth, setLineHeight, setMarginWidth])
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<HStack
|
||||
alignment='center'
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '70px',
|
||||
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
|
||||
}}
|
||||
>
|
||||
<Button style='plainIcon' onClick={() => props.articleActionHandler('decrementFontSize')}>
|
||||
<AIcon size={28} color={theme.colors.readerFont.toString()} />
|
||||
<Minus size={28} color={theme.colors.readerFont.toString()}/>
|
||||
</Button>
|
||||
<VerticalDivider />
|
||||
<Button style='plainIcon' onClick={() => props.articleActionHandler('incrementFontSize')}>
|
||||
<AIcon size={44} color={theme.colors.readerFont.toString()} />
|
||||
<Plus size={28} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack
|
||||
css={{
|
||||
p: '0px',
|
||||
m: '0px',
|
||||
pb: '14px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'@mdDown': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StyledText color={theme.colors.readerFontTransparent.toString()} css={{ pl: '8px', m: '0px', pt: '14px' }}>Margin:</StyledText>
|
||||
<HStack distribution='between' css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}>
|
||||
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
|
||||
const newMarginWith = Math.max(marginWidth - 45, 200)
|
||||
setMarginWidth(newMarginWith)
|
||||
props.articleActionHandler('setMarginWidth', newMarginWith)
|
||||
}}>
|
||||
<ArrowsOutLineHorizontal size={24} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
<TickedRangeSlider min={200} max={560} step={45} value={marginWidth} onChange={(value) => {
|
||||
setMarginWidth(value)
|
||||
props.articleActionHandler('setMarginWidth', value)
|
||||
}} />
|
||||
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
|
||||
const newMarginWith = Math.min(marginWidth + 45, 560)
|
||||
setMarginWidth(newMarginWith)
|
||||
props.articleActionHandler('setMarginWidth', newMarginWith)
|
||||
}}>
|
||||
<ArrowsInLineHorizontal size={24} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack css={{
|
||||
p: '0px',
|
||||
m: '0px',
|
||||
pb: '12px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<StyledText color={theme.colors.readerFontTransparent.toString()} css={{ pl: '12px', m: '0px', pt: '14px' }}>Line Spacing:</StyledText>
|
||||
<HStack distribution='between' css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}>
|
||||
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
|
||||
const newLineHeight = Math.max(lineHeight - 25, 100)
|
||||
setLineHeight(newLineHeight)
|
||||
props.articleActionHandler('setLineHeight', newLineHeight)
|
||||
}}>
|
||||
<AlignCenterHorizontalSimple size={25} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
<TickedRangeSlider min={100} max={300} step={25} value={lineHeight} onChange={(value) => {
|
||||
setLineHeight(value)
|
||||
props.articleActionHandler('setLineHeight', value)
|
||||
}} />
|
||||
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
|
||||
const newLineHeight = Math.min(lineHeight + 25, 300)
|
||||
setLineHeight(newLineHeight)
|
||||
props.articleActionHandler('setLineHeight', newLineHeight)
|
||||
}}>
|
||||
<AlignCenterHorizontalSimple size={25} color={theme.colors.readerFont.toString()} />
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Button style='plainIcon' css={{ justifyContent: 'center', textDecoration: 'underline', display: 'flex', gap: '4px', width: '100%', fontSize: '12px', p: '8px', pb: '0px', pt: '16px', height: '42px', alignItems: 'center' }}
|
||||
onClick={() => {
|
||||
setMarginWidth(290)
|
||||
setLineHeight(150)
|
||||
props.articleActionHandler('resetReaderSettings')
|
||||
showSuccessToast('Display settings reset', { position: 'bottom-right' })
|
||||
}}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
345
packages/web/components/templates/article/SetLabelsControl.tsx
Normal file
345
packages/web/components/templates/article/SetLabelsControl.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { CrossIcon } from '../../elements/images/CrossIcon'
|
||||
import { styled, theme } from '../../tokens/stitches.config'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { Check, Circle, PencilSimple, Plus } from 'phosphor-react'
|
||||
import { isTouchScreenDevice } from '../../../lib/deviceType'
|
||||
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
|
||||
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
type SetLabelsControlProps = {
|
||||
article: ArticleAttributes
|
||||
articleActionHandler: (action: string, arg?: unknown) => void
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
filterText: string
|
||||
focused: boolean
|
||||
resetFocusedIndex: () => void
|
||||
setFilterText: (text: string) => void
|
||||
}
|
||||
|
||||
const FormInput = styled('input', {
|
||||
width: '100%',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'inter',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '1.8',
|
||||
color: '$grayTextContrast',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledLabel = styled('label', {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
})
|
||||
|
||||
function Header(props: HeaderProps): JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTouchScreenDevice() && props.focused && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [props.focused])
|
||||
|
||||
return (
|
||||
<VStack css={{ width: '100%', my: '0px', borderBottom: '1px solid $grayBorder'}}>
|
||||
<Box css={{
|
||||
width: '100%',
|
||||
my: '14px',
|
||||
px: '14px',
|
||||
}}>
|
||||
<FormInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
tabIndex={props.focused && !isTouchScreenDevice() ? 0 : -1}
|
||||
autoFocus={!isTouchScreenDevice()}
|
||||
value={props.filterText}
|
||||
placeholder="Filter for label"
|
||||
onChange={(event) => {
|
||||
props.setFilterText(event.target.value)
|
||||
}}
|
||||
onFocus={() => {
|
||||
props.resetFocusedIndex()
|
||||
}}
|
||||
css={{
|
||||
border: '1px solid $grayBorder',
|
||||
borderRadius: '8px',
|
||||
width: '100%',
|
||||
bg: 'transparent',
|
||||
fontSize: '16px',
|
||||
textIndent: '8px',
|
||||
marginBottom: '2px',
|
||||
color: '$grayTextContrast',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>)
|
||||
}
|
||||
|
||||
type LabelListItemProps = {
|
||||
label: Label
|
||||
focused: boolean
|
||||
selected: boolean
|
||||
toggleLabel: (label: Label) => void
|
||||
}
|
||||
|
||||
function LabelListItem(props: LabelListItemProps): JSX.Element {
|
||||
const ref = useRef<HTMLLabelElement>(null)
|
||||
const { label, focused, selected } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (props.focused && ref.current) {
|
||||
ref.current.focus()
|
||||
}
|
||||
}, [props.focused])
|
||||
|
||||
return (
|
||||
<StyledLabel
|
||||
ref={ref}
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '42px',
|
||||
borderBottom: '1px solid $grayBorder',
|
||||
bg: props.focused ? '$grayBgActive' : 'unset',
|
||||
}}
|
||||
tabIndex={props.focused ? 0 : -1}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.toggleLabel(label)
|
||||
ref.current?.blur()
|
||||
}}
|
||||
>
|
||||
<input autoFocus={focused} hidden={true} type="checkbox" checked={selected} readOnly />
|
||||
<Box css={{ pl: '10px', width: '32px', display: 'flex', alignItems: 'center' }}>
|
||||
{selected && <Check size={15} color={theme.colors.grayText.toString()} weight='bold' />}
|
||||
</Box>
|
||||
<Box css={{ width: '30px', height: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<Circle width={22} height={22} color={label.color} weight='fill' />
|
||||
</Box>
|
||||
<Box css={{ overflow: 'clip', height: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<StyledText style="caption">{label.name}</StyledText>
|
||||
</Box>
|
||||
<Box css={{ pl: '10px', width: '40px', marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
{selected && <CrossIcon
|
||||
size={14}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
/>}
|
||||
</Box>
|
||||
</StyledLabel>
|
||||
)
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
function Footer(props: FooterProps): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.focused && ref.current) {
|
||||
ref.current.focus()
|
||||
}
|
||||
}, [props.focused])
|
||||
|
||||
return (
|
||||
<HStack
|
||||
ref={ref}
|
||||
distribution="start" alignment="center"
|
||||
css={{
|
||||
width: '100%', height: '42px',
|
||||
bg: props.focused ? '$grayBgActive' : 'unset',
|
||||
color: theme.colors.grayText.toString(),
|
||||
'a:link': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'a:visited': {
|
||||
color: theme.colors.grayText.toString(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SpanBox css={{ display: 'flex', fontSize: '12px', padding: '33px', gap: '8px' }}>
|
||||
<PencilSimple size={18} color={theme.colors.grayText.toString()} />
|
||||
<Link href="/settings/labels">Edit labels</Link>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
|
||||
const router = useRouter()
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const { labels, revalidate } = useGetLabelsQuery()
|
||||
const [selectedLabels, setSelectedLabels] = useState<Label[]>(props.article.labels || [])
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedIndex(undefined)
|
||||
}, [filterText])
|
||||
|
||||
const isSelected = useCallback((label: Label): boolean => {
|
||||
return selectedLabels.some((other) => {
|
||||
return other.id === label.id
|
||||
})
|
||||
}, [selectedLabels])
|
||||
|
||||
const toggleLabel = useCallback(async (label: Label) => {
|
||||
let newSelectedLabels = [...selectedLabels]
|
||||
if (isSelected(label)) {
|
||||
newSelectedLabels = selectedLabels.filter((other) => {
|
||||
return other.id !== label.id
|
||||
})
|
||||
} else {
|
||||
newSelectedLabels = [...selectedLabels, label]
|
||||
}
|
||||
setSelectedLabels(newSelectedLabels)
|
||||
|
||||
const result = await setLabelsMutation(
|
||||
props.article.linkId,
|
||||
newSelectedLabels.map((label) => label.id)
|
||||
)
|
||||
|
||||
props.article.labels = result
|
||||
props.articleActionHandler('refreshLabels', result)
|
||||
|
||||
revalidate()
|
||||
}, [isSelected, selectedLabels, setSelectedLabels])
|
||||
|
||||
const filteredLabels = useMemo(() => {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter((label) => {
|
||||
return label.name.toLowerCase().includes(filterText.toLowerCase())
|
||||
})
|
||||
}, [labels, filterText])
|
||||
|
||||
// Move focus through the labels list on tab or arrow up/down keys
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(undefined)
|
||||
const handleKeyDown = useCallback(async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const maxIndex = filteredLabels.length + 1
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
let newIndex = focusedIndex
|
||||
if (focusedIndex) {
|
||||
newIndex = Math.max(0, focusedIndex - 1)
|
||||
} else {
|
||||
newIndex = undefined
|
||||
}
|
||||
// If the `Create New label` button isn't visible we skip it
|
||||
// when navigating with the arrow keys
|
||||
if (focusedIndex === maxIndex && !filterText) {
|
||||
newIndex = maxIndex - 2
|
||||
}
|
||||
setFocusedIndex(newIndex)
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
let newIndex = focusedIndex
|
||||
if (focusedIndex === undefined) {
|
||||
newIndex = 0
|
||||
} else {
|
||||
newIndex = Math.min(maxIndex, focusedIndex + 1)
|
||||
}
|
||||
// If the `Create New label` button isn't visible we skip it
|
||||
// when navigating with the arrow keys
|
||||
if (focusedIndex === maxIndex - 2 && !filterText) {
|
||||
newIndex = maxIndex
|
||||
}
|
||||
setFocusedIndex(newIndex)
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (focusedIndex === maxIndex) {
|
||||
router.push('/settings/labels')
|
||||
return
|
||||
}
|
||||
if (focusedIndex === maxIndex - 1) {
|
||||
await createLabelFromFilterText()
|
||||
return
|
||||
}
|
||||
if (focusedIndex !== undefined) {
|
||||
const label = filteredLabels[focusedIndex]
|
||||
if (label) {
|
||||
toggleLabel(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [filterText, filteredLabels, focusedIndex, isSelected, selectedLabels, setSelectedLabels])
|
||||
|
||||
const createLabelFromFilterText = useCallback(async () => {
|
||||
const label = await createLabelMutation(filterText, randomLabelColorHex(), '')
|
||||
if (label) {
|
||||
showSuccessToast(`Created label ${label.name}`, { position: 'bottom-right' })
|
||||
toggleLabel(label)
|
||||
} else {
|
||||
showErrorToast('Failed to create label', { position: 'bottom-right' })
|
||||
}
|
||||
}, [filterText, selectedLabels, setSelectedLabels, toggleLabel])
|
||||
|
||||
return (
|
||||
<VStack
|
||||
distribution="start"
|
||||
onKeyDown={handleKeyDown}
|
||||
css={{
|
||||
p: '0',
|
||||
width: '100%',
|
||||
}}>
|
||||
<Header
|
||||
focused={focusedIndex === undefined}
|
||||
resetFocusedIndex={() => setFocusedIndex(undefined)}
|
||||
setFilterText={setFilterText} filterText={filterText}
|
||||
/>
|
||||
<VStack
|
||||
distribution="start"
|
||||
alignment="start"
|
||||
css={{ flexGrow: '1', overflow: 'scroll', width: '100%', height: '100%', maxHeight: '294px'
|
||||
}}>
|
||||
{filteredLabels &&
|
||||
filteredLabels.map((label, idx) => (
|
||||
<LabelListItem
|
||||
key={label.id}
|
||||
label={label}
|
||||
focused={idx === focusedIndex}
|
||||
selected={isSelected(label)}
|
||||
toggleLabel={toggleLabel}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
{filterText && (
|
||||
<Button style='modalOption' css={{
|
||||
pl: '26px',
|
||||
color: theme.colors.grayText.toString(),
|
||||
height: '42px',
|
||||
borderBottom: '1px solid $grayBorder',
|
||||
bg: focusedIndex === filteredLabels.length ? '$grayBgActive' : 'unset',
|
||||
}}
|
||||
onClick={createLabelFromFilterText}
|
||||
>
|
||||
<HStack alignment='center' distribution='start' css={{ gap: '8px' }}>
|
||||
<Plus size={18} color={theme.colors.grayText.toString()} />
|
||||
<SpanBox css={{ fontSize: '12px' }}>{`Create new label "${filterText}"`}</SpanBox>
|
||||
</HStack>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Footer focused={focusedIndex === (filteredLabels.length + 1)} />
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
56
packages/web/components/templates/article/SetLabelsModal.tsx
Normal file
56
packages/web/components/templates/article/SetLabelsModal.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { CrossIcon } from '../../elements/images/CrossIcon'
|
||||
import { HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import {
|
||||
ModalRoot,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
} from '../../elements/ModalPrimitives'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { SetLabelsControl } from './SetLabelsControl'
|
||||
|
||||
type SetLabelsModalProps = {
|
||||
article: ArticleAttributes
|
||||
onOpenChange: (open: boolean) => void
|
||||
articleActionHandler: (action: string, arg?: unknown) => void
|
||||
}
|
||||
|
||||
export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
|
||||
return (
|
||||
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
css={{ border: '1px solid $grayBorder' }}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault()
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<VStack css={{ width: '100%' }}>
|
||||
<HStack
|
||||
distribution="between"
|
||||
alignment="center"
|
||||
css={{ width: '100%' }}
|
||||
>
|
||||
<StyledText style="modalHeadline" css={{ pl: '16px' }}>Labels</StyledText>
|
||||
<Button
|
||||
css={{ pt: '16px', pr: '16px' }}
|
||||
style="ghost"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<CrossIcon
|
||||
size={14}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
<SetLabelsControl {...props} />
|
||||
</VStack>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
)
|
||||
}
|
||||
|
|
@ -102,8 +102,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
borderStyles: {},
|
||||
shadows: {
|
||||
panelShadow: '0px 4px 18px rgba(120, 123, 134, 0.12)',
|
||||
cardBoxShadow:
|
||||
'0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
|
||||
cardBoxShadow: '0px 0px 4px 0px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
zIndices: {},
|
||||
transitions: {},
|
||||
|
|
@ -156,6 +155,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
xsmDown: '(max-width: 375px)',
|
||||
smDown: '(max-width: 575px)',
|
||||
mdDown: '(max-width: 768px)',
|
||||
lgDown: '(max-width: 992px)',
|
||||
sm: '(min-width: 576px)',
|
||||
md: '(min-width: 768px)',
|
||||
lg: '(min-width: 992px)',
|
||||
|
|
@ -199,7 +199,7 @@ const darkThemeSpec = {
|
|||
},
|
||||
shadows: {
|
||||
cardBoxShadow:
|
||||
'0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
|
||||
'0px 0px 9px -2px rgba(255, 255, 255, 0.09), 0px 7px 12px rgba(255, 255, 255, 0.07)',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,7 +207,9 @@ type ArticleKeyboardAction =
|
|||
| 'openOriginalArticle'
|
||||
| 'incrementFontSize'
|
||||
| 'decrementFontSize'
|
||||
| 'editLabels'
|
||||
| 'incrementMarginWidth'
|
||||
| 'decrementMarginWidth'
|
||||
| 'setLabels'
|
||||
|
||||
export function articleKeyboardCommands(
|
||||
router: NextRouter | undefined,
|
||||
|
|
@ -238,11 +240,23 @@ export function articleKeyboardCommands(
|
|||
shortcutKeyDescription: '-',
|
||||
callback: () => actionHandler('decrementFontSize'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: [']'],
|
||||
actionDescription: 'Increase margin width',
|
||||
shortcutKeyDescription: ']',
|
||||
callback: () => actionHandler('incrementMarginWidth'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['['],
|
||||
actionDescription: 'Decrease margin width',
|
||||
shortcutKeyDescription: '[',
|
||||
callback: () => actionHandler('decrementMarginWidth'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['l'],
|
||||
actionDescription: 'Edit labels',
|
||||
shortcutKeyDescription: 'l',
|
||||
callback: () => actionHandler('editLabels'),
|
||||
callback: () => actionHandler('setLabels'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { gql } from 'graphql-request'
|
||||
|
||||
export type LabelColor =
|
||||
| '#FF5D99'
|
||||
| '#7CFF7B'
|
||||
| '#FFD234'
|
||||
| '#7BE4FF'
|
||||
| '#CE88EF'
|
||||
| '#EF8C43'
|
||||
| 'custom color';
|
||||
|
||||
export const labelFragment = gql`
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
|
|
@ -13,7 +22,7 @@ export const labelFragment = gql`
|
|||
export type Label = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
color: LabelColor
|
||||
description?: string
|
||||
createdAt: string
|
||||
}
|
||||
createdAt: Date
|
||||
}
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { Label } from '../fragments/labelFragment'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
type CreateLabelResult = {
|
||||
createLabel: CreateLabel
|
||||
errorCodes?: unknown[]
|
||||
}
|
||||
|
||||
type CreateLabel = {
|
||||
label: Label
|
||||
}
|
||||
|
||||
export async function createLabelMutation(
|
||||
name: string,
|
||||
color: string,
|
||||
|
|
@ -32,9 +42,9 @@ export async function createLabelMutation(
|
|||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation)
|
||||
const data = await gqlFetcher(mutation) as CreateLabelResult
|
||||
console.log('created label', data)
|
||||
return data
|
||||
return data.errorCodes ? undefined : data.createLabel.label
|
||||
} catch (error) {
|
||||
console.log('createLabelMutation error', error)
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import { Label, labelFragment } from '../fragments/labelFragment'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
type SetLabelsResult = {
|
||||
setLabels: SetLabels
|
||||
errorCodes?: unknown[]
|
||||
}
|
||||
|
||||
type SetLabels = {
|
||||
labels: Label[]
|
||||
}
|
||||
|
||||
export async function setLabelsMutation(
|
||||
pageId: string,
|
||||
labelIds: string[]
|
||||
): Promise<any | undefined> {
|
||||
): Promise<Label[] | undefined> {
|
||||
const mutation = gql`
|
||||
mutation SetLabels($input: SetLabelsInput!) {
|
||||
setLabels(input: $input) {
|
||||
... on SetLabelsSuccess {
|
||||
labels {
|
||||
id
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
... on SetLabelsError {
|
||||
|
|
@ -18,12 +28,12 @@ export async function setLabelsMutation(
|
|||
}
|
||||
}
|
||||
}
|
||||
${labelFragment}
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation, { input: { pageId, labelIds } })
|
||||
console.log(data)
|
||||
return data
|
||||
const data = await gqlFetcher(mutation, { input: { pageId, labelIds } }) as SetLabelsResult
|
||||
return data.errorCodes ? undefined : data.setLabels.labels
|
||||
} catch (error) {
|
||||
console.log('SetLabelsOutput error', error)
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import useSWRImmutable from 'swr'
|
||||
import useSWRImmutable, { Cache } from 'swr'
|
||||
import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers'
|
||||
import { articleFragment, ContentReader } from '../fragments/articleFragment'
|
||||
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
|
||||
import { ScopedMutator } from 'swr/dist/types'
|
||||
import { Label, labelFragment } from '../fragments/labelFragment'
|
||||
import { LibraryItems } from './useGetLibraryItemsQuery'
|
||||
|
||||
type ArticleQueryInput = {
|
||||
username?: string
|
||||
|
|
@ -77,16 +78,7 @@ const query = gql`
|
|||
${highlightFragment}
|
||||
${labelFragment}
|
||||
`
|
||||
export const cacheArticle = (
|
||||
mutate: ScopedMutator,
|
||||
username: string,
|
||||
article: ArticleAttributes,
|
||||
includeFriendsHighlights = false
|
||||
) => {
|
||||
mutate([query, username, article.slug, includeFriendsHighlights], {
|
||||
article: { article: { ...article, cached: true } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function useGetArticleQuery({
|
||||
username,
|
||||
|
|
@ -99,7 +91,7 @@ export function useGetArticleQuery({
|
|||
includeFriendsHighlights,
|
||||
}
|
||||
|
||||
const { data, error } = useSWRImmutable(
|
||||
const { data, error, mutate } = useSWRImmutable(
|
||||
slug ? [query, username, slug, includeFriendsHighlights] : null,
|
||||
makeGqlFetcher(variables)
|
||||
)
|
||||
|
|
@ -133,3 +125,46 @@ export async function articleQuery(
|
|||
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
export const cacheArticle = (
|
||||
mutate: ScopedMutator,
|
||||
username: string,
|
||||
article: ArticleAttributes,
|
||||
includeFriendsHighlights = false
|
||||
) => {
|
||||
mutate([query, username, article.slug, includeFriendsHighlights], {
|
||||
article: { article: { ...article, cached: true } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const removeItemFromCache = (
|
||||
cache: Cache<unknown>,
|
||||
mutate: ScopedMutator,
|
||||
itemId: string,
|
||||
) => {
|
||||
try {
|
||||
const mappedCache = cache as Map<string, unknown>
|
||||
mappedCache.forEach((value: any, key) => {
|
||||
if (typeof value == 'object' && 'articles' in value) {
|
||||
const articles = value.articles as LibraryItems
|
||||
const idx = articles.edges.findIndex((edge) => edge.node.id == itemId)
|
||||
if (idx > -1) {
|
||||
value.articles.edges.splice(idx, 1)
|
||||
mutate(key, value, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mappedCache.forEach((value: any, key) => {
|
||||
if (Array.isArray(value)) {
|
||||
const idx = value.findIndex((item) => 'articles' in item)
|
||||
if (idx > -1) {
|
||||
mutate(key, value, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('error removing item from cache', error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { gql } from 'graphql-request'
|
||||
import useSWR from 'swr'
|
||||
import { LabelColor } from '../../../utils/settings-page/labels/types';
|
||||
import { labelFragment } from '../fragments/labelFragment'
|
||||
import { Label, labelFragment } from '../fragments/labelFragment'
|
||||
import { publicGqlFetcher } from '../networkHelpers'
|
||||
|
||||
type LabelsQueryResponse = {
|
||||
|
|
@ -18,14 +17,6 @@ type LabelsData = {
|
|||
labels?: unknown
|
||||
}
|
||||
|
||||
export type Label = {
|
||||
id: string
|
||||
name: string
|
||||
color: LabelColor
|
||||
description?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export function useGetLabelsQuery(): LabelsQueryResponse {
|
||||
const query = gql`
|
||||
query GetLabels {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { articleFragment } from '../fragments/articleFragment'
|
|||
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
|
||||
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
|
||||
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
|
||||
import { Label } from './../fragments/labelFragment'
|
||||
import { Label, labelFragment } from './../fragments/labelFragment'
|
||||
import { showErrorToast, showSuccessToast } from '../../toastHelpers'
|
||||
|
||||
export type LibraryItemsQueryInput = {
|
||||
|
|
@ -40,7 +40,7 @@ export type LibraryItemsData = {
|
|||
articles: LibraryItems
|
||||
}
|
||||
|
||||
type LibraryItems = {
|
||||
export type LibraryItems = {
|
||||
edges: LibraryItem[]
|
||||
pageInfo: PageInfo
|
||||
errorCodes?: string[]
|
||||
|
|
@ -67,6 +67,30 @@ export type PageInfo = {
|
|||
totalCount: number
|
||||
}
|
||||
|
||||
const libraryItemFragment = gql`
|
||||
fragment ArticleFields on Article {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
image
|
||||
savedAt
|
||||
createdAt
|
||||
publishedAt
|
||||
contentReader
|
||||
originalArticleUrl
|
||||
readingProgressPercent
|
||||
readingProgressAnchorIndex
|
||||
slug
|
||||
isArchived
|
||||
description
|
||||
linkId
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
|
|
@ -109,7 +133,8 @@ export function useGetLibraryItemsQuery({
|
|||
}
|
||||
}
|
||||
}
|
||||
${articleFragment}
|
||||
${libraryItemFragment}
|
||||
${labelFragment}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function useGetNewsletterEmailsQuery(): NewsletterEmailsQueryResponse {
|
|||
isValidating,
|
||||
emailAddresses,
|
||||
revalidate: () => {
|
||||
mutate()
|
||||
mutate(undefined, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type UserPreferences = {
|
|||
fontSize: number
|
||||
fontFamily: string
|
||||
margin: number
|
||||
lineHeight?: number
|
||||
libraryLayoutType: string
|
||||
librarySortOrder?: SortOrder
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { PrimaryLayout } from '../../../components/templates/PrimaryLayout'
|
||||
import { LoadingView } from '../../../components/patterns/LoadingView'
|
||||
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
|
||||
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { removeItemFromCache, useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { useRouter } from 'next/router'
|
||||
import { VStack } from './../../../components/elements/LayoutPrimitives'
|
||||
import { ArticleContainer } from './../../../components/templates/article/ArticleContainer'
|
||||
import { PdfArticleContainerProps } from './../../../components/templates/article/PdfArticleContainer'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
|
||||
import { articleKeyboardCommands, navigationCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
|
@ -20,9 +20,16 @@ import { articleReadingProgressMutation } from '../../../lib/networking/mutation
|
|||
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
|
||||
import { userPersonalizationMutation } from '../../../lib/networking/mutations/userPersonalizationMutation'
|
||||
import Script from 'next/script'
|
||||
import { EditLabelsModal } from '../../../components/templates/article/EditLabelsModal'
|
||||
import { theme } from '../../../components/tokens/stitches.config'
|
||||
import { ArticleActionsMenu } from '../../../components/templates/article/ArticleActionsMenu'
|
||||
import { setLinkArchivedMutation } from '../../../lib/networking/mutations/setLinkArchivedMutation'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { isVipUser } from '../../../lib/featureFlag'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
import { SetLabelsModal } from '../../../components/templates/article/SetLabelsModal'
|
||||
import { DisplaySettingsModal } from '../../../components/templates/article/DisplaySettingsModal'
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
|
||||
|
||||
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
|
||||
() => import('./../../../components/templates/article/PdfArticleContainer'),
|
||||
|
|
@ -31,49 +38,121 @@ const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
|
|||
|
||||
export default function Home(): JSX.Element {
|
||||
const router = useRouter()
|
||||
const { cache, mutate } = useSWRConfig()
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const { slug } = router.query
|
||||
const [showLabelsModal, setShowLabelsModal] = useState(false)
|
||||
const [showHighlightsModal, setShowHighlightsModal] = useState(false)
|
||||
|
||||
// Populate data cache
|
||||
const { viewerData } = useGetViewerQuery()
|
||||
const { preferencesData } = useGetUserPreferences()
|
||||
const [fontSize, setFontSize] = useState(preferencesData?.fontSize ?? 20)
|
||||
const [lineHeight, setLineHeight] = usePersistedState({ key: 'lineHeight', initialValue: 150 })
|
||||
const [marginWidth, setMarginWidth] = usePersistedState({ key: 'marginWidth', initialValue: 200 })
|
||||
const [showSetLabelsModal, setShowSetLabelsModal] = useState(false)
|
||||
const [showEditDisplaySettingsModal, setShowEditDisplaySettingsModal] = useState(false)
|
||||
|
||||
const { articleData } = useGetArticleQuery({
|
||||
username: router.query.username as string,
|
||||
slug: router.query.slug as string,
|
||||
includeFriendsHighlights: false,
|
||||
})
|
||||
const { preferencesData } = useGetUserPreferences()
|
||||
const article = articleData?.article.article
|
||||
const [fontSize, setFontSize] = useState(preferencesData?.fontSize ?? 20)
|
||||
|
||||
const [labels, setLabels] = useState<Label[]>([])
|
||||
useEffect(() => {
|
||||
if (article?.labels) {
|
||||
setLabels(article.labels)
|
||||
}
|
||||
}, [article])
|
||||
|
||||
useKeyboardShortcuts(navigationCommands(router))
|
||||
|
||||
const updateFontSize = async (newFontSize: number) => {
|
||||
setFontSize(newFontSize)
|
||||
await userPersonalizationMutation({ fontSize: newFontSize })
|
||||
}
|
||||
const actionHandler = useCallback(async(action: string, arg?: unknown) => {
|
||||
const updateFontSize = async(newFontSize: number) => {
|
||||
setFontSize(newFontSize)
|
||||
await userPersonalizationMutation({ fontSize: newFontSize })
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'archive':
|
||||
if (article) {
|
||||
removeItemFromCache(cache, mutate, article.id)
|
||||
|
||||
await setLinkArchivedMutation({
|
||||
linkId: article.id,
|
||||
archived: true,
|
||||
}).then((res) => {
|
||||
if (res) {
|
||||
showSuccessToast('Link archived', { position: 'bottom-right' })
|
||||
} else {
|
||||
// todo: revalidate or put back in cache?
|
||||
showErrorToast('Error archiving link', { position: 'bottom-right' })
|
||||
}
|
||||
})
|
||||
|
||||
router.push(`/home`)
|
||||
}
|
||||
break
|
||||
case 'openOriginalArticle':
|
||||
const url = article?.url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
break
|
||||
case 'refreshLabels':
|
||||
setLabels(arg as Label[])
|
||||
break
|
||||
case 'showHighlights':
|
||||
setShowHighlightsModal(true)
|
||||
break
|
||||
case 'incrementFontSize':
|
||||
await updateFontSize(Math.min(fontSize + 2, 28))
|
||||
break
|
||||
case 'decrementFontSize':
|
||||
await updateFontSize(Math.max(fontSize - 2, 10))
|
||||
break
|
||||
case 'setMarginWidth': {
|
||||
const value = Number(arg)
|
||||
if (value >= 200 && value <= 560) {
|
||||
setMarginWidth(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'incrementMarginWidth':
|
||||
setMarginWidth(Math.min(marginWidth + 45, 560))
|
||||
break
|
||||
case 'decrementMarginWidth':
|
||||
setMarginWidth(Math.max(marginWidth - 45, 200))
|
||||
break
|
||||
case 'setLineHeight': {
|
||||
const value = Number(arg)
|
||||
if (value >= 100 && value <= 300) {
|
||||
setLineHeight(arg as number)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'editDisplaySettings': {
|
||||
setShowEditDisplaySettingsModal(true)
|
||||
break
|
||||
}
|
||||
case 'setLabels': {
|
||||
setShowSetLabelsModal(true)
|
||||
break
|
||||
}
|
||||
case 'resetReaderSettings': {
|
||||
updateFontSize(20)
|
||||
setMarginWidth(290)
|
||||
setLineHeight(150)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [article, cache, mutate, router,
|
||||
fontSize, setFontSize, lineHeight,
|
||||
setLineHeight, marginWidth, setMarginWidth])
|
||||
|
||||
useKeyboardShortcuts(
|
||||
articleKeyboardCommands(router, async (action) => {
|
||||
switch (action) {
|
||||
case 'openOriginalArticle':
|
||||
const url = article?.url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
break
|
||||
case 'incrementFontSize':
|
||||
await updateFontSize(Math.min(fontSize + 2, 28))
|
||||
break
|
||||
case 'decrementFontSize':
|
||||
await updateFontSize(Math.max(fontSize - 2, 10))
|
||||
break
|
||||
case 'editLabels':
|
||||
if (viewerData?.me && isVipUser(viewerData?.me)) {
|
||||
setShowLabelsModal(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
actionHandler(action)
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -82,7 +161,15 @@ export default function Home(): JSX.Element {
|
|||
<PrimaryLayout
|
||||
pageTestId="home-page-tag"
|
||||
scrollElementRef={scrollRef}
|
||||
displayFontStepper={true}
|
||||
headerToolbarControl={
|
||||
<ArticleActionsMenu
|
||||
article={article}
|
||||
layout='horizontal'
|
||||
lineHeight={lineHeight}
|
||||
marginWidth={marginWidth}
|
||||
articleActionHandler={actionHandler}
|
||||
/>
|
||||
}
|
||||
pageMetaDataProps={{
|
||||
title: article.title,
|
||||
path: router.pathname,
|
||||
|
|
@ -97,6 +184,28 @@ export default function Home(): JSX.Element {
|
|||
/>
|
||||
<Toaster />
|
||||
|
||||
<VStack distribution="between" alignment="center" css={{
|
||||
position: 'fixed',
|
||||
flexDirection: 'row-reverse',
|
||||
top: '-120px',
|
||||
left: 8,
|
||||
height: '100%',
|
||||
width: '48px',
|
||||
'@lgDown': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{article.contentReader !== 'PDF' ? (
|
||||
<ArticleActionsMenu
|
||||
article={article}
|
||||
layout='vertical'
|
||||
lineHeight={lineHeight}
|
||||
marginWidth={marginWidth}
|
||||
articleActionHandler={actionHandler}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
{article.contentReader == 'PDF' ? (
|
||||
<PdfArticleContainerNoSSR
|
||||
article={article}
|
||||
|
|
@ -104,10 +213,15 @@ export default function Home(): JSX.Element {
|
|||
/>
|
||||
) : (
|
||||
<VStack
|
||||
alignment="center"
|
||||
distribution="center"
|
||||
ref={scrollRef}
|
||||
className="disable-webkit-callout"
|
||||
alignment="center"
|
||||
distribution="center"
|
||||
ref={scrollRef}
|
||||
className="disable-webkit-callout"
|
||||
css={{
|
||||
'@smDown': {
|
||||
background: theme.colors.grayBg.toString(),
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArticleContainer
|
||||
article={article}
|
||||
|
|
@ -116,6 +230,11 @@ export default function Home(): JSX.Element {
|
|||
highlightBarDisabled={false}
|
||||
highlightsBaseURL={`${webBaseURL}/${viewerData.me?.profile?.username}/${slug}/highlights`}
|
||||
fontSize={fontSize}
|
||||
margin={marginWidth}
|
||||
lineHeight={lineHeight}
|
||||
labels={labels}
|
||||
showHighlightsModal={showHighlightsModal}
|
||||
setShowHighlightsModal={setShowHighlightsModal}
|
||||
articleMutations={{
|
||||
createHighlightMutation,
|
||||
deleteHighlightMutation,
|
||||
|
|
@ -124,20 +243,25 @@ export default function Home(): JSX.Element {
|
|||
articleReadingProgressMutation,
|
||||
}}
|
||||
/>
|
||||
{/* {showLabelsModal && (
|
||||
<EditLabelsModal
|
||||
labels={article.labels || []}
|
||||
article={article}
|
||||
onOpenChange={() => {
|
||||
setShowLabelsModal(false)
|
||||
}}
|
||||
setLabels={(labels: Label[]) => {
|
||||
// setLabels(labels)
|
||||
}}
|
||||
/>
|
||||
)} */}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{showSetLabelsModal && (
|
||||
<SetLabelsModal
|
||||
article={article}
|
||||
articleActionHandler={actionHandler}
|
||||
onOpenChange={() => setShowSetLabelsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditDisplaySettingsModal && (
|
||||
<DisplaySettingsModal
|
||||
lineHeight={lineHeight}
|
||||
marginWidth={marginWidth}
|
||||
articleActionHandler={actionHandler}
|
||||
onOpenChange={() => setShowEditDisplaySettingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
</PrimaryLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ function AppArticleEmbedContent(
|
|||
props: AppArticleEmbedContentProps
|
||||
): JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showHighlightsModal, setShowHighlightsModal] = useState(false)
|
||||
|
||||
const { articleData } = useGetArticleQuery({
|
||||
username: props.username,
|
||||
|
|
@ -99,6 +100,9 @@ function AppArticleEmbedContent(
|
|||
fontSize={props.fontSize}
|
||||
margin={props.margin}
|
||||
fontFamily={props.fontFamily}
|
||||
labels={[]}
|
||||
showHighlightsModal={showHighlightsModal}
|
||||
setShowHighlightsModal={setShowHighlightsModal}
|
||||
articleMutations={{
|
||||
createHighlightMutation,
|
||||
deleteHighlightMutation,
|
||||
|
|
|
|||
89
packages/web/pages/help/labels.tsx
Normal file
89
packages/web/pages/help/labels.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Labels(): JSX.Element {
|
||||
return (
|
||||
<PrimaryLayout
|
||||
pageMetaDataProps={{
|
||||
title: 'Labels',
|
||||
path: '/help/labels',
|
||||
}}
|
||||
pageTestId="help-labels-tag"
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
m: '42px',
|
||||
maxWidth: '640px',
|
||||
color: '$grayText',
|
||||
img: {
|
||||
maxWidth: '85%',
|
||||
},
|
||||
'@smDown': {
|
||||
m: '16px',
|
||||
maxWidth: '85%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1>Organize your Omnivore library with labels</h1>
|
||||
<hr />
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
Labels allow you to group and search for content in Omnivore. A saved page
|
||||
can have multiple labels and search results can be filtered by label.
|
||||
</p>
|
||||
<h2>Adding labels to a page on iOS</h2>
|
||||
<p>
|
||||
On iOS you add and remove labels from a page using the Assign Labels modal.
|
||||
</p>
|
||||
<p>
|
||||
You can open the Assign Labels modal from the home view or the reader view.
|
||||
In the home view long press on an item and choose Edit Labels from the dropdowm
|
||||
menu. In the reader view use the top right menu button.
|
||||
</p>
|
||||
<h2>Adding labels to a page on the web</h2>
|
||||
<p>
|
||||
On the web you add and remove labels from a page using the Assign Labels dropdown
|
||||
or modal depending on your screen size. For larger monitors you will see the Labels
|
||||
button on the left hand side of the reader view. For smaller monitors you will see
|
||||
the labels dropdown at the top of your screen.
|
||||
</p>
|
||||
<p>
|
||||
You can also use keyboard commands to open the assign labels modal. On the reader
|
||||
view tap the <code>l</code> key. Once open you can use the up/down arrow keys, or the
|
||||
tab key to navigate the available labels, and the Enter key to toggle a label.
|
||||
</p>
|
||||
<h2>Searching by label on iOS</h2>
|
||||
<p>
|
||||
On iOS you can use the Labels search chip to search for labels. This will open a modal
|
||||
allowing you to assign multiple labels to your search. This will become an <code>OR </code>
|
||||
search, meaning if you add multiple labels to your search, pages that have any of the
|
||||
labels will be returned.
|
||||
</p>
|
||||
<h2>Searching by label with Advanced Search</h2>
|
||||
<p>
|
||||
Omnivore's advanced search syntax supports searching for multiple labels using
|
||||
<code>AND</code> and <code>OR</code> clauses. You can also negate a label search
|
||||
to find all pages that do not have a certain label.
|
||||
</p>
|
||||
<p>Some examples:</p>
|
||||
<ul>
|
||||
<li><code>-label:Newsletter</code> finds all pages that do not have the label <code>Newsletter</code></li>
|
||||
<li><code>label:Cooking,Fitness</code> finds all your pages with either the <code>Cooking</code> or <code>Fitness</code> labels</li>
|
||||
<li><code>label:Newsletter label:Surfing</code> finds all pages with both the <code>Newsletter</code> and <code>Surfing</code> labels</li>
|
||||
<li><code>label:Coding -label:Newsletter</code> finds all pages with the <code>Coding</code> label that do not have the <code>Newsletter</code> label</li>
|
||||
</ul>
|
||||
|
||||
<h2>Editing your list of labels</h2>
|
||||
<p>
|
||||
The <Link href="/settings/labels"><a>labels</a></Link> page allows you to
|
||||
edit all of your labels. From here you can create new labels, delete existing
|
||||
labels, or modify the color and description of a label.
|
||||
</p>
|
||||
</Box>
|
||||
<Box css={{ height: '120px' }} />
|
||||
</PrimaryLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,8 +15,7 @@ import { updateLabelMutation } from '../../lib/networking/mutations/updateLabelM
|
|||
import { deleteLabelMutation } from '../../lib/networking/mutations/deleteLabelMutation'
|
||||
import { applyStoredTheme, isDarkTheme } from '../../lib/themeUpdater'
|
||||
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
|
||||
import { Label } from '../../lib/networking/queries/useGetLabelsQuery'
|
||||
|
||||
import { Label, LabelColor } from '../../lib/networking/fragments/labelFragment'
|
||||
import { StyledText } from '../../components/elements/StyledText'
|
||||
import {
|
||||
ArrowClockwise,
|
||||
|
|
@ -26,7 +25,6 @@ import {
|
|||
Plus,
|
||||
} from 'phosphor-react'
|
||||
import {
|
||||
LabelColor,
|
||||
GenericTableCardProps,
|
||||
LabelColorHex,
|
||||
} from '../../utils/settings-page/labels/types'
|
||||
|
|
@ -185,18 +183,14 @@ export default function LabelsPage(): JSX.Element {
|
|||
|
||||
async function createLabel(): Promise<void> {
|
||||
const res = await createLabelMutation(
|
||||
nameInputText,
|
||||
nameInputText.trim(),
|
||||
labelColorHex.value,
|
||||
descriptionInputText
|
||||
)
|
||||
if (res) {
|
||||
if (res.createLabel.errorCodes && res.createLabel.errorCodes.length > 0) {
|
||||
showErrorToast(res.createLabel.errorCodes[0])
|
||||
} else {
|
||||
showSuccessToast('Label created', { position: 'bottom-right' })
|
||||
resetLabelState()
|
||||
revalidate()
|
||||
}
|
||||
showSuccessToast('Label created', { position: 'bottom-right' })
|
||||
resetLabelState()
|
||||
revalidate()
|
||||
} else {
|
||||
showErrorToast('Failed to create label')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,3 +99,44 @@ div#appleid-signin {
|
|||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--colors-grayBorderHover);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px !important;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--colors-utilityTextContrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px !important;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--colors-utilityTextContrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--colors-grayTextContrast);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
|
@ -44,3 +44,9 @@ export const labelColorObjects: LabelColorObjects = {
|
|||
background: '#D8D7D50D',
|
||||
},
|
||||
}
|
||||
|
||||
export const randomLabelColorHex = () => {
|
||||
const colorHexes = Object.keys(labelColorObjects).slice(0, -1)
|
||||
const randomColorHex = colorHexes[Math.floor(Math.random() * colorHexes.length)]
|
||||
return randomColorHex
|
||||
}
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
import React from "react";
|
||||
import { Label } from "../../../lib/networking/queries/useGetLabelsQuery";
|
||||
|
||||
export type LabelColor =
|
||||
| '#FF5D99'
|
||||
| '#7CFF7B'
|
||||
| '#FFD234'
|
||||
| '#7BE4FF'
|
||||
| '#CE88EF'
|
||||
| '#EF8C43'
|
||||
| 'custom color';
|
||||
import { Label, LabelColor } from "../../../lib/networking/fragments/labelFragment";
|
||||
|
||||
export type LabelOptionProps = {
|
||||
color: string;
|
||||
|
|
|
|||
|
|
@ -1727,9 +1727,9 @@ array-flatten@1.1.1:
|
|||
integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
|
||||
|
||||
async@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
|
||||
integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"
|
||||
integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==
|
||||
|
||||
at-least-node@^1.0.0:
|
||||
version "1.0.0"
|
||||
|
|
|
|||
237
yarn.lock
237
yarn.lock
|
|
@ -4681,20 +4681,20 @@
|
|||
"@sendgrid/client" "^7.6.0"
|
||||
"@sendgrid/helpers" "^7.6.0"
|
||||
|
||||
"@sentry/browser@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.17.7.tgz#04b72ae29a56c2001be525fa39c17aa7d9992e7f"
|
||||
integrity sha512-0Ad6TmB5KH5o152Hgk5tlxNiooV0Rfoj7HEzxdOnHFkl57aR7VsiPkzIBl9vxn4iyy7IheUONhHSOU1osJkv2w==
|
||||
"@sentry/browser@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.19.6.tgz#75be467667fffa1f4745382fc7a695568609c634"
|
||||
integrity sha512-V5QyY1cO1iuFCI78dOFbHV7vckbeQEPPq3a5dGSXlBQNYnd9Ec5xoxp5nRNpWQPOZ8/Ixt9IgRxdqVTkWib51g==
|
||||
dependencies:
|
||||
"@sentry/core" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
"@sentry/core" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/cli@^1.72.0":
|
||||
version "1.72.2"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.72.2.tgz#ba57d8248fe4d2836883bbe3047fdd75cd6739b3"
|
||||
integrity sha512-H/yrxyYscOzn0YBOzPsBnIW7QxsSQrG1kQfD1FisuMhPl67D948DPkagAms4sy1v+MntBBNnD4Z+WsdRzFWXJQ==
|
||||
"@sentry/cli@^1.73.0":
|
||||
version "1.74.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.74.3.tgz#8405a19f6bb21b2ff3d051fb8a18056cc796c5ae"
|
||||
integrity sha512-74NiqWTgTFDPe2S99h1ge5UMe6aAC44ebareadd1P6MdaNfYz6JUEa2QrDfMq7TKccEiRFXhXBHbUI8mxzrzuQ==
|
||||
dependencies:
|
||||
https-proxy-agent "^5.0.0"
|
||||
mkdirp "^0.5.5"
|
||||
|
|
@ -4702,6 +4702,7 @@
|
|||
npmlog "^4.1.2"
|
||||
progress "^2.0.3"
|
||||
proxy-from-env "^1.1.0"
|
||||
which "^2.0.2"
|
||||
|
||||
"@sentry/core@5.30.0":
|
||||
version "5.30.0"
|
||||
|
|
@ -4714,17 +4715,6 @@
|
|||
"@sentry/utils" "5.30.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/core@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.17.7.tgz#f591235c06b1a4e75d748b15c539e071bd3f5cf5"
|
||||
integrity sha512-SRhLkD05lQb4eCt1ed9Dz72DKbRDlM8PJix8eC2oJLtwyFTS0IlJNkIYRrbsSKkJUm0VsKcDkzIHvUAgBBQICw==
|
||||
dependencies:
|
||||
"@sentry/hub" "6.17.7"
|
||||
"@sentry/minimal" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/core@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.3.tgz#88268afc8c42716c455ad77bb4bed2bbf96abd83"
|
||||
|
|
@ -4736,6 +4726,17 @@
|
|||
"@sentry/utils" "6.19.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/core@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.6.tgz#7d4649d0148b5d0be1358ab02e2f869bf7363e9a"
|
||||
integrity sha512-biEotGRr44/vBCOegkTfC9rwqaqRKIpFljKGyYU6/NtzMRooktqOhjmjmItNCMRknArdeaQwA8lk2jcZDXX3Og==
|
||||
dependencies:
|
||||
"@sentry/hub" "6.19.6"
|
||||
"@sentry/minimal" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/hub@5.30.0":
|
||||
version "5.30.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.30.0.tgz#2453be9b9cb903404366e198bd30c7ca74cdc100"
|
||||
|
|
@ -4745,15 +4746,6 @@
|
|||
"@sentry/utils" "5.30.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/hub@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.17.7.tgz#5c90d661e263dad7da0e0106f1cb90cf797d93a7"
|
||||
integrity sha512-siGzcg+quGOdjRaBGAz6T3ycwHUsGgvalptSJdf5Q783FVFhU+haPul++zGOYURXOgx0RjYGWqagwO8+jljl3Q==
|
||||
dependencies:
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/hub@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.3.tgz#d555c83404f19ac9b68f336b051b8e7a9d75feb0"
|
||||
|
|
@ -4763,23 +4755,22 @@
|
|||
"@sentry/utils" "6.19.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/integrations@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.17.7.tgz#c14f409fec91743db4a21232413de235946cb6d3"
|
||||
integrity sha512-/BYVoLHY5gUZNan6bSlsRYX44Qi2UlusLuwgEc1HZm9m4C2h4mVwhHy0XlhBAYWCr0q28ggtiFNzIxqHSKZu6g==
|
||||
"@sentry/hub@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.6.tgz#ada83ceca0827c49534edfaba018221bc1eb75e1"
|
||||
integrity sha512-PuEOBZxvx3bjxcXmWWZfWXG+orojQiWzv9LQXjIgroVMKM/GG4QtZbnWl1hOckUj7WtKNl4hEGO2g/6PyCV/vA==
|
||||
dependencies:
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
localforage "^1.8.1"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/integrations@^6.19.1":
|
||||
version "6.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.19.1.tgz#ccc5da6b20b9ba9ff4e8ed79c92b512f8088fa8d"
|
||||
integrity sha512-0ViIiwx9BlPi8jvT97ERpJWmRbBUThGOyjNVzWxaa07OEjOgKI6cNgsBHWbPjaCM00WSzaOFOtYiC4464+Mucg==
|
||||
"@sentry/integrations@6.19.6", "@sentry/integrations@^6.19.1":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.19.6.tgz#157152f16a8ad8df8a97d08bfe740909446b075a"
|
||||
integrity sha512-K2xuA/ByhTh3qfIe0/XIsQSNf1HrRuIgtkC4TbU7T0QosybtXDsh6t/EWK+qzs2RjVE+Iaqldihstpoyew1JgA==
|
||||
dependencies:
|
||||
"@sentry/types" "6.19.1"
|
||||
"@sentry/utils" "6.19.1"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
localforage "^1.8.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
|
|
@ -4792,15 +4783,6 @@
|
|||
"@sentry/types" "5.30.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/minimal@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.17.7.tgz#f19182047f19b563f40a30d45d2ce9ad7df1ec4e"
|
||||
integrity sha512-+/FGem1uXsXikX9wHPw44nevO7YTVjkkiPjyLsvnWMjv64r4Au5s+NQSFHDaytRm9IlU//+OasCAS5VAwHcYRg==
|
||||
dependencies:
|
||||
"@sentry/hub" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/minimal@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.19.3.tgz#b9b7f0d7f0cd2341b243318668ac01458f9d7889"
|
||||
|
|
@ -4810,34 +4792,28 @@
|
|||
"@sentry/types" "6.19.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/nextjs@^6.16.1":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-6.17.7.tgz#00e0380ba2b709674e495b3c00b24ee82405575d"
|
||||
integrity sha512-X9XfO3vDDv3YEnOc7taGK6Sb1wfBlL3cc1jcMoOma8qW9z+8tDoM3NBPp2uOmWdfo28D0hxVOIvqFa/fiKbbEg==
|
||||
"@sentry/minimal@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.19.6.tgz#b6cced3708e25d322039e68ebdf8fadfa445bf7d"
|
||||
integrity sha512-T1NKcv+HTlmd8EbzUgnGPl4ySQGHWMCyZ8a8kXVMZOPDzphN3fVIzkYzWmSftCWp0rpabXPt9aRF2mfBKU+mAQ==
|
||||
dependencies:
|
||||
"@sentry/core" "6.17.7"
|
||||
"@sentry/hub" "6.17.7"
|
||||
"@sentry/integrations" "6.17.7"
|
||||
"@sentry/node" "6.17.7"
|
||||
"@sentry/react" "6.17.7"
|
||||
"@sentry/tracing" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
"@sentry/webpack-plugin" "1.18.5"
|
||||
"@sentry/hub" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/node@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-6.17.7.tgz#c142d93328b29312098276e0cb66ec3b9e805a93"
|
||||
integrity sha512-YlBhEv8YYUimHLpwzUP5lXkagbGxGAfjvsbahhvqf7rRl8Fu5XbatAcSDcx7YE4R0Iox94IfZy95kF2NL4Idow==
|
||||
"@sentry/nextjs@^6.16.1":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-6.19.6.tgz#419205659c0fe2b7695bb7c892c9014b55ddd01b"
|
||||
integrity sha512-xV6yj9H1Ieg4uSS4SsT1x5GvrWdifuBNLPWrneQ89kWBuPVFLLH1wZA0gvDuq6AJstRZ3A5pWI2IbwpmQzxMWQ==
|
||||
dependencies:
|
||||
"@sentry/core" "6.17.7"
|
||||
"@sentry/hub" "6.17.7"
|
||||
"@sentry/tracing" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
cookie "^0.4.1"
|
||||
https-proxy-agent "^5.0.0"
|
||||
lru_map "^0.3.3"
|
||||
"@sentry/core" "6.19.6"
|
||||
"@sentry/hub" "6.19.6"
|
||||
"@sentry/integrations" "6.19.6"
|
||||
"@sentry/node" "6.19.6"
|
||||
"@sentry/react" "6.19.6"
|
||||
"@sentry/tracing" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
"@sentry/webpack-plugin" "1.18.8"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/node@6.19.3":
|
||||
|
|
@ -4854,6 +4830,20 @@
|
|||
lru_map "^0.3.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/node@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-6.19.6.tgz#d63c4ffcf0150b4175a2e4e5021b53af46e5946f"
|
||||
integrity sha512-kHQMfsy40ZxxdS9zMPmXCOOLWOJbQj6/aVSHt/L1QthYcgkAi7NJQNXnQIPWQDe8eP3DfNIWM7dc446coqjXrQ==
|
||||
dependencies:
|
||||
"@sentry/core" "6.19.6"
|
||||
"@sentry/hub" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
cookie "^0.4.1"
|
||||
https-proxy-agent "^5.0.0"
|
||||
lru_map "^0.3.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/node@^5.26.0":
|
||||
version "5.30.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.30.0.tgz#4ca479e799b1021285d7fe12ac0858951c11cd48"
|
||||
|
|
@ -4869,15 +4859,15 @@
|
|||
lru_map "^0.3.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/react@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.17.7.tgz#550735bd9b56f20f15059f10a4573c80d5c882a5"
|
||||
integrity sha512-rt6nAa9SJ+A4va88FASBoR5/26V2uZx/yucT9NzwoBIW41jA8lAwTmFu5MdtxGDBKDUSJlL3lt/Nkiq6LkiTaw==
|
||||
"@sentry/react@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.19.6.tgz#4c07168637bfcef4d6556a2c4548b74a61eaed87"
|
||||
integrity sha512-RnWZ7clg1lRgf/JFNnTOs8ZPCv566E5CwFXXb6swyjPYUMcIn95XujDQU9SU4hXZ4qXd9BRvifxqyxvq0LMXNw==
|
||||
dependencies:
|
||||
"@sentry/browser" "6.17.7"
|
||||
"@sentry/minimal" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
"@sentry/browser" "6.19.6"
|
||||
"@sentry/minimal" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
tslib "^1.9.3"
|
||||
|
||||
|
|
@ -4906,17 +4896,6 @@
|
|||
"@sentry/utils" "5.30.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/tracing@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.17.7.tgz#f4536683b29bb3ac7ddda5ca49494731cec6b619"
|
||||
integrity sha512-QzIDHOjjdi/0LTdrK2LTC27YEOODI473KD8KmMJ+r9PmjDeIjNzz4hJlPwQSnXR3Mu/8foxGJGXsAt3LNmKzlQ==
|
||||
dependencies:
|
||||
"@sentry/hub" "6.17.7"
|
||||
"@sentry/minimal" "6.17.7"
|
||||
"@sentry/types" "6.17.7"
|
||||
"@sentry/utils" "6.17.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/tracing@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.19.3.tgz#dfdbd5019486c899bdf352b1152d5d253544ef70"
|
||||
|
|
@ -4928,26 +4907,32 @@
|
|||
"@sentry/utils" "6.19.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/tracing@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.19.6.tgz#faa156886afe441730f03cf9ac9c4982044b7135"
|
||||
integrity sha512-STZdlEtTBqRmPw6Vjkzi/1kGkGPgiX0zdHaSOhSeA2HXHwx7Wnfu7veMKxtKWdO+0yW9QZGYOYqp0GVf4Swujg==
|
||||
dependencies:
|
||||
"@sentry/hub" "6.19.6"
|
||||
"@sentry/minimal" "6.19.6"
|
||||
"@sentry/types" "6.19.6"
|
||||
"@sentry/utils" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/types@5.30.0":
|
||||
version "5.30.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.30.0.tgz#19709bbe12a1a0115bc790b8942917da5636f402"
|
||||
integrity sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==
|
||||
|
||||
"@sentry/types@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.17.7.tgz#61946a3f6532b8f21251b264f173b02f9ea2458e"
|
||||
integrity sha512-iBlJDhrSowZKeqvutY0tCkUjrWqkLFsHrbaQ553r1Nx+/4mxHjzVYtEVGMjZAxQUEbkm0TbnQIkkT7ltglNJ9A==
|
||||
|
||||
"@sentry/types@6.19.1":
|
||||
version "6.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.1.tgz#684a231f8fe10c9cf14f9552f5da0a4492e6fdbc"
|
||||
integrity sha512-ovmNYdqD2MKLmru4calxetX1xjJdYim+HEI/GzwvVUYshsaXRq4EiQ17h3DAy90MV7JH279PmMoPGDTOpufq+Q==
|
||||
|
||||
"@sentry/types@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.3.tgz#94b19da68d4d23561efb1014f72968bcea85cd0c"
|
||||
integrity sha512-jHhqxp8MIWSfOc3krorirTGKTEaSFO6XrAvi+2AZhr6gvOChwOgzgrN2ZqesJcZmgCsqWV21u3usSwYeRrjOJA==
|
||||
|
||||
"@sentry/types@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.6.tgz#70513f9dca05d23d7ab9c2a6cb08d4db6763ca67"
|
||||
integrity sha512-QH34LMJidEUPZK78l+Frt3AaVFJhEmIi05Zf8WHd9/iTt+OqvCHBgq49DDr1FWFqyYWm/QgW/3bIoikFpfsXyQ==
|
||||
|
||||
"@sentry/utils@5.30.0":
|
||||
version "5.30.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.30.0.tgz#9a5bd7ccff85ccfe7856d493bffa64cabc41e980"
|
||||
|
|
@ -4956,22 +4941,6 @@
|
|||
"@sentry/types" "5.30.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/utils@6.17.7":
|
||||
version "6.17.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.17.7.tgz#0574bf914cc129b5e47041b75bb34dfbe0decbba"
|
||||
integrity sha512-HEEEeKlZtwfQvH0waSKv5FKRFjHkVgkkEiAigXoYGQAlaUIuwRTvZGFnsmBoKMIrA4pARkA00FwwdtMU7ziC8A==
|
||||
dependencies:
|
||||
"@sentry/types" "6.17.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/utils@6.19.1":
|
||||
version "6.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.1.tgz#9d5f50a0ceb60b63631de88b4abed07253568209"
|
||||
integrity sha512-mWcQbQ1yO7PooLpJpQK84+wOfxGeb8iUKRb8inO+2Eg6VksDbXRuJ89Yd4APBTRxBj5Wihy48bPuVfKtovtm8g==
|
||||
dependencies:
|
||||
"@sentry/types" "6.19.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/utils@6.19.3":
|
||||
version "6.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.3.tgz#0c3a3f0b86c12e3b079e56e37a44e62a1226043d"
|
||||
|
|
@ -4980,12 +4949,20 @@
|
|||
"@sentry/types" "6.19.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/webpack-plugin@1.18.5":
|
||||
version "1.18.5"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-1.18.5.tgz#aaff79d8e05b8d803654490324252406c976b1cd"
|
||||
integrity sha512-HycNZEcVRj/LxaG6hLsxjHo47mpxop3j7u2aUkriE2pT7XNpeypsa0WiokYzStxzCfSu8rbAbX4PchTGLMlTjw==
|
||||
"@sentry/utils@6.19.6":
|
||||
version "6.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.6.tgz#2ddc9ef036c3847084c43d0e5a55e4646bdf9021"
|
||||
integrity sha512-fAMWcsguL0632eWrROp/vhPgI7sBj/JROWVPzpabwVkm9z3m1rQm6iLFn4qfkZL8Ozy6NVZPXOQ7EXmeU24byg==
|
||||
dependencies:
|
||||
"@sentry/cli" "^1.72.0"
|
||||
"@sentry/types" "6.19.6"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/webpack-plugin@1.18.8":
|
||||
version "1.18.8"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-1.18.8.tgz#247a73a0aa9e28099a736bbe89ca0d35cbac7636"
|
||||
integrity sha512-PtKr0NL62b5L3kPFGjwSNbIUwwcW5E5G6bQxAYZGpkgL1MFPnS4ND0SAsySuX0byQJRFFium5A19LpzyvQZSlQ==
|
||||
dependencies:
|
||||
"@sentry/cli" "^1.73.0"
|
||||
|
||||
"@sindresorhus/is@^0.14.0":
|
||||
version "0.14.0"
|
||||
|
|
@ -7620,9 +7597,9 @@ async-retry@^1.2.1, async-retry@^1.3.3:
|
|||
retry "0.13.1"
|
||||
|
||||
async@^2.6.2:
|
||||
version "2.6.3"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
|
||||
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
|
||||
version "2.6.4"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
|
||||
integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
|
||||
dependencies:
|
||||
lodash "^4.17.14"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue