feat: Add bookmark creation functionality and WebView dark mode support
- Add CREATE API endpoint for new bookmarks with POST request - Implement CreateBookmarkRequestDto and CreateBookmarkResponseDto - Create AddBookmarkView with form validation and clipboard integration - Add AddBookmarkViewModel with URL validation and label parsing - Implement CreateBookmarkUseCase with convenience methods - Extend BookmarksRepository with createBookmark method returning server message - Add comprehensive error handling for bookmark creation scenarios - Integrate WebView dark mode support with CSS variables and system color scheme - Add dynamic theme switching based on iOS appearance settings - Enhance WebView styling with iOS-native colors and typography - Fix BookmarksView refresh after bookmark creation - Add floating action button and sheet presentation for adding bookmarks - Implement form validation with real-time feedback - Add clipboard URL detection and paste functionality
This commit is contained in:
parent
cd265730d3
commit
da7bb1613c
@ -13,6 +13,7 @@ protocol PAPI {
|
|||||||
func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto]
|
func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto]
|
||||||
func getBookmark(id: String) async throws -> BookmarkDetailDto
|
func getBookmark(id: String) async throws -> BookmarkDetailDto
|
||||||
func getBookmarkArticle(id: String) async throws -> String
|
func getBookmarkArticle(id: String) async throws -> String
|
||||||
|
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
|
||||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
|
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
|
||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
}
|
}
|
||||||
@ -165,6 +166,17 @@ class API: PAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto {
|
||||||
|
let requestData = try JSONEncoder().encode(createRequest)
|
||||||
|
|
||||||
|
return try await makeJSONRequest(
|
||||||
|
endpoint: "/api/bookmarks",
|
||||||
|
method: .POST,
|
||||||
|
body: requestData,
|
||||||
|
responseType: CreateBookmarkResponseDto.self
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
|
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
|
||||||
let requestData = try JSONEncoder().encode(updateRequest)
|
let requestData = try JSONEncoder().encode(updateRequest)
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct BookmarkDto: Codable {
|
|||||||
let siteName: String
|
let siteName: String
|
||||||
let site: String
|
let site: String
|
||||||
let readingTime: Int?
|
let readingTime: Int?
|
||||||
let wordCount: Int
|
let wordCount: Int?
|
||||||
let hasArticle: Bool
|
let hasArticle: Bool
|
||||||
let isArchived: Bool
|
let isArchived: Bool
|
||||||
let isDeleted: Bool
|
let isDeleted: Bool
|
||||||
@ -62,3 +62,5 @@ struct ImageResourceDto: Codable {
|
|||||||
let height: Int
|
let height: Int
|
||||||
let width: Int
|
let width: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
13
readeck/Data/API/DTOs/CreateBookmarkRequestDto.swift
Normal file
13
readeck/Data/API/DTOs/CreateBookmarkRequestDto.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CreateBookmarkRequestDto: Codable {
|
||||||
|
let labels: [String]?
|
||||||
|
let title: String?
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
init(url: String, title: String? = nil, labels: [String]? = nil) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
self.labels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
6
readeck/Data/DTOs/CreateBookmarkResponseDto.swift
Normal file
6
readeck/Data/DTOs/CreateBookmarkResponseDto.swift
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CreateBookmarkResponseDto: Codable {
|
||||||
|
let message: String
|
||||||
|
let status: Int
|
||||||
|
}
|
||||||
@ -4,9 +4,9 @@ protocol PBookmarksRepository {
|
|||||||
func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark]
|
func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark]
|
||||||
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
||||||
func fetchBookmarkArticle(id: String) async throws -> String
|
func fetchBookmarkArticle(id: String) async throws -> String
|
||||||
|
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
|
||||||
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
func addBookmark(bookmark: Bookmark) async throws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class BookmarksRepository: PBookmarksRepository {
|
class BookmarksRepository: PBookmarksRepository {
|
||||||
@ -46,8 +46,21 @@ class BookmarksRepository: PBookmarksRepository {
|
|||||||
return try await api.getBookmarkArticle(id: id)
|
return try await api.getBookmarkArticle(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addBookmark(bookmark: Bookmark) async throws {
|
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String {
|
||||||
// Implement logic to add a bookmark if needed
|
let dto = CreateBookmarkRequestDto(
|
||||||
|
url: createRequest.url,
|
||||||
|
title: createRequest.title,
|
||||||
|
labels: createRequest.labels
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try await api.createBookmark(createRequest: dto)
|
||||||
|
|
||||||
|
// Prüfe ob die Erstellung erfolgreich war
|
||||||
|
guard response.status == 0 else {
|
||||||
|
throw CreateBookmarkError.serverError(response.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.message
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteBookmark(id: String) async throws {
|
func deleteBookmark(id: String) async throws {
|
||||||
@ -80,7 +93,7 @@ struct BookmarkDetail {
|
|||||||
let authors: [String]
|
let authors: [String]
|
||||||
let created: String
|
let created: String
|
||||||
let updated: String
|
let updated: String
|
||||||
let wordCount: Int
|
let wordCount: Int?
|
||||||
let readingTime: Int?
|
let readingTime: Int?
|
||||||
let hasArticle: Bool
|
let hasArticle: Bool
|
||||||
let isMarked: Bool
|
let isMarked: Bool
|
||||||
@ -88,3 +101,23 @@ struct BookmarkDetail {
|
|||||||
let thumbnailUrl: String
|
let thumbnailUrl: String
|
||||||
let imageUrl: String
|
let imageUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CreateBookmarkError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case duplicateBookmark
|
||||||
|
case networkError
|
||||||
|
case serverError(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Die eingegebene URL ist ungültig"
|
||||||
|
case .duplicateBookmark:
|
||||||
|
return "Dieser Bookmark existiert bereits"
|
||||||
|
case .networkError:
|
||||||
|
return "Netzwerkfehler beim Erstellen des Bookmarks"
|
||||||
|
case .serverError(let message):
|
||||||
|
return message // Verwende die Server-Nachricht direkt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ protocol UseCaseFactory {
|
|||||||
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
|
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
|
||||||
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
|
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
|
||||||
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
|
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
|
||||||
|
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultUseCaseFactory: UseCaseFactory {
|
class DefaultUseCaseFactory: UseCaseFactory {
|
||||||
@ -57,4 +58,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase {
|
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase {
|
||||||
return DeleteBookmarkUseCase(repository: bookmarksRepository)
|
return DeleteBookmarkUseCase(repository: bookmarksRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
|
||||||
|
return CreateBookmarkUseCase(repository: bookmarksRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct Bookmark {
|
|||||||
let siteName: String
|
let siteName: String
|
||||||
let site: String
|
let site: String
|
||||||
let readingTime: Int?
|
let readingTime: Int?
|
||||||
let wordCount: Int
|
let wordCount: Int?
|
||||||
let hasArticle: Bool
|
let hasArticle: Bool
|
||||||
let isArchived: Bool
|
let isArchived: Bool
|
||||||
let isDeleted: Bool
|
let isDeleted: Bool
|
||||||
|
|||||||
28
readeck/Domain/Model/CreateBookmarkRequest.swift
Normal file
28
readeck/Domain/Model/CreateBookmarkRequest.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CreateBookmarkRequest {
|
||||||
|
let url: String
|
||||||
|
let title: String?
|
||||||
|
let labels: [String]?
|
||||||
|
|
||||||
|
init(url: String, title: String? = nil, labels: [String]? = nil) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
self.labels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience Initializers
|
||||||
|
extension CreateBookmarkRequest {
|
||||||
|
static func fromURL(_ url: String) -> CreateBookmarkRequest {
|
||||||
|
return CreateBookmarkRequest(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromURLWithTitle(_ url: String, title: String) -> CreateBookmarkRequest {
|
||||||
|
return CreateBookmarkRequest(url: url, title: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromURLWithLabels(_ url: String, labels: [String]) -> CreateBookmarkRequest {
|
||||||
|
return CreateBookmarkRequest(url: url, labels: labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
readeck/Domain/UseCase/CreateBookmarkUseCase.swift
Normal file
51
readeck/Domain/UseCase/CreateBookmarkUseCase.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class CreateBookmarkUseCase {
|
||||||
|
private let repository: PBookmarksRepository
|
||||||
|
|
||||||
|
init(repository: PBookmarksRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(createRequest: CreateBookmarkRequest) async throws -> String {
|
||||||
|
// URL-Validierung
|
||||||
|
guard URL(string: createRequest.url) != nil else {
|
||||||
|
throw CreateBookmarkError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await repository.createBookmark(createRequest: createRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods für häufige Use Cases
|
||||||
|
func createFromURL(_ url: String) async throws -> String {
|
||||||
|
let request = CreateBookmarkRequest.fromURL(url)
|
||||||
|
return try await execute(createRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFromURLWithTitle(_ url: String, title: String) async throws -> String {
|
||||||
|
let request = CreateBookmarkRequest.fromURLWithTitle(url, title: title)
|
||||||
|
return try await execute(createRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String {
|
||||||
|
let request = CreateBookmarkRequest.fromURLWithLabels(url, labels: labels)
|
||||||
|
return try await execute(createRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFromClipboard() async throws -> String? {
|
||||||
|
return nil
|
||||||
|
// URL aus Zwischenablage holen (falls verfügbar)
|
||||||
|
/*#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
guard let clipboardString = UIPasteboard.general.string,
|
||||||
|
URL(string: clipboardString) != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = CreateBookmarkRequest.fromURL(clipboardString)
|
||||||
|
return try await execute(createRequest: request)
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif*/
|
||||||
|
}
|
||||||
|
}
|
||||||
134
readeck/UI/AddBookmark/AddBookmarkView.swift
Normal file
134
readeck/UI/AddBookmark/AddBookmarkView.swift
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddBookmarkView: View {
|
||||||
|
@State private var viewModel = AddBookmarkViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Bookmark Details")) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("URL *")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField("https://example.com", text: $viewModel.url)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Titel (optional)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField("Bookmark Titel", text: $viewModel.title)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Labels")) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Labels (durch Komma getrennt)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField("work, important, later", text: $viewModel.labelsText)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.parsedLabels.isEmpty {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.adaptive(minimum: 80))
|
||||||
|
], spacing: 8) {
|
||||||
|
ForEach(viewModel.parsedLabels, id: \.self) { label in
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Aus Zwischenablage einfügen") {
|
||||||
|
viewModel.pasteFromClipboard()
|
||||||
|
}
|
||||||
|
.disabled(viewModel.clipboardURL == nil)
|
||||||
|
|
||||||
|
if let clipboardURL = viewModel.clipboardURL {
|
||||||
|
Text("Zwischenablage: \(clipboardURL)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Bookmark hinzufügen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Speichern") {
|
||||||
|
Task {
|
||||||
|
await viewModel.createBookmark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.isValid || viewModel.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.3)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
|
||||||
|
Text("Bookmark wird erstellt...")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(radius: 10)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Erfolgreich", isPresented: $viewModel.showSuccessAlert) {
|
||||||
|
Button("OK") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Bookmark wurde erfolgreich hinzugefügt!")
|
||||||
|
}
|
||||||
|
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.checkClipboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AddBookmarkView()
|
||||||
|
}
|
||||||
80
readeck/UI/AddBookmark/AddBookmarkViewModel.swift
Normal file
80
readeck/UI/AddBookmark/AddBookmarkViewModel.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class AddBookmarkViewModel {
|
||||||
|
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||||
|
|
||||||
|
var url: String = ""
|
||||||
|
var title: String = ""
|
||||||
|
var labelsText: String = ""
|
||||||
|
|
||||||
|
var isLoading: Bool = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var showErrorAlert: Bool = false
|
||||||
|
var showSuccessAlert: Bool = false
|
||||||
|
var clipboardURL: String?
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
!url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedLabels: [String] {
|
||||||
|
labelsText
|
||||||
|
.components(separatedBy: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func createBookmark() async {
|
||||||
|
guard isValid else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let labels = parsedLabels
|
||||||
|
|
||||||
|
let request = CreateBookmarkRequest(
|
||||||
|
url: cleanURL,
|
||||||
|
title: cleanTitle.isEmpty ? nil : cleanTitle,
|
||||||
|
labels: labels.isEmpty ? nil : labels
|
||||||
|
)
|
||||||
|
|
||||||
|
let message = try await createBookmarkUseCase.execute(createRequest: request)
|
||||||
|
|
||||||
|
// Optional: Zeige die Server-Nachricht an
|
||||||
|
print("Server response: \(message)")
|
||||||
|
|
||||||
|
showSuccessAlert = true
|
||||||
|
|
||||||
|
} catch let error as CreateBookmarkError {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showErrorAlert = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Fehler beim Erstellen des Bookmarks"
|
||||||
|
showErrorAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkClipboard() {
|
||||||
|
guard let clipboardString = UIPasteboard.general.string,
|
||||||
|
URL(string: clipboardString) != nil else {
|
||||||
|
clipboardURL = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboardURL = clipboardString
|
||||||
|
}
|
||||||
|
|
||||||
|
func pasteFromClipboard() {
|
||||||
|
guard let clipboardURL = clipboardURL else { return }
|
||||||
|
url = clipboardURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,17 +28,17 @@ struct BookmarkCardView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
// Status-Badges und Action-Button
|
// Status-Icons und Action-Button
|
||||||
HStack {
|
HStack {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
if bookmark.isMarked {
|
if bookmark.isMarked {
|
||||||
Badge(text: "Markiert", color: .blue)
|
IconBadge(systemName: "heart.fill", color: .red)
|
||||||
}
|
}
|
||||||
if bookmark.isArchived {
|
if bookmark.isArchived {
|
||||||
Badge(text: "Archiviert", color: .gray)
|
IconBadge(systemName: "archivebox.fill", color: .gray)
|
||||||
}
|
}
|
||||||
if bookmark.hasArticle {
|
if bookmark.hasArticle {
|
||||||
Badge(text: "Artikel", color: .green)
|
IconBadge(systemName: "doc.text.fill", color: .green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,18 +146,17 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Badge: View {
|
struct IconBadge: View {
|
||||||
let text: String
|
let systemName: String
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Image(systemName: systemName)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 6)
|
.padding(6)
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(color.opacity(0.2))
|
.background(color.opacity(0.2))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.clipShape(Capsule())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct BookmarksView: View {
|
struct BookmarksView: View {
|
||||||
@State private var viewModel = BookmarksViewModel()
|
@State private var viewModel = BookmarksViewModel()
|
||||||
|
@State private var showingAddBookmark = false
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -55,6 +56,18 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(state.displayName)
|
.navigationTitle(state.displayName)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button(action: {
|
||||||
|
showingAddBookmark = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddBookmark) {
|
||||||
|
AddBookmarkView()
|
||||||
|
}
|
||||||
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
|
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||||
Button("OK", role: .cancel) {
|
Button("OK", role: .cancel) {
|
||||||
viewModel.errorMessage = nil
|
viewModel.errorMessage = nil
|
||||||
@ -65,6 +78,14 @@ struct BookmarksView: View {
|
|||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarks(state: state)
|
await viewModel.loadBookmarks(state: state)
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
||||||
|
// Refresh bookmarks when sheet is dismissed
|
||||||
|
if oldValue && !newValue {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshBookmarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import WebKit
|
|||||||
struct WebView: UIViewRepresentable {
|
struct WebView: UIViewRepresentable {
|
||||||
let htmlContent: String
|
let htmlContent: String
|
||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
let webView = WKWebView()
|
let webView = WKWebView()
|
||||||
webView.navigationDelegate = context.coordinator
|
webView.navigationDelegate = context.coordinator
|
||||||
webView.scrollView.isScrollEnabled = false
|
webView.scrollView.isScrollEnabled = false
|
||||||
|
webView.isOpaque = false
|
||||||
|
webView.backgroundColor = UIColor.clear
|
||||||
|
|
||||||
// Message Handler hier einmalig hinzufügen
|
// Message Handler hier einmalig hinzufügen
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||||
@ -21,21 +24,39 @@ struct WebView: UIViewRepresentable {
|
|||||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
|
|
||||||
|
let isDarkMode = colorScheme == .dark
|
||||||
|
|
||||||
let styledHTML = """
|
let styledHTML = """
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--background-color: \(isDarkMode ? "#000000" : "#ffffff");
|
||||||
|
--text-color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
|
||||||
|
--heading-color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||||
|
--link-color: \(isDarkMode ? "#0A84FF" : "#007AFF");
|
||||||
|
--quote-color: \(isDarkMode ? "#8E8E93" : "#666666");
|
||||||
|
--quote-border: \(isDarkMode ? "#0A84FF" : "#007AFF");
|
||||||
|
--code-background: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
||||||
|
--code-text: \(isDarkMode ? "#ffffff" : "#000000");
|
||||||
|
--separator-color: \(isDarkMode ? "#38383A" : "#e0e0e0");
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #1a1a1a;
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
color: #000;
|
color: var(--heading-color);
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -43,43 +64,123 @@ struct WebView: UIViewRepresentable {
|
|||||||
h1 { font-size: 24px; }
|
h1 { font-size: 24px; }
|
||||||
h2 { font-size: 20px; }
|
h2 { font-size: 20px; }
|
||||||
h3 { font-size: 18px; }
|
h3 { font-size: 18px; }
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #007AFF;
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 4px solid #007AFF;
|
border-left: 4px solid var(--quote-border);
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #666;
|
color: var(--quote-color);
|
||||||
}
|
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
|
||||||
code {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'SF Mono', Consolas, monospace;
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--code-background);
|
||||||
|
color: var(--code-text);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background-color: #f5f5f5;
|
background-color: var(--code-background);
|
||||||
padding: 12px;
|
color: var(--code-text);
|
||||||
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-family: 'SF Mono', Consolas, monospace;
|
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--separator-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--separator-color);
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--separator-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.5)" : "rgba(0, 0, 0, 0.05)");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode media query als Fallback */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background-color: #000000;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--heading-color: #ffffff;
|
||||||
|
--link-color: #0A84FF;
|
||||||
|
--quote-color: #8E8E93;
|
||||||
|
--quote-border: #0A84FF;
|
||||||
|
--code-background: #1C1C1E;
|
||||||
|
--code-text: #ffffff;
|
||||||
|
--separator-color: #38383A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode media query als Fallback */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--text-color: #1a1a1a;
|
||||||
|
--heading-color: #000000;
|
||||||
|
--link-color: #007AFF;
|
||||||
|
--quote-color: #666666;
|
||||||
|
--quote-border: #007AFF;
|
||||||
|
--code-background: #f5f5f5;
|
||||||
|
--code-text: #000000;
|
||||||
|
--separator-color: #e0e0e0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -95,6 +196,11 @@ struct WebView: UIViewRepresentable {
|
|||||||
setTimeout(updateHeight, 100);
|
setTimeout(updateHeight, 100);
|
||||||
setTimeout(updateHeight, 500);
|
setTimeout(updateHeight, 500);
|
||||||
setTimeout(updateHeight, 1000);
|
setTimeout(updateHeight, 1000);
|
||||||
|
|
||||||
|
// Höhe bei Bild-Ladevorgängen aktualisieren
|
||||||
|
document.querySelectorAll('img').forEach(img => {
|
||||||
|
img.addEventListener('load', updateHeight);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user