Initial commit: nightly iOS app + Supabase backend
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Post Row
|
||||
|
||||
struct PostRowView: View {
|
||||
let post: Post
|
||||
let onResonate: () -> Void
|
||||
var onReport: (() -> Void)? = nil
|
||||
|
||||
@State private var showReport = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Mood accent bar — der einzige echte Farbakzent im Feed
|
||||
Rectangle()
|
||||
.fill(post.mood?.color ?? Color.nightTertiary)
|
||||
.frame(width: 2)
|
||||
.padding(.vertical, 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 11) {
|
||||
// Author
|
||||
HStack(spacing: 9) {
|
||||
if post.isAnonymous {
|
||||
AnonymousAvatar(size: 32)
|
||||
} else {
|
||||
AvatarView(user: post.author, size: 32)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
if post.isAnonymous {
|
||||
Text("anonym")
|
||||
.font(.nightLabel(13))
|
||||
.foregroundColor(.nightSecondary)
|
||||
.italic()
|
||||
} else {
|
||||
Text(post.author.displayName)
|
||||
.font(.nightLabel(14, weight: .semibold))
|
||||
.foregroundColor(.nightPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let mood = post.mood {
|
||||
Text(mood.emoji)
|
||||
.font(.nightMono(11))
|
||||
.foregroundColor(mood.color.opacity(0.8))
|
||||
}
|
||||
Text(post.formattedTime)
|
||||
.font(.nightMono(11))
|
||||
.foregroundColor(.nightTertiary)
|
||||
|
||||
// Drei-Punkte-Menü für Report
|
||||
Menu {
|
||||
Button(role: .destructive) {
|
||||
showReport = true
|
||||
} label: {
|
||||
Label("Melden", systemImage: "flag")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.nightTertiary)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
Text(post.content)
|
||||
.font(.nightBody(16))
|
||||
.foregroundColor(.nightPrimary.opacity(0.9))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Resonance
|
||||
ResonanceButton(
|
||||
count: post.resonanceCount,
|
||||
isActive: post.hasResonated,
|
||||
action: onResonate
|
||||
)
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.sheet(isPresented: $showReport) {
|
||||
ReportSheet(postId: post.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resonance Button
|
||||
|
||||
struct ResonanceButton: View {
|
||||
let count: Int
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation(.spring(duration: 0.2)) { scale = 1.0 }
|
||||
}
|
||||
action()
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: isActive ? "heart.fill" : "heart")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(isActive ? .nightRed : .nightSecondary)
|
||||
.scaleEffect(scale)
|
||||
|
||||
Text(count > 0 ? "\(count)" : "hat mich getroffen")
|
||||
.font(.nightLabel(13))
|
||||
.foregroundColor(isActive ? .nightRed : .nightSecondary)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, count > 0 || isActive ? 10 : 0)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear)
|
||||
)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: isActive)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
struct AvatarView: View {
|
||||
let user: User
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url = user.avatarURL {
|
||||
AsyncImage(url: url) { img in img.resizable().scaledToFill() }
|
||||
placeholder: { initials }
|
||||
} else { initials }
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
var initials: some View {
|
||||
ZStack {
|
||||
Circle().fill(Color.nightPurple.opacity(0.18))
|
||||
Text(String(user.displayName.prefix(1)).uppercased())
|
||||
.font(.system(size: size * 0.38, weight: .semibold))
|
||||
.foregroundColor(.nightPurpleSoft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnonymousAvatar: View {
|
||||
let size: CGFloat
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().fill(Color.nightRaised)
|
||||
Image(systemName: "questionmark")
|
||||
.font(.system(size: size * 0.35, weight: .semibold))
|
||||
.foregroundColor(.nightSecondary)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Report Sheet
|
||||
|
||||
struct ReportSheet: View {
|
||||
let postId: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var selected: ReportReason? = nil
|
||||
@State private var submitted = false
|
||||
@State private var isLoading = false
|
||||
|
||||
enum ReportReason: String, CaseIterable {
|
||||
case hate = "Hassrede / Diskriminierung"
|
||||
case harassment = "Belästigung / Mobbing"
|
||||
case selfharm = "Selbstverletzung / Suizid"
|
||||
case illegal = "Illegale Inhalte"
|
||||
case spam = "Spam"
|
||||
case other = "Sonstiges"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.nightSurface.ignoresSafeArea()
|
||||
|
||||
if submitted {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.nightGreen)
|
||||
Text("Danke für deine Meldung")
|
||||
.font(.nightTitle(18))
|
||||
.foregroundColor(.nightPrimary)
|
||||
Text("Wir prüfen den Inhalt so schnell wie möglich.")
|
||||
.font(.nightBody(14))
|
||||
.foregroundColor(.nightSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Schließen") { dismiss() }
|
||||
.foregroundColor(.nightPurpleSoft)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(40)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Warum möchtest du das melden?")
|
||||
.font(.nightTitle(17))
|
||||
.foregroundColor(.nightPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
ForEach(ReportReason.allCases, id: \.self) { reason in
|
||||
Button {
|
||||
selected = reason
|
||||
} label: {
|
||||
HStack {
|
||||
Text(reason.rawValue)
|
||||
.font(.nightBody(15))
|
||||
.foregroundColor(.nightPrimary)
|
||||
Spacer()
|
||||
if selected == reason {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.nightPurpleSoft)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(selected == reason ? Color.nightPurple.opacity(0.08) : Color.clear)
|
||||
}
|
||||
Divider().background(Color.nightBorder)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
guard let reason = selected else { return }
|
||||
Task { await submit(reason: reason) }
|
||||
} label: {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView().tint(.black)
|
||||
} else {
|
||||
Text("Melden")
|
||||
.font(.nightLabel(16, weight: .semibold))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(selected != nil ? Color.nightPrimary : Color.nightRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(selected == nil || isLoading)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
.foregroundColor(.nightSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
func submit(reason: ReportReason) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil)
|
||||
submitted = true
|
||||
} catch {
|
||||
// Fehler still ignorieren — Meldung trotzdem als abgeschlossen zeigen
|
||||
submitted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user