| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562 |
- import AppKit
- import SwiftUI
- import UniformTypeIdentifiers
- // MARK: - Page Boundary
- struct PostPageBoundary<Content: View>: View {
- @ViewBuilder let content: Content
- var body: some View {
- content
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
- .fill(AppTheme.panelBackground)
- )
- .overlay(
- RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- }
- }
- // MARK: - Shared Form Components
- struct PostFormCard<Content: View>: View {
- let title: String
- var subtitle: String?
- @ViewBuilder let content: Content
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(AppTheme.textPrimary)
- if let subtitle {
- Text(subtitle)
- .font(.system(size: 10))
- .foregroundStyle(AppTheme.textSecondary)
- }
- }
- content
- }
- .padding(14)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
- .fill(AppTheme.cardBackground)
- .overlay(
- RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- )
- }
- }
- struct PostFormField: View {
- let label: String
- var placeholder: String = ""
- @Binding var text: String
- var prefix: String?
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text(label)
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(AppTheme.textTertiary)
- HStack(spacing: 0) {
- if let prefix {
- Text(prefix)
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(AppTheme.textSecondary)
- .padding(.leading, 10)
- }
- TextField(placeholder, text: $text)
- .textFieldStyle(.plain)
- .font(.system(size: 12))
- .foregroundStyle(AppTheme.textPrimary)
- .padding(.horizontal, prefix == nil ? 10 : 4)
- .padding(.vertical, 8)
- }
- .background(AppTheme.panelBackground)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- }
- }
- }
- struct PostFormTextEditor: View {
- let label: String
- var placeholder: String = ""
- @Binding var text: String
- var minHeight: CGFloat = 120
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text(label)
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(AppTheme.textTertiary)
- ThinCaretTextEditor(
- text: $text,
- placeholder: placeholder
- )
- .frame(height: minHeight)
- .background(AppTheme.panelBackground)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- }
- }
- }
- struct PostTypePicker: View {
- @Binding var selectedType: RedditPostType
- var body: some View {
- LazyVGrid(
- columns: [GridItem(.flexible()), GridItem(.flexible())],
- spacing: 8
- ) {
- ForEach(RedditPostType.allCases) { type in
- PostTypeButton(type: type, isSelected: selectedType == type) {
- selectedType = type
- }
- }
- }
- }
- }
- private struct PostTypeButton: View {
- let type: RedditPostType
- let isSelected: Bool
- let action: () -> Void
- var body: some View {
- Button(action: action) {
- HStack(spacing: 8) {
- Image(systemName: type.systemImage)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(isSelected ? AppTheme.accentPurpleLight : AppTheme.textSecondary)
- .frame(width: 16)
- VStack(alignment: .leading, spacing: 1) {
- Text(type.title)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(AppTheme.textPrimary)
- Text(type.subtitle)
- .font(.system(size: 9))
- .foregroundStyle(AppTheme.textTertiary)
- .lineLimit(1)
- }
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: 8)
- .fill(isSelected ? AppTheme.accentPurple.opacity(0.15) : AppTheme.panelBackground)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(
- isSelected ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder,
- lineWidth: 1
- )
- )
- }
- .buttonStyle(AppPlainButtonStyle())
- .hoverOverlay(cornerRadius: 8)
- }
- }
- struct PostTagToggle: View {
- let title: String
- let systemImage: String
- let activeColor: Color
- @Binding var isOn: Bool
- var body: some View {
- Button {
- isOn.toggle()
- } label: {
- HStack(spacing: 5) {
- Image(systemName: systemImage)
- .font(.system(size: 10, weight: .semibold))
- Text(title)
- .font(.system(size: 10, weight: .semibold))
- }
- .frame(maxWidth: .infinity)
- .foregroundStyle(isOn ? activeColor : AppTheme.textSecondary)
- .padding(.horizontal, 10)
- .padding(.vertical, 7)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(isOn ? activeColor.opacity(0.15) : AppTheme.panelBackground)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 6)
- .stroke(isOn ? activeColor.opacity(0.4) : AppTheme.cardBorder, lineWidth: 1)
- )
- }
- .buttonStyle(AppPlainButtonStyle())
- .hoverOverlay(cornerRadius: 8)
- }
- }
- struct TonePicker: View {
- @Binding var selectedTone: PostTone
- var body: some View {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 6) {
- ForEach(PostTone.allCases) { tone in
- Button {
- selectedTone = tone
- } label: {
- Text(tone.title)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(selectedTone == tone ? .white : AppTheme.textSecondary)
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
- .background(
- Capsule()
- .fill(selectedTone == tone ? AppTheme.accentPurple : AppTheme.panelBackground)
- )
- .overlay(
- Capsule()
- .stroke(
- selectedTone == tone ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder,
- lineWidth: 1
- )
- )
- }
- .buttonStyle(AppPlainButtonStyle())
- .hoverOverlay(cornerRadius: 50)
- }
- }
- }
- }
- }
- struct PostGeneratorMessageBanner: View {
- let message: String
- let isError: Bool
- var body: some View {
- HStack(spacing: 8) {
- Image(systemName: isError ? "exclamationmark.circle.fill" : "checkmark.circle.fill")
- .font(.system(size: 12))
- Text(message)
- .font(.system(size: 11))
- }
- .foregroundStyle(isError ? Color(hex: 0xF87171) : AppTheme.accentGreen)
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 8)
- .fill((isError ? Color(hex: 0xF87171) : AppTheme.accentGreen).opacity(0.1))
- )
- }
- }
- // MARK: - Markdown
- struct PostMarkdownText: View {
- let text: String
- var fontSize: CGFloat = 12
- var color: Color = AppTheme.textSecondary
- var lineSpacing: CGFloat = 3
- var body: some View {
- if let attributed = try? AttributedString(
- markdown: text,
- options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
- ) {
- Text(attributed)
- .font(.system(size: fontSize))
- .foregroundStyle(color)
- .lineSpacing(lineSpacing)
- .fixedSize(horizontal: false, vertical: true)
- .textSelection(.enabled)
- } else {
- Text(text)
- .font(.system(size: fontSize))
- .foregroundStyle(color)
- .lineSpacing(lineSpacing)
- .fixedSize(horizontal: false, vertical: true)
- .textSelection(.enabled)
- }
- }
- }
- // MARK: - Reddit Preview Card
- struct RedditPostPreviewCard: View {
- let draft: PostDraft
- let formattedSubreddit: String
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- previewHeader
- previewContent
- }
- .background(AppTheme.panelBackground)
- .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadius))
- .overlay(
- RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- }
- private var previewHeader: some View {
- HStack(spacing: 8) {
- Circle()
- .fill(
- LinearGradient(
- colors: [AppTheme.accentOrange, AppTheme.accentOrange.opacity(0.7)],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- .frame(width: 28, height: 28)
- .overlay {
- Image(systemName: "person.fill")
- .font(.system(size: 12))
- .foregroundStyle(.white)
- }
- VStack(alignment: .leading, spacing: 1) {
- HStack(spacing: 4) {
- Text(formattedSubreddit)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(AppTheme.textPrimary)
- if !draft.flair.isEmpty {
- Text(draft.flair)
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(AppTheme.accentBlue)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(AppTheme.accentBlue.opacity(0.15))
- .clipShape(Capsule())
- }
- }
- Text("u/ReddoraUser · just now")
- .font(.system(size: 9))
- .foregroundStyle(AppTheme.textTertiary)
- }
- Spacer()
- previewTags
- }
- .padding(.horizontal, 14)
- .padding(.top, 14)
- .padding(.bottom, 10)
- }
- @ViewBuilder
- private var previewTags: some View {
- HStack(spacing: 4) {
- if draft.isNSFW {
- previewTag("NSFW", color: Color(hex: 0xEF4444))
- }
- if draft.isSpoiler {
- previewTag("Spoiler", color: AppTheme.textSecondary)
- }
- if draft.isOC {
- previewTag("OC", color: AppTheme.accentGreen)
- }
- }
- }
- private func previewTag(_ text: String, color: Color) -> some View {
- Text(text)
- .font(.system(size: 8, weight: .bold))
- .foregroundStyle(color)
- .padding(.horizontal, 5)
- .padding(.vertical, 2)
- .background(color.opacity(0.15))
- .clipShape(RoundedRectangle(cornerRadius: 3))
- }
- @ViewBuilder
- private var previewContent: some View {
- VStack(alignment: .leading, spacing: 10) {
- Text(draft.title.isEmpty ? "Your post title will appear here" : draft.title)
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(draft.title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
- .fixedSize(horizontal: false, vertical: true)
- switch draft.postType {
- case .text:
- textPreview
- case .image:
- imagePreview
- case .link:
- linkPreview
- case .video:
- videoPreview
- case .poll:
- pollPreview
- }
- }
- .padding(.horizontal, 14)
- .padding(.bottom, 14)
- }
- @ViewBuilder
- private var textPreview: some View {
- if !draft.body.isEmpty {
- PostMarkdownText(text: draft.body)
- }
- }
- @ViewBuilder
- private var imagePreview: some View {
- if let url = draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
- Image(nsImage: nsImage)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity, maxHeight: 200)
- .frame(height: 200)
- .clipped()
- .clipShape(RoundedRectangle(cornerRadius: 8))
- } else {
- imagePlaceholder(icon: "photo", label: "Image preview")
- }
- if !draft.body.isEmpty {
- PostMarkdownText(text: draft.body, fontSize: 11)
- }
- }
- @ViewBuilder
- private var linkPreview: some View {
- linkCard(
- domain: linkDomain,
- title: draft.linkURL.isEmpty ? "Link preview" : draft.title,
- url: draft.linkURL
- )
- if !draft.body.isEmpty {
- PostMarkdownText(text: draft.body, fontSize: 11)
- }
- }
- @ViewBuilder
- private var videoPreview: some View {
- if draft.videoURL.isEmpty {
- imagePlaceholder(icon: "play.rectangle", label: "Video preview")
- } else {
- linkCard(
- domain: videoDomain,
- title: draft.title.isEmpty ? "Video link" : draft.title,
- url: draft.videoURL
- )
- }
- if !draft.body.isEmpty {
- PostMarkdownText(text: draft.body, fontSize: 11)
- }
- }
- @ViewBuilder
- private var pollPreview: some View {
- if !draft.body.isEmpty {
- PostMarkdownText(text: draft.body, fontSize: 11)
- }
- VStack(spacing: 6) {
- ForEach(draft.pollOptions) { option in
- HStack {
- Text(option.text.isEmpty ? "Poll option" : option.text)
- .font(.system(size: 11))
- .foregroundStyle(option.text.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
- Spacer()
- Circle()
- .stroke(AppTheme.cardBorder, lineWidth: 1.5)
- .frame(width: 14, height: 14)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(AppTheme.cardBackground)
- .clipShape(RoundedRectangle(cornerRadius: 6))
- }
- }
- Text("Poll ends in \(draft.pollDuration.label)")
- .font(.system(size: 9))
- .foregroundStyle(AppTheme.textTertiary)
- }
- private func imagePlaceholder(icon: String, label: String) -> some View {
- RoundedRectangle(cornerRadius: 8)
- .fill(AppTheme.cardBackground)
- .frame(height: 140)
- .overlay {
- VStack(spacing: 6) {
- Image(systemName: icon)
- .font(.system(size: 24))
- .foregroundStyle(AppTheme.textTertiary)
- Text(label)
- .font(.system(size: 10))
- .foregroundStyle(AppTheme.textTertiary)
- }
- }
- }
- private func linkCard(domain: String, title: String, url: String) -> some View {
- HStack(spacing: 10) {
- RoundedRectangle(cornerRadius: 6)
- .fill(AppTheme.cardBackground)
- .frame(width: 60, height: 60)
- .overlay {
- Image(systemName: "link")
- .font(.system(size: 18))
- .foregroundStyle(AppTheme.textTertiary)
- }
- VStack(alignment: .leading, spacing: 3) {
- Text(domain.isEmpty ? "example.com" : domain)
- .font(.system(size: 9))
- .foregroundStyle(AppTheme.textTertiary)
- Text(title)
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(AppTheme.textPrimary)
- .lineLimit(2)
- }
- Spacer(minLength: 0)
- }
- .padding(10)
- .background(AppTheme.cardBackground)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(AppTheme.cardBorder, lineWidth: 1)
- )
- }
- private var linkDomain: String {
- guard let url = URL(string: draft.linkURL), let host = url.host else { return "" }
- return host
- }
- private var videoDomain: String {
- guard let url = URL(string: draft.videoURL), let host = url.host else { return "" }
- return host
- }
- }
|