v0.5.0 — Phase β-4 Media + Advanced Card-Types
Alle 7 Card-Types werden gerendert und können erstellt werden. image-occlusion mit Touch-Drag-Mask-Editor (kein PencilKit — Server- Schema erlaubt nur Rechtecke), audio-front mit AVAudioPlayer und File-Picker. - MediaUploadResponse-DTO, MaskRegion-Codable mit 0..1-Coordinates - MaskRegions.parse/encode (1:1-Port aus cards-domain, Sortierung nach ID lexikographisch) - CardFieldsBuilder.imageOcclusion mit stringified-JSON-mask_regions + audioFront - CardsAPI.uploadMedia (Multipart, 25 MiB) + fetchMedia (streamed) - MediaCache actor mit LRU 200 MB (contentModificationDate-Eviction) - mediaCache Environment-Key - RemoteImage + AudioPlayerButton SwiftUI-Views - CardRenderer: imageOcclusion (Mask-Overlay über RemoteImage) + audioFront (AudioPlayerButton + back-Text auf Flip) - MaskEditorView: Touch-Drag-Rechteck, Label-Edit, Delete - CardEditorView erweitert: PhotosPicker für Image, fileImporter für Audio, Magic-Byte-MIME-Detection - 6 neue Tests für MaskRegions (30 Total grün) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf1160b270
commit
80eb3708b4
12 changed files with 923 additions and 44 deletions
|
|
@ -54,6 +54,44 @@ actor CardsAPI {
|
|||
return try decoder.decode(DueReviewsResponse.self, from: data).total
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
|
||||
/// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB.
|
||||
/// Erlaubte MIMEs: image/*, audio/*, video/*.
|
||||
func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse {
|
||||
let boundary = "cards-native-\(UUID().uuidString)"
|
||||
let body = makeMultipartBody(
|
||||
file: data,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
boundary: boundary
|
||||
)
|
||||
let (response, http) = try await transport.request(
|
||||
path: "/api/v1/media/upload",
|
||||
method: "POST",
|
||||
body: body,
|
||||
contentType: "multipart/form-data; boundary=\(boundary)"
|
||||
)
|
||||
try ensureOK(http, data: response)
|
||||
return try decoder.decode(MediaUploadResponse.self, from: response)
|
||||
}
|
||||
|
||||
/// `GET /api/v1/media/:id` — streamt das Media-File. Antwortet mit
|
||||
/// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache.
|
||||
func fetchMedia(id: String) async throws -> Data {
|
||||
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
|
||||
guard (200 ..< 300).contains(http.statusCode) else {
|
||||
throw AuthError.serverError(status: http.statusCode, message: "media fetch failed")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht
|
||||
/// implementiert serverseitig; Stub bleibt für späteren Use.)
|
||||
func deleteMedia(id _: String) async throws {
|
||||
throw AuthError.serverError(status: 501, message: "media delete not implemented on server")
|
||||
}
|
||||
|
||||
// MARK: - Deck-Mutations
|
||||
|
||||
/// `POST /api/v1/decks` — Deck anlegen.
|
||||
|
|
@ -172,6 +210,28 @@ actor CardsAPI {
|
|||
return try encoder.encode(value)
|
||||
}
|
||||
|
||||
// MARK: - Multipart
|
||||
|
||||
private func makeMultipartBody(
|
||||
file: Data,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
boundary: String
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
let lineBreak = "\r\n"
|
||||
let header = """
|
||||
--\(boundary)\(lineBreak)\
|
||||
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
|
||||
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
|
||||
"""
|
||||
body.append(header.data(using: .utf8) ?? Data())
|
||||
body.append(file)
|
||||
body.append(lineBreak.data(using: .utf8) ?? Data())
|
||||
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
|
||||
return body
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue