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,287 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen.
|
||||
/// Bleibt für immer. Das ist der Retention-Hook.
|
||||
struct DiaryView: View {
|
||||
@StateObject private var viewModel = DiaryViewModel()
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.nightBase.ignoresSafeArea()
|
||||
|
||||
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
|
||||
DiaryEmptyView()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
// "Vor genau einem Jahr" — Memory
|
||||
if let memory = viewModel.yearAgoPost {
|
||||
MemoryBanner(post: memory)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
ForEach(viewModel.groupedPosts, id: \.nightLabel) { group in
|
||||
// Nacht-Header
|
||||
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
|
||||
|
||||
ForEach(group.posts) { post in
|
||||
DiaryPostRow(post: post) {
|
||||
Task { await viewModel.deletePost(post) }
|
||||
}
|
||||
Divider().background(Color.nightBorder).padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
}
|
||||
.refreshable { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "book.closed.fill")
|
||||
.foregroundColor(.nightPurple)
|
||||
Text("tagebuch")
|
||||
.font(.nightTitle(17))
|
||||
.foregroundColor(.nightPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory Banner
|
||||
|
||||
struct MemoryBanner: View {
|
||||
let post: Post
|
||||
@State private var show = true
|
||||
|
||||
var body: some View {
|
||||
if show {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundColor(.nightPurpleSoft)
|
||||
.font(.system(size: 14))
|
||||
Text("vor genau einem jahr")
|
||||
.font(.nightLabel(12, weight: .semibold))
|
||||
.foregroundColor(.nightPurpleSoft)
|
||||
.kerning(0.5)
|
||||
Spacer()
|
||||
Button { withAnimation { show = false } } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.nightTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(post.content)
|
||||
.font(.nightBody(15))
|
||||
.foregroundColor(.nightPrimary.opacity(0.85))
|
||||
.lineSpacing(4)
|
||||
|
||||
HStack(spacing: 5) {
|
||||
if let mood = post.mood {
|
||||
Text(mood.emoji).font(.nightMono(11))
|
||||
Text(mood.label).font(.nightLabel(11))
|
||||
}
|
||||
Text("·")
|
||||
Text(post.formattedTime)
|
||||
.font(.nightMono(11))
|
||||
}
|
||||
.foregroundColor(.nightTertiary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(Color.nightPurple.opacity(0.06))
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.nightPurple.opacity(0.08), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
.overlay(
|
||||
Rectangle().fill(Color.nightBorder).frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Night Group Header
|
||||
|
||||
struct DiaryNightHeader: View {
|
||||
let label: String
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.nightLabel(12, weight: .semibold))
|
||||
.foregroundColor(.nightSecondary)
|
||||
.kerning(0.5)
|
||||
Text("· \(count) Gedanke\(count == 1 ? "" : "n")")
|
||||
.font(.nightLabel(12))
|
||||
.foregroundColor(.nightTertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.nightSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diary Post Row
|
||||
|
||||
struct DiaryPostRow: View {
|
||||
let post: Post
|
||||
let onDelete: () -> Void
|
||||
@State private var confirmDelete = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(post.mood?.color ?? Color.nightTertiary)
|
||||
.frame(width: 2)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(post.formattedTime)
|
||||
.font(.nightMono(12))
|
||||
.foregroundColor(.nightTertiary)
|
||||
|
||||
if post.isAnonymous {
|
||||
Text("· anonym")
|
||||
.font(.nightLabel(11))
|
||||
.foregroundColor(.nightTertiary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
if let mood = post.mood {
|
||||
Text("· \(mood.emoji) \(mood.label)")
|
||||
.font(.nightLabel(11))
|
||||
.foregroundColor(mood.color.opacity(0.7))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if post.resonanceCount > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 10))
|
||||
Text("\(post.resonanceCount)")
|
||||
.font(.nightLabel(11))
|
||||
}
|
||||
.foregroundColor(.nightRed.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
Text(post.content)
|
||||
.font(.nightBody(16))
|
||||
.foregroundColor(.nightPrimary.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
confirmDelete = true
|
||||
} label: {
|
||||
Label("Löschen", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Post löschen?",
|
||||
isPresented: $confirmDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Löschen", role: .destructive) { onDelete() }
|
||||
} message: {
|
||||
Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
struct DiaryEmptyView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.nightTertiary)
|
||||
Text("noch nichts hier")
|
||||
.font(.nightTitle(18))
|
||||
.foregroundColor(.nightPrimary)
|
||||
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.")
|
||||
.font(.nightBody(15))
|
||||
.foregroundColor(.nightSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class DiaryViewModel: ObservableObject {
|
||||
struct NightGroup { let nightLabel: String; let posts: [Post] }
|
||||
|
||||
@Published var groupedPosts: [NightGroup] = []
|
||||
@Published var yearAgoPost: Post? = nil
|
||||
@Published var isLoading = false
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let posts = try await supabase.getDiary()
|
||||
|
||||
// Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört)
|
||||
let grouped = Dictionary(grouping: posts) { post -> String in
|
||||
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "EEEE, d. MMMM yyyy"
|
||||
return f.string(from: nightDate)
|
||||
}
|
||||
|
||||
groupedPosts = grouped
|
||||
.sorted { $0.key > $1.key }
|
||||
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
|
||||
|
||||
// Memory: Post von vor genau einem Jahr
|
||||
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
|
||||
yearAgoPost = posts.first { post in
|
||||
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func deletePost(_ post: Post) async {
|
||||
try? await supabase.softDeletePost(id: post.id)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user