Compare commits

...

2 Commits

Author SHA1 Message Date
624816d914 feat: add German localization and improve share extension UX
- Add comprehensive German localization with Localizable.xcstrings
- Integrate R.swift for type-safe resource management
- Improve share extension UI with better styling and optional title input
- Add archive functionality to bookmark detail view
- Update README with current features and planned roadmap
- Remove title validation requirement from share extension
- Optimize share extension auto-dismiss timing
- Clean up code structure and remove unused components
2025-07-04 22:30:01 +02:00
1763dd6fa1 feat: Complete Share Extension implementation with Keychain integration
UI/UX Improvements:
- Replace SLComposeServiceViewController with custom UIViewController
- Add beautiful green-themed UI with Readeck branding and logo
- Implement modern card-based layout with shadows and rounded corners
- Add custom cancel button and proper navigation styling
- Include loading states and comprehensive user feedback

Backend Integration:
- Add KeychainHelper integration for secure token/endpoint storage
- Implement proper API integration with async/await
- Add comprehensive error handling and status messages
- Include DTOs for API communication

Security & Configuration:
- Add keychain access groups to entitlements for both main app and extension
- Update TokenProvider to save tokens to keychain
- Modify LogoutUseCase to clear keychain data
- Update SaveServerSettingsUseCase to persist endpoint in keychain
- Configure proper build settings and file sharing between targets

Extension Lifecycle:
- Implement proper URL extraction from various sources
- Add automatic extension dismissal on success
- Ensure proper extension context handling
2025-07-04 00:00:35 +02:00
19 changed files with 904 additions and 286 deletions

290
Localizable.xcstrings Normal file
View File

@ -0,0 +1,290 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%lld min" : {
},
"%lld Minuten" : {
},
"12 min • Today • example.com" : {
},
"Abbrechen" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Abbrechen"
}
}
}
},
"Abmelden" : {
},
"Add Item" : {
},
"Anmelden & speichern" : {
},
"Archivieren" : {
},
"Artikel automatisch als gelesen markieren" : {
},
"Automatischer Sync" : {
},
"Benutzername" : {
},
"Bookmark archivieren" : {
},
"Bookmark ist archiviert" : {
},
"Bookmark speichern" : {
},
"Cache leeren" : {
},
"Datenmanagement" : {
},
"Debug-Anmeldung" : {
},
"done" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fertig"
}
}
}
},
"Einfügen" : {
},
"Einstellungen" : {
},
"Einstellungen speichern" : {
},
"Einstellungen zurücksetzen" : {
},
"Entfernen" : {
},
"Entwickler: %@" : {
},
"Erfolgreich angemeldet" : {
},
"Erforderlich" : {
},
"Erneut anmelden & speichern" : {
},
"Es wurden noch keine Bookmarks in %@ gefunden." : {
},
"Externe Links in In-App Safari öffnen" : {
},
"Favorit" : {
},
"Fehler" : {
},
"Fertig" : {
},
"Fertig mit Lesen?" : {
},
"font_settings_title" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schrift-Einstellungen"
}
}
}
},
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
},
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
},
"https://example.com" : {
},
"https://readeck.example.com" : {
},
"Ihr Benutzername" : {
},
"Ihr Passwort" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
},
"Item at %@" : {
},
"Keine Bookmarks" : {
},
"Keine Bookmarks gefunden." : {
},
"Keine Ergebnisse" : {
},
"Labels" : {
},
"Lade %@..." : {
},
"Lade Artikel..." : {
},
"Leseeinstellungen" : {
},
"Löschen" : {
},
"Mehr" : {
},
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
},
"Neues Bookmark" : {
},
"OK" : {
},
"Optional: Eigener Titel" : {
},
"Passwort" : {
},
"readeck Bookmark Title" : {
},
"Safari Reader Modus" : {
},
"Schließen" : {
},
"Schrift" : {
},
"Schrift-Einstellungen" : {
},
"Schriftart" : {
},
"Schriftgröße" : {
},
"Select a bookmark" : {
},
"Select an item" : {
},
"Server-Endpunkt" : {
},
"Speichern..." : {
},
"Suchbegriff eingeben..." : {
},
"Suche" : {
},
"Suche..." : {
},
"Sync-Einstellungen" : {
},
"Sync-Intervall" : {
},
"Tags" : {
},
"Theme" : {
},
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
},
"Titel" : {
},
"Über die App" : {
},
"URL" : {
},
"URL gefunden:" : {
},
"Version %@" : {
},
"Vorschau" : {
},
"Website" : {
},
"Wiederherstellen" : {
},
"Wird gespeichert..." : {
},
"z.B. arbeit, wichtig, später" : {
},
"Zwischenablage" : {
}
},
"version" : "1.0"
}

View File

@ -10,32 +10,23 @@ https://codeberg.org/readeck/readeck
## Features
- Browse and manage bookmarks (Unread, Favorites, Archive)
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
- Share Extension for adding URLs from Safari and other apps
- Swipe actions for quick bookmark management
- Native iOS design with Dark Mode support
## Requirements
- iOS 17.0+
- Xcode 15.0+
- Swift 5.9+
- Full iPad Support with Multi-Column Split View
- Font Customization
- Article View with Reading Time and Word Count
- Search functionality
## Configuration
After installing the app:
1. Open the readeck app
2. Go to the **Settings** tab
3. Enter your readeck server URL and credentials
4. The app will automatically sync your bookmarks
2. Enter your readeck server URL and credentials
3. The app will automatically load your bookmarks
## Architecture
- **SwiftUI** for UI
- **Core Data** for local storage
- **MVVM** architecture pattern
- **Repository pattern** for data access
## Share Extension
@ -43,7 +34,17 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
1. Share any webpage in Safari
2. Select "readeck" from the share sheet
3. The bookmark is automatically added to your collection
3. Enter a title if you want and hit save
4. The bookmark is automatically added to your collection
## Planned Features
- [ ] Add support for bookmark filtering and sorting options
- [ ] Add support for tags
- [ ] Offline sync with Core Data
- [ ] Add support for collection management
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
## Contributing
@ -53,17 +54,3 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Planned Features
- [ ] Offline sync with Core Data
- [ ] Add support for tags
- [ ] Add support for bookmark filtering and sorting options
- [ ] Implement search functionality
- [ ] Add support for collection management
- [ ] Add support for multiple readeck servers
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
- [ ] Implement push notifications for new bookmarks
- [ ] Support for iPad multitasking
- [ ] Implement a dark mode toggle in settings
- [ ] Implement a tutorial for first-time users

View File

@ -9,58 +9,228 @@ import UIKit
import Social
import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController {
class ShareViewController: UIViewController {
private var extractedURL: String?
private var extractedTitle: String?
// UI Elements
private var titleTextField: UITextField?
private var urlLabel: UILabel?
private var statusLabel: UILabel?
private var saveButton: UIButton?
private var activityIndicator: UIActivityIndicatorView?
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
extractSharedContent()
// Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.openParentApp()
}
}
override func isContentValid() -> Bool {
return true // Immer true, damit der Button funktioniert
// MARK: - UI Setup
private func setupUI() {
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
// Add cancel button
let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
cancelButton.tintColor = UIColor.white
navigationItem.leftBarButtonItem = cancelButton
// Ensure navigation bar is visible
navigationController?.navigationBar.isTranslucent = false
navigationController?.navigationBar.backgroundColor = UIColor(named: "green") ?? UIColor.systemGreen
navigationController?.navigationBar.tintColor = UIColor.white
navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
// Add logo
let logoImageView = UIImageView(image: UIImage(named: "readeck"))
logoImageView.translatesAutoresizingMaskIntoConstraints = false
logoImageView.contentMode = .scaleAspectFit
logoImageView.alpha = 0.9
view.addSubview(logoImageView)
// Add custom cancel button
let customCancelButton = UIButton(type: .system)
customCancelButton.translatesAutoresizingMaskIntoConstraints = false
customCancelButton.setTitle("Abbrechen", for: .normal)
customCancelButton.setTitleColor(UIColor.white, for: .normal)
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
view.addSubview(customCancelButton)
// URL Container View
let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
urlContainerView.layer.cornerRadius = 12
urlContainerView.layer.shadowColor = UIColor.black.cgColor
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
urlContainerView.layer.shadowRadius = 4
urlContainerView.layer.shadowOpacity = 0.1
view.addSubview(urlContainerView)
// URL Label
urlLabel = UILabel()
urlLabel?.translatesAutoresizingMaskIntoConstraints = false
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
urlLabel?.textColor = UIColor.label
urlLabel?.numberOfLines = 0
urlLabel?.text = "URL wird geladen..."
urlLabel?.textAlignment = .left
urlContainerView.addSubview(urlLabel!)
// Title Container View
let titleContainerView = UIView()
titleContainerView.translatesAutoresizingMaskIntoConstraints = false
titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
titleContainerView.layer.cornerRadius = 12
titleContainerView.layer.shadowColor = UIColor.black.cgColor
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
titleContainerView.layer.shadowRadius = 4
titleContainerView.layer.shadowOpacity = 0.1
view.addSubview(titleContainerView)
// Title TextField
titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Optionales Titel eingeben..."
titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear
titleContainerView.addSubview(titleTextField!)
// Status Label
statusLabel = UILabel()
statusLabel?.translatesAutoresizingMaskIntoConstraints = false
statusLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
statusLabel?.numberOfLines = 0
statusLabel?.textAlignment = .center
statusLabel?.isHidden = true
statusLabel?.layer.cornerRadius = 10
statusLabel?.layer.masksToBounds = true
view.addSubview(statusLabel!)
// Save Button
saveButton = UIButton(type: .system)
saveButton?.translatesAutoresizingMaskIntoConstraints = false
saveButton?.setTitle("Bookmark speichern", for: .normal)
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
saveButton?.layer.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
saveButton?.layer.shadowRadius = 8
saveButton?.layer.shadowOpacity = 0.2
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
view.addSubview(saveButton!)
// Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
activityIndicator?.hidesWhenStopped = true
view.addSubview(activityIndicator!)
setupConstraints()
}
override func didSelectPost() {
openMainApp()
private func setupConstraints() {
guard let urlLabel = urlLabel,
let titleTextField = titleTextField,
let statusLabel = statusLabel,
let saveButton = saveButton,
let activityIndicator = activityIndicator else { return }
// Find container views and logo
let urlContainerView = urlLabel.superview!
let titleContainerView = titleTextField.superview!
let logoImageView = view.subviews.first { $0 is UIImageView }!
let customCancelButton = view.subviews.first { $0 is UIButton && $0 != saveButton }!
NSLayoutConstraint.activate([
// Custom Cancel Button
customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
customCancelButton.heightAnchor.constraint(equalToConstant: 36),
customCancelButton.widthAnchor.constraint(equalToConstant: 100),
// Logo
logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
logoImageView.heightAnchor.constraint(equalToConstant: 40),
logoImageView.widthAnchor.constraint(equalToConstant: 120),
// URL Container
urlContainerView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 24),
urlContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
urlContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// URL Label inside container
urlLabel.topAnchor.constraint(equalTo: urlContainerView.topAnchor, constant: 16),
urlLabel.leadingAnchor.constraint(equalTo: urlContainerView.leadingAnchor, constant: 16),
urlLabel.trailingAnchor.constraint(equalTo: urlContainerView.trailingAnchor, constant: -16),
urlLabel.bottomAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: -16),
// Title Container
titleContainerView.topAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: 20),
titleContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
titleContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
titleContainerView.heightAnchor.constraint(equalToConstant: 60),
// Title TextField inside container
titleTextField.topAnchor.constraint(equalTo: titleContainerView.topAnchor, constant: 16),
titleTextField.leadingAnchor.constraint(equalTo: titleContainerView.leadingAnchor, constant: 16),
titleTextField.trailingAnchor.constraint(equalTo: titleContainerView.trailingAnchor, constant: -16),
titleTextField.bottomAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: -16),
// Status Label
statusLabel.topAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: 20),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// Save Button
saveButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 32),
saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
saveButton.heightAnchor.constraint(equalToConstant: 56),
// Activity Indicator
activityIndicator.centerXAnchor.constraint(equalTo: saveButton.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor)
])
}
// MARK: - Private Methods
// MARK: - Content Extraction
private func extractSharedContent() {
guard let extensionContext = extensionContext else { return }
print("=== DEBUG: Starting content extraction ===")
print("Input items count: \(extensionContext.inputItems.count)")
for (itemIndex, item) in extensionContext.inputItems.enumerated() {
for item in extensionContext.inputItems {
guard let inputItem = item as? NSExtensionItem else { continue }
print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)")
// Versuche alle verfügbaren Type Identifiers
for (attachmentIndex, provider) in (inputItem.attachments ?? []).enumerated() {
print("Attachment \(attachmentIndex) - registered types: \(provider.registeredTypeIdentifiers)")
// Iteriere durch alle registrierten Type Identifiers
for typeIdentifier in provider.registeredTypeIdentifiers {
print("Trying type identifier: \(typeIdentifier)")
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, error in
if let error = error {
print("Error loading \(typeIdentifier): \(error)")
return
}
for attachment in inputItem.attachments ?? [] {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
DispatchQueue.main.async {
self?.processLoadedItem(item, typeIdentifier: typeIdentifier, inputItem: inputItem)
if let url = url as? URL {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
}
}
}
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
DispatchQueue.main.async {
if let text = text as? String, let url = URL(string: text) {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
}
}
}
@ -68,156 +238,119 @@ class ShareViewController: SLComposeServiceViewController {
}
}
private func processLoadedItem(_ item: Any?, typeIdentifier: String, inputItem: NSExtensionItem) {
print("Processing item of type \(typeIdentifier): \(type(of: item))")
// MARK: - Actions
// URL direkt
if let url = item as? URL {
print("Found URL: \(url.absoluteString)")
extractedURL = url.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
@objc private func saveButtonTapped() {
let title = titleTextField?.text ?? ""
// NSURL
if let nsurl = item as? NSURL {
print("Found NSURL: \(nsurl.absoluteString ?? "nil")")
extractedURL = nsurl.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
saveButton?.isEnabled = false
activityIndicator?.startAnimating()
// String (könnte URL sein)
if let text = item as? String {
print("Found String: \(text)")
if URL(string: text) != nil {
extractedURL = text
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// Versuche URL aus Text zu extrahieren
if let extractedURL = extractURLFromText(text) {
self.extractedURL = extractedURL
self.extractedTitle = text != extractedURL ? text : nil
return
}
}
// Dictionary (Property List)
if let dictionary = item as? [String: Any] {
print("Found Dictionary: \(dictionary)")
handlePropertyList(dictionary)
return
}
// NSData - versuche als String zu interpretieren
if let data = item as? Data {
if let text = String(data: data, encoding: .utf8) {
print("Found Data as String: \(text)")
if URL(string: text) != nil {
extractedURL = text
return
}
}
}
print("Could not process item of type: \(type(of: item))")
}
private func extractURLFromText(_ text: String) -> String? {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
if let match = detector?.firstMatch(in: text, options: [], range: range),
let url = match.url {
return url.absoluteString
}
return nil
}
private func handlePropertyList(_ dictionary: [String: Any]) {
// Safari und andere Browser verwenden oft Property Lists
if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any] {
if let url = results["URL"] as? String {
extractedURL = url
extractedTitle = results["title"] as? String
return
}
}
// Direkte URL im Dictionary
if let url = dictionary["URL"] as? String {
extractedURL = url
extractedTitle = dictionary["title"] as? String
return
}
// Andere mögliche Keys
for key in dictionary.keys {
if let value = dictionary[key] as? String, URL(string: value) != nil {
extractedURL = value
return
Task {
await addBookmarkViaAPI(title: title)
await MainActor.run {
self.saveButton?.isEnabled = true
self.activityIndicator?.stopAnimating()
}
}
}
private func openMainApp() {
let url = extractedURL ?? "https://example.com"
let title = extractedTitle ?? ""
print("Opening main app with URL: \(url)")
// Verwende NSUserActivity anstatt URL-Schema
let userActivity = NSUserActivity(activityType: "de.ilyas.readeck")
userActivity.userInfo = [
"url": url,
"title": title
]
userActivity.webpageURL = URL(string: url)
// Extension schließen und Activity übergeben
extensionContext?.completeRequest(returningItems: [userActivity], completionHandler: nil)
}
private func completeRequest() {
@objc private func cancelButtonTapped() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
func openParentApp() {
guard let extensionContext = extensionContext else { return }
let url = extractedURL ?? "https://example.com"
let title = extractedTitle ?? ""
print("Opening parent app with URL: \(url)")
// URL für die Haupt-App erstellen mit Parametern
var urlComponents = URLComponents(string: "readeck://add-bookmark")
urlComponents?.queryItems = [
URLQueryItem(name: "url", value: url)
]
if !title.isEmpty {
urlComponents?.queryItems?.append(URLQueryItem(name: "title", value: title))
}
guard let finalURL = urlComponents?.url else {
print("Failed to create final URL")
// MARK: - API Call
private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else {
showStatus("Keine URL gefunden.", error: true)
return
}
print("Final URL: \(finalURL)")
// Token und Endpoint aus KeychainHelper
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
return
}
var responder: UIResponder? = self
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("Kein Server-Endpunkt gefunden.", error: true)
return
}
while responder != nil {
if let application = responder as? UIApplication {
application.open(finalURL)
break
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
guard let requestData = try? JSONEncoder().encode(requestDto) else {
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
return
}
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
showStatus("Ungültiger Server-Endpunkt.", error: true)
return
}
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = requestData
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Ungültige Server-Antwort.", error: true)
return
}
responder = responder?.next
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
return
}
// Optional: Response parsen
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
showStatus("Gespeichert: \(resp.message)", error: false)
} else {
showStatus("Lesezeichen gespeichert!", error: false)
}
} catch {
showStatus("Netzwerkfehler: \(error.localizedDescription)", error: true)
}
}
private func showStatus(_ message: String, error: Bool) {
DispatchQueue.main.async {
self.statusLabel?.text = message
self.statusLabel?.textColor = error ? UIColor.systemRed : UIColor.systemGreen
self.statusLabel?.backgroundColor = error ? UIColor.systemRed.withAlphaComponent(0.1) : UIColor.systemGreen.withAlphaComponent(0.1)
self.statusLabel?.isHidden = false
if !error {
// Automatically dismiss after success
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
}
// MARK: - DTOs (kopiert)
private 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
}
}
private struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
</array>
</dict>
</plist>

View File

@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FC2E17C3B3007531C3 /* rswift */; };
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -64,6 +68,7 @@
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -77,7 +82,9 @@
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Assets.xcassets,
Data/CoreData/CoreDataManager.swift,
Data/KeychainHelper.swift,
Domain/Model/Bookmark.swift,
readeck.xcdatamodeld,
);
@ -135,6 +142,8 @@
buildActionMask = 2147483647;
files = (
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -158,6 +167,7 @@
5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup;
children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -215,6 +225,7 @@
buildRules = (
);
dependencies = (
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */,
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
@ -223,6 +234,8 @@
name = readeck;
packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5DA241FC2E17C3B3007531C3 /* rswift */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -306,11 +319,14 @@
knownRegions = (
en,
Base,
"fr-CA",
de,
);
mainGroup = 5D45F9BF2DF858680048D5B8;
minimizedProjectReferenceProxies = 1;
packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -330,6 +346,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -337,6 +354,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -403,12 +421,17 @@
target = 5D45F9C72DF858680048D5B8 /* readeck */;
targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */;
};
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN;
@ -437,6 +460,7 @@
5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN;
@ -520,6 +544,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
@ -574,6 +599,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Release;
};
@ -592,7 +618,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -635,7 +660,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -812,6 +836,14 @@
minimumVersion = 1.21.0;
};
};
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.8.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -820,6 +852,21 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = RswiftLibrary;
};
5DA241FC2E17C3B3007531C3 /* rswift */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = rswift;
};
5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = "plugin:RswiftGenerateInternalResources";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5D45F9C02DF858680048D5B8 /* Project object */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "7374154e7686de69a9f88fbafb081b646b02140f8d82770f46fa750840581e0e",
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"pins" : [
{
"identity" : "netfox",
@ -9,6 +9,33 @@
"revision" : "557576032736fd3140422baefb68b8f76c55088f",
"version" : "1.21.0"
}
},
{
"identity" : "r.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mac-cain13/R.swift.git",
"state" : {
"revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1",
"version" : "7.8.0"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
"identity" : "xcodeedit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tomlokhorst/XcodeEdit",
"state" : {
"revision" : "0e550cdee72844b35431afc3a1e176042be6d0f0",
"version" : "2.13.0"
}
}
],
"version" : 3

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4D",
"red" : "0x00"
"blue" : "0x39",
"green" : "0x37",
"red" : "0x2E"
}
},
"idiom" : "universal"

View File

@ -158,28 +158,28 @@ class API: PAPI {
}
func login(endpoint: String, username: String, password: String) async throws -> UserDto {
guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL }
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
let requestData = try JSONEncoder().encode(loginRequest)
guard let url = URL(string: endpoint + "/api/auth") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
throw APIError.serverError(httpResponse.statusCode)
}
let userDto = try JSONDecoder().decode(UserDto.self, from: data)
// Token NICHT automatisch speichern, da Settings noch nicht existieren
return userDto
return try JSONDecoder().decode(UserDto.self, from: data)
}
// Angepasste getBookmarks-Methode mit Header-Auslesen
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = []

View File

@ -0,0 +1,58 @@
import Foundation
import Security
class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck2"
@discardableResult
func saveToken(_ token: String) -> Bool {
saveString(token, forKey: "readeck_token")
}
func loadToken() -> String? {
loadString(forKey: "readeck_token")
}
@discardableResult
func saveEndpoint(_ endpoint: String) -> Bool {
saveString(endpoint, forKey: "readeck_endpoint")
}
func loadEndpoint() -> String? {
loadString(forKey: "readeck_endpoint")
}
// MARK: - Private generic helpers
@discardableResult
private func saveString(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
private func loadString(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return String(data: data, encoding: .utf8)
}
return nil
}
}

View File

@ -89,41 +89,3 @@ class BookmarksRepository: PBookmarksRepository {
return bookmarkDtos.toDomain()
}
}
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
let isArchived: Bool
let thumbnailUrl: 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
}
}
}

View File

@ -11,6 +11,7 @@ class CoreDataTokenProvider: TokenProvider {
private let settingsRepository = SettingsRepository()
private var cachedSettings: Settings?
private var isLoaded = false
private let keychainHelper = KeychainHelper.shared
private func loadSettingsIfNeeded() async {
guard !isLoaded else { return }
@ -40,6 +41,7 @@ class CoreDataTokenProvider: TokenProvider {
do {
try await settingsRepository.saveToken(token)
saveTokenToKeychain(token: token)
if cachedSettings != nil {
cachedSettings!.token = token
}
@ -52,8 +54,19 @@ class CoreDataTokenProvider: TokenProvider {
do {
try await settingsRepository.clearSettings()
cachedSettings = nil
saveTokenToKeychain(token: "")
} catch {
print("Failed to clear settings: \(error)")
}
}
// MARK: - Keychain Support
func saveTokenToKeychain(token: String) {
keychainHelper.saveToken(token)
}
func loadTokenFromKeychain() -> String? {
keychainHelper.loadToken()
}
}

View File

@ -0,0 +1,21 @@
import Foundation
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
}
}
}

View File

@ -0,0 +1,39 @@
import Foundation
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
var isArchived: Bool
let thumbnailUrl: String
let imageUrl: String
}
extension BookmarkDetail {
static let empty = BookmarkDetail(
id: "",
title: "",
url: "",
description: "",
siteName: "",
authors: [],
created: "",
updated: "",
wordCount: 0,
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
}

View File

@ -35,6 +35,9 @@ class LogoutUseCase: LogoutUseCaseProtocol {
try await settingsRepository.saveUsername("")
try await settingsRepository.savePassword("")
KeychainHelper.shared.saveToken("")
KeychainHelper.shared.saveEndpoint("")
// Note: We keep the endpoint for potential re-login
// but clear the authentication data

View File

@ -9,5 +9,8 @@ class SaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws {
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
KeychainHelper.shared.saveToken(token)
KeychainHelper.shared.saveEndpoint(endpoint)
print("token saved", KeychainHelper.shared.loadToken())
}
}

View File

@ -20,6 +20,7 @@ struct BookmarkDetailView: View {
Divider().padding(.horizontal)
contentSection
Spacer(minLength: 40)
archiveSection
}
}
}
@ -190,6 +191,39 @@ struct BookmarkDetailView: View {
}
return dateString
}
private var archiveSection: some View {
VStack(spacing: 12) {
Text("Fertig mit Lesen?")
.font(.headline)
.padding(.top, 24)
if viewModel.bookmarkDetail.isArchived {
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
} else {
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId)
}
}) {
HStack {
Image(systemName: "archivebox")
Text("Bookmark archivieren")
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
}
#Preview {

View File

@ -5,6 +5,7 @@ class BookmarkDetailViewModel {
private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -20,6 +21,7 @@ class BookmarkDetailViewModel {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
}
@MainActor
@ -30,11 +32,6 @@ class BookmarkDetailViewModel {
do {
settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
// Auch das vollständige Bookmark für readProgress laden
// (Falls GetBookmarkUseCase nur BookmarkDetail zurückgibt)
// Du könntest einen separaten UseCase für das vollständige Bookmark erstellen
} catch {
errorMessage = "Fehler beim Laden des Bookmarks"
}
@ -57,31 +54,23 @@ class BookmarkDetailViewModel {
}
private func processArticleContent() {
// HTML in Paragraphen aufteilen
let paragraphs = articleContent
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs
}
}
extension BookmarkDetail {
static let empty = BookmarkDetail(
id: "",
title: "",
url: "",
description: "",
siteName: "",
authors: [],
created: "",
updated: "",
wordCount: 0,
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
@MainActor
func archiveBookmark(id: String) async {
isLoading = true
errorMessage = nil
do {
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
bookmarkDetail.isArchived = true
} catch {
errorMessage = "Fehler beim Archivieren des Bookmarks"
}
isLoading = false
}
}

View File

@ -10,14 +10,14 @@ struct BookmarkCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Vorschaubild - verwende image oder thumbnail
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
} placeholder: {
Image("placeholder")
Image(R.image.placeholder.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
@ -25,14 +25,12 @@ struct BookmarkCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
// Titel
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Meta-Info mit Datum
VStack(alignment: .leading, spacing: 4) {
HStack {

View File

@ -2,9 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
</array>
</dict>
</plist>