feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s

- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug];
  returns avatarUrls map alongside comments and myVotes
- CommentsSection.svelte: show avatar image or initials fallback (24px top-level,
  20px replies) next to each comment/reply username
- iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and
  populates it on load; CommentRow renders AsyncImage with initials fallback
- Also includes: comment replies (1-level nesting), delete, sort (Top/New),
  parent_id schema migration, and AvatarCropModal cropperjs fix
This commit is contained in:
Admin
2026-03-10 20:05:31 +05:00
parent 718bfa6691
commit 09cdda2a07
14 changed files with 1028 additions and 206 deletions

View File

@@ -19,7 +19,9 @@
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
@@ -53,6 +55,7 @@
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
@@ -70,6 +73,7 @@
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
@@ -127,6 +131,7 @@
isa = PBXGroup;
children = (
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
);
path = Profile;
sourceTree = "<group>";
@@ -265,6 +270,7 @@
isa = PBXGroup;
children = (
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
);
path = BookDetail;
sourceTree = "<group>";
@@ -391,6 +397,7 @@
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
@@ -408,6 +415,7 @@
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,

View File

@@ -273,10 +273,13 @@ struct BookComment: Identifiable, Codable, Hashable {
var upvotes: Int
var downvotes: Int
let created: String
let parentId: String // empty = top-level; non-empty = reply
var replies: [BookComment]? // populated client-side from the API response
enum CodingKeys: String, CodingKey {
case id, slug, username, body, upvotes, downvotes, created
case id, slug, username, body, upvotes, downvotes, created, replies
case userId = "user_id"
case parentId = "parent_id"
}
init(from decoder: Decoder) throws {
@@ -289,16 +292,27 @@ struct BookComment: Identifiable, Codable, Hashable {
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
}
}
struct CommentsResponse: Decodable {
let comments: [BookComment]
let myVotes: [String: String]
let avatarUrls: [String: String]
enum CodingKeys: String, CodingKey {
case comments
case myVotes = "myVotes"
case avatarUrls = "avatarUrls"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
comments = try c.decode([BookComment].self, forKey: .comments)
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
}
}

View File

@@ -324,14 +324,17 @@ actor APIClient {
// MARK: - Comments
func fetchComments(slug: String) async throws -> CommentsResponse {
try await fetch("/api/comments/\(slug)")
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
try await fetch("/api/comments/\(slug)?sort=\(sort)")
}
struct PostCommentBody: Encodable { let body: String }
struct PostCommentBody: Encodable {
let body: String
let parent_id: String?
}
func postComment(slug: String, body: String) async throws -> BookComment {
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
}
struct VoteBody: Encodable { let vote: String }
@@ -341,6 +344,12 @@ actor APIClient {
func voteComment(commentId: String, vote: String) async throws -> BookComment {
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
}
/// Delete a comment (and its replies) by ID. Only the owner can delete.
func deleteComment(commentId: String) async throws {
struct Empty: Decodable {}
let _: Empty = try await fetch("/api/comments/\(commentId)", method: "DELETE")
}
}
// MARK: - Response types

View File

@@ -8,6 +8,7 @@ class CommentsViewModel: ObservableObject {
@Published var comments: [BookComment] = []
@Published var myVotes: [String: String] = [:] // commentId "up" | "down"
@Published var avatarUrls: [String: String] = [:] // userId presigned URL
@Published var isLoading = true
@Published var error: String?
@@ -15,7 +16,16 @@ class CommentsViewModel: ObservableObject {
@Published var isPosting = false
@Published var postError: String?
@Published var sort: CommentSortOrder = .top
// Reply state
@Published var replyingToId: String? = nil
@Published var replyBody = ""
@Published var isPostingReply = false
@Published var replyError: String?
private var votingIds: Set<String> = []
private var deletingIds: Set<String> = []
init(slug: String) {
self.slug = slug
@@ -25,9 +35,10 @@ class CommentsViewModel: ObservableObject {
isLoading = true
error = nil
do {
let response = try await APIClient.shared.fetchComments(slug: slug)
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
comments = response.comments
myVotes = response.myVotes
avatarUrls = response.avatarUrls
} catch {
self.error = error.localizedDescription
}
@@ -44,7 +55,8 @@ class CommentsViewModel: ObservableObject {
isPosting = true
postError = nil
do {
let created = try await APIClient.shared.postComment(slug: slug, body: text)
var created = try await APIClient.shared.postComment(slug: slug, body: text)
created.replies = []
comments.insert(created, at: 0)
newBody = ""
} catch let apiError as APIError {
@@ -58,17 +70,78 @@ class CommentsViewModel: ObservableObject {
isPosting = false
}
func vote(commentId: String, vote: String) async {
func postReply(parentId: String) async {
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isPostingReply else { return }
if text.count > 2000 {
replyError = "Reply too long (max 2000 characters)."
return
}
isPostingReply = true
replyError = nil
do {
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
var replies = parent.replies ?? []
replies.append(created)
parent.replies = replies
comments[idx] = parent
}
replyBody = ""
replyingToId = nil
} catch let apiError as APIError {
switch apiError {
case .httpError(401, _): replyError = "You must be logged in to reply."
default: replyError = apiError.localizedDescription
}
} catch {
replyError = error.localizedDescription
}
isPostingReply = false
}
func deleteComment(commentId: String, parentId: String? = nil) async {
guard !deletingIds.contains(commentId) else { return }
deletingIds.insert(commentId)
defer { deletingIds.remove(commentId) }
do {
try await APIClient.shared.deleteComment(commentId: commentId)
if let parentId {
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
comments[idx] = parent
}
} else {
comments.removeAll { $0.id == commentId }
}
} catch {
// Silently ignore comment may have already been deleted
}
}
func vote(commentId: String, vote: String, parentId: String? = nil) async {
guard !votingIds.contains(commentId) else { return }
votingIds.insert(commentId)
defer { votingIds.remove(commentId) }
do {
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
// Update the comment in the list
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
comments[idx] = updated
if let parentId {
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
parent.replies![rIdx] = updated
}
comments[idx] = parent
}
} else {
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
var c = updated
c.replies = comments[idx].replies
comments[idx] = c
}
}
// Toggle myVotes
let prev = myVotes[commentId]
if prev == vote {
myVotes.removeValue(forKey: commentId)
@@ -76,12 +149,29 @@ class CommentsViewModel: ObservableObject {
myVotes[commentId] = vote
}
} catch {
// Silently ignore vote errors don't disrupt the UI
// Silently ignore vote errors
}
}
func isVoting(_ commentId: String) -> Bool {
votingIds.contains(commentId)
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
func setSort(_ newSort: CommentSortOrder) {
guard newSort != sort else { return }
sort = newSort
Task { await load() }
}
}
enum CommentSortOrder: String, CaseIterable {
case top = "top"
case new = "new"
var label: String {
switch self {
case .top: return "Top"
case .new: return "New"
}
}
}
@@ -97,16 +187,30 @@ struct CommentsView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Section header
// Section header + sort picker
HStack {
Text("Comments")
.font(.headline)
if !vm.isLoading && !vm.comments.isEmpty {
Text("(\(vm.comments.count))")
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
if !vm.isLoading && total > 0 {
Text("(\(total))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
// Sort picker
if !vm.isLoading && !vm.comments.isEmpty {
Picker("Sort", selection: Binding(
get: { vm.sort },
set: { vm.setSort($0) }
)) {
ForEach(CommentSortOrder.allCases, id: \.self) { s in
Text(s.label).tag(s)
}
}
.pickerStyle(.segmented)
.frame(width: 120)
}
}
.padding(.horizontal)
.padding(.vertical, 14)
@@ -135,13 +239,7 @@ struct CommentsView: View {
.padding()
} else {
ForEach(vm.comments) { comment in
CommentRow(
comment: comment,
myVote: vm.myVotes[comment.id],
isVoting: vm.isVoting(comment.id)
) { vote in
Task { await vm.vote(commentId: comment.id, vote: vote) }
}
commentThread(comment: comment)
Divider().padding(.leading, 16)
}
}
@@ -151,61 +249,198 @@ struct CommentsView: View {
.task { await vm.load() }
}
// MARK: - Post form
// MARK: - Comment thread (top-level + replies)
@ViewBuilder
private var postForm: some View {
VStack(alignment: .leading, spacing: 8) {
private func commentThread(comment: BookComment) -> some View {
VStack(alignment: .leading, spacing: 0) {
CommentRow(
comment: comment,
myVote: vm.myVotes[comment.id],
isVoting: vm.isVoting(comment.id),
isDeleting: vm.isDeleting(comment.id),
isOwner: authStore.user?.id == comment.userId,
isLoggedIn: authStore.isAuthenticated,
isReplyingTo: vm.replyingToId == comment.id,
avatarUrl: vm.avatarUrls[comment.userId],
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
onReply: {
if vm.replyingToId == comment.id {
vm.replyingToId = nil
vm.replyBody = ""
vm.replyError = nil
} else {
vm.replyingToId = comment.id
vm.replyBody = ""
vm.replyError = nil
}
}
)
// Inline reply form
if vm.replyingToId == comment.id {
replyForm(parentId: comment.id)
.padding(.leading, 32)
.padding(.trailing, 16)
.padding(.bottom, 8)
}
// Replies
if let replies = comment.replies, !replies.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(replies) { reply in
CommentRow(
comment: reply,
myVote: vm.myVotes[reply.id],
isVoting: vm.isVoting(reply.id),
isDeleting: vm.isDeleting(reply.id),
isOwner: authStore.user?.id == reply.userId,
isLoggedIn: authStore.isAuthenticated,
isReplyingTo: false,
isReply: true,
avatarUrl: vm.avatarUrls[reply.userId],
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
onReply: nil
)
if reply.id != replies.last?.id {
Divider().padding(.leading, 48)
}
}
}
.padding(.leading, 24)
.overlay(alignment: .leading) {
Rectangle()
.fill(Color(.systemGray4))
.frame(width: 2)
.padding(.leading, 16)
.padding(.vertical, 4)
}
}
}
}
// MARK: - Reply form
@ViewBuilder
private func replyForm(parentId: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topLeading) {
if vm.newBody.isEmpty {
Text("Write a comment")
.font(.subheadline)
if vm.replyBody.isEmpty {
Text("Write a reply")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.top, 6)
.padding(.leading, 4)
}
TextEditor(text: $vm.newBody)
.font(.subheadline)
.frame(minHeight: 72, maxHeight: 160)
TextEditor(text: $vm.replyBody)
.font(.caption)
.frame(minHeight: 56, maxHeight: 120)
.scrollContentBackground(.hidden)
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(8)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
HStack {
let count = vm.newBody.count
let count = vm.replyBody.count
Text("\(count)/2000")
.font(.caption2)
.monospacedDigit()
.foregroundStyle(count > 2000 ? .red : .tertiary)
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
Spacer()
if let err = vm.postError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
if let err = vm.replyError {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
Button("Cancel") {
vm.replyingToId = nil
vm.replyBody = ""
vm.replyError = nil
}
.font(.caption)
.foregroundStyle(.secondary)
Button {
Task { await vm.postComment() }
Task { await vm.postReply(parentId: parentId) }
} label: {
if vm.isPosting {
ProgressView().controlSize(.small)
if vm.isPostingReply {
ProgressView().controlSize(.mini)
} else {
Text("Post")
.fontWeight(.semibold)
Text("Reply").fontWeight(.semibold).font(.caption)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.controlSize(.small)
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
.controlSize(.mini)
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
}
}
}
// MARK: - Post form
@ViewBuilder
private var postForm: some View {
if authStore.isAuthenticated {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .topLeading) {
if vm.newBody.isEmpty {
Text("Write a comment…")
.font(.subheadline)
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 4)
}
TextEditor(text: $vm.newBody)
.font(.subheadline)
.frame(minHeight: 72, maxHeight: 160)
.scrollContentBackground(.hidden)
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
HStack {
let count = vm.newBody.count
Text("\(count)/2000")
.font(.caption2)
.monospacedDigit()
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
Spacer()
if let err = vm.postError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
Button {
Task { await vm.postComment() }
} label: {
if vm.isPosting {
ProgressView().controlSize(.small)
} else {
Text("Post")
.fontWeight(.semibold)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.controlSize(.small)
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
}
}
} else {
Text("Log in to leave a comment.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
// MARK: - Loading skeleton
@ViewBuilder
@@ -238,14 +473,23 @@ private struct CommentRow: View {
let comment: BookComment
let myVote: String?
let isVoting: Bool
let isDeleting: Bool
let isOwner: Bool
let isLoggedIn: Bool
let isReplyingTo: Bool
var isReply: Bool = false
var avatarUrl: String? = nil
let onVote: (String) -> Void
let onDelete: () -> Void
let onReply: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Username + date
HStack(spacing: 6) {
// Avatar + Username + date
HStack(spacing: 8) {
avatarView
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(.subheadline.weight(.medium))
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
Text("·")
.foregroundStyle(.tertiary)
Text(formattedDate(comment.created))
@@ -256,16 +500,14 @@ private struct CommentRow: View {
// Body
Text(comment.body)
.font(.subheadline)
.font(isReply ? .caption : .subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
// Vote row
HStack(spacing: 16) {
// Actions
HStack(spacing: 14) {
// Upvote
Button {
onVote("up")
} label: {
Button { onVote("up") } label: {
HStack(spacing: 4) {
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
.font(.caption)
@@ -277,9 +519,7 @@ private struct CommentRow: View {
.disabled(isVoting)
// Downvote
Button {
onVote("down")
} label: {
Button { onVote("down") } label: {
HStack(spacing: 4) {
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
.font(.caption)
@@ -290,12 +530,68 @@ private struct CommentRow: View {
}
.disabled(isVoting)
// Reply button (top-level only, logged in)
if let onReply, isLoggedIn {
Button { onReply() } label: {
HStack(spacing: 3) {
Image(systemName: "arrowshape.turn.up.left")
.font(.caption)
Text("Reply")
.font(.caption)
}
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
}
}
Spacer()
// Delete (owner only)
if isOwner {
Button(role: .destructive) { onDelete() } label: {
Image(systemName: "trash")
.font(.caption)
}
.disabled(isDeleting)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(isVoting ? 0.6 : 1)
.opacity(isDeleting ? 0.5 : 1)
.animation(.easeInOut(duration: 0.15), value: isDeleting)
}
private var avatarSize: CGFloat { isReply ? 20 : 24 }
@ViewBuilder
private var avatarView: some View {
if let url = avatarUrl, let imageUrl = URL(string: url) {
AsyncImage(url: imageUrl) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
initialsView
}
}
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
initialsView
}
}
private var initialsView: some View {
let name = comment.username.isEmpty ? "?" : comment.username
let letters = String(name.prefix(2)).uppercased()
return ZStack {
Circle()
.fill(Color(.systemGray4))
.frame(width: avatarSize, height: avatarSize)
Text(letters)
.font(.system(size: avatarSize * 0.42, weight: .semibold))
.foregroundStyle(.secondary)
}
}
private func formattedDate(_ iso: String) -> String {

View File

@@ -20,64 +20,7 @@ struct ProfileView: View {
// User header
Section {
HStack(spacing: 16) {
// Tappable avatar circle
PhotosPicker(selection: $photoPickerItem,
matching: .images,
photoLibrary: .shared()) {
ZStack {
Circle()
.fill(Color(.systemGray5))
.frame(width: 72, height: 72)
if avatarUploading {
ProgressView()
.frame(width: 72, height: 72)
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
let url = URL(string: urlStr) {
KFImage(url)
.placeholder {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
}
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
.frame(width: 72, height: 72)
}
// Camera overlay badge
if !avatarUploading {
VStack {
Spacer()
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 22, height: 22)
Image(systemName: "camera.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.black)
}
.offset(x: 2, y: 2)
}
}
.frame(width: 72, height: 72)
}
}
}
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await loadImageForCrop(item) }
}
avatarPicker
VStack(alignment: .leading, spacing: 3) {
Text(authStore.user?.username ?? "")
.font(.headline)
@@ -181,6 +124,68 @@ struct ProfileView: View {
}
}
// MARK: - Avatar picker
@ViewBuilder
private var avatarPicker: some View {
PhotosPicker(selection: $photoPickerItem,
matching: .images,
photoLibrary: .shared()) {
ZStack {
Circle()
.fill(Color(.systemGray5))
.frame(width: 72, height: 72)
if avatarUploading {
ProgressView()
.frame(width: 72, height: 72)
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
let url = URL(string: urlStr) {
KFImage(url)
.placeholder {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
}
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
.frame(width: 72, height: 72)
}
// Camera overlay badge
if !avatarUploading {
VStack {
Spacer()
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 22, height: 22)
Image(systemName: "camera.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.black)
}
.offset(x: 2, y: 2)
}
}
.frame(width: 72, height: 72)
}
}
}
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await loadImageForCrop(item) }
}
}
// MARK: - Voice picker
@ViewBuilder