import SwiftUI import DSKit /// 2-column 셸 (사이드바 + 단일 detail). 각 섹션이 detail 전폭을 받아 자기 내부 레이아웃을 소유한다 /// (개요=풀폭 캔버스 / 문서=내부 HSplitView 3-pane / 메모=리스트+상세). 이전 3-column 이 대시보드를 /// 좁은 가운데칸에 욱여넣어 깨지던 문제를 구조적으로 제거. macOS-only. /// 인증 게이트: checking(refresh 쿠키 복귀) → loggedOut(LoginView) → ready(셸). public struct RootView: View { @Environment(AppModel.self) private var model @State private var columnVisibility: NavigationSplitViewVisibility = .all public init() {} public var body: some View { Group { switch model.authPhase { case .checking: ProgressView("서버 연결 확인 중") .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Sage.surface) case .loggedOut: LoginView() case .ready: shell } } .task { await model.bootstrap() } } private var shell: some View { NavigationSplitView(columnVisibility: $columnVisibility) { Sidebar() .navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270) } detail: { SectionDetail() } .navigationSplitViewStyle(.balanced) .tint(Sage.brand) .toolbar { ToolbarItem(placement: .primaryAction) { UploadToolbarButton() } ToolbarItem(placement: .primaryAction) { AccountMenu() } } .safeAreaInset(edge: .bottom) { VStack(spacing: 0) { UploadStatusBar() // 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지. if let err = model.errorText { HStack(spacing: 10) { Text(err) .font(.callout) .foregroundStyle(.white) .lineLimit(2) Spacer() Button("닫기") { model.errorText = nil } .buttonStyle(.plain) .foregroundStyle(.white.opacity(0.85)) } .padding(.horizontal, 14) .padding(.vertical, 8) .background(Sage.danger) } } } } } // MARK: - Sidebar struct Sidebar: View { @Environment(AppModel.self) private var model private let navSections: [AppModel.Section] = [.dashboard, .documents, .digest, .memos] var body: some View { let selection = Binding( get: { model.section }, set: { if let v = $0 { model.section = v } } ) List(selection: selection) { BrandRow().selectionDisabled() Section { ForEach(navSections) { s in Label(s.title, systemImage: Self.icon(s)).tag(s) } } // 문서 섹션일 때만 분류 소스트리 노출 (다른 섹션은 4-섹션만 보임). if model.section == .documents { DocumentsSourceSidebar() } } .listStyle(.sidebar) .background(Sage.sidebar) } static func icon(_ s: AppModel.Section) -> String { switch s { case .dashboard: return "house" case .documents: return "folder" case .digest: return "newspaper" case .memos: return "note.text" } } } struct BrandRow: View { var body: some View { HStack(spacing: 8) { RoundedRectangle(cornerRadius: 7).fill(Sage.brand).frame(width: 26, height: 26) .overlay(Text("DS").font(.system(size: 10, weight: .heavy)).foregroundStyle(.white)) Text("Document Server").font(.system(size: 13.5, weight: .heavy)).foregroundStyle(Sage.ink) } .padding(.vertical, 4) } } /// 문서 전용 소스트리: 분류(도메인 필터 = 실데이터) + 스마트그룹/태그(데이터 미연결 placeholder). struct DocumentsSourceSidebar: View { @Environment(AppModel.self) private var model var body: some View { Section("분류") { SourceRow(label: "전체 문서", color: nil, count: model.stats?.total, selected: model.documentDomainFilter == nil) { Task { await model.loadDocuments(domain: nil) } } ForEach(model.tree) { node in SourceRow(label: localizedDomain(node.name), color: Sage.domainColor(node.name), count: node.count, selected: model.documentDomainFilter == node.path) { Task { await model.loadDocuments(domain: node.path) } } } } // 데이터 미연결 — IA 만 맞추고 비활성(가짜 카운트 금지). Section("스마트 그룹") { ForEach(["최근 7일", "검토 대기", "법령 알림"], id: \.self) { t in Text(t).font(.callout).foregroundStyle(Sage.muted).opacity(0.5) } } Section("태그") { ForEach(["압력용기", "ASME", "받은편지함"], id: \.self) { t in Text("#\(t)").font(.callout).foregroundStyle(Sage.muted).opacity(0.5) } } } } /// 소스트리 행 (분류). 선택 시 brand-soft 배경 — List 시스템 선택과 분리(수동 하이라이트). struct SourceRow: View { let label: String let color: Color? let count: Int? let selected: Bool let action: () -> Void var body: some View { HStack(spacing: 8) { if let color { RoundedRectangle(cornerRadius: 3).fill(color).frame(width: 8, height: 8) } Text(label).font(.callout) .foregroundStyle(selected ? Sage.brandDark : Sage.ink) .fontWeight(selected ? .bold : .regular) .lineLimit(1) Spacer() if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) } } .padding(.vertical, 2) .contentShape(Rectangle()) .onTapGesture(perform: action) .listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear) } } // MARK: - Section router /// 선택 섹션을 detail 전폭으로 라우팅. 셸 차원 inspector/list 칼럼 없음 — 각 페이지가 내부에서 소유. struct SectionDetail: View { @Environment(AppModel.self) private var model var body: some View { Group { switch model.section { case .dashboard: DashboardView() // 풀폭 캔버스 case .documents: DocumentsBrowser() // 내부 HSplitView 3-pane case .digest: DigestView() // 풀폭 (뉴스 — 후속 모닝브리핑 재구성) case .memos: MemosBoard() // 리스트 + 상세 (후속 버킷 트리아지) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Sage.surface) .navigationTitle(model.section.title) } } /// 메모 — v1 리스트+상세 split (확정 버킷 트리아지는 후속 트랙). struct MemosBoard: View { @Environment(AppModel.self) private var model var body: some View { HSplitView { MemoListView() .frame(minWidth: 300, idealWidth: 360, maxWidth: 460) Group { if let m = model.memoDetail { MemoDetailView(memo: m) } else { EmptyState(text: "메모를 선택하세요") } } .frame(minWidth: 360, maxWidth: .infinity) } } } struct EmptyState: View { let text: String var body: some View { Text(text) .foregroundStyle(Sage.muted) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Sage.surface) } } // MARK: - Toolbar items /// 툴바 업로드 버튼 — NSOpenPanel 로 파일 선택 → 멀티파트 업로드. 진행 중 비활성. struct UploadToolbarButton: View { @Environment(AppModel.self) private var model var body: some View { Button { guard let fileURL = FilePanels.pickFileToUpload() else { return } Task { await model.uploadPicked(fileURL) } } label: { Label("업로드", systemImage: "square.and.arrow.up") } .help("문서 업로드") .disabled(isUploading) } private var isUploading: Bool { if case .uploading = model.uploadState { return true } return false } } /// 계정 메뉴 — 사용자명 표시 + 로그아웃(확인 대화상자). struct AccountMenu: View { @Environment(AppModel.self) private var model @State private var confirmLogout = false var body: some View { Menu { Button("로그아웃", role: .destructive) { confirmLogout = true } } label: { Label(model.currentUser?.username ?? "계정", systemImage: "person.crop.circle") } .help("계정") .confirmationDialog("로그아웃하시겠습니까?", isPresented: $confirmLogout, titleVisibility: .visible) { Button("로그아웃", role: .destructive) { Task { await model.logout() } } Button("취소", role: .cancel) {} } } } /// 업로드 진행/결과 상태바. uploading=스피너(닫기 없음) / done=성공(처리 대기 안내)+닫기 / failed=오류+닫기. struct UploadStatusBar: View { @Environment(AppModel.self) private var model var body: some View { switch model.uploadState { case .idle: EmptyView() case .uploading(let name): row(bg: Sage.brand) { ProgressView().controlSize(.small).tint(.white) Text("업로드 중 — \(name)").font(.callout).foregroundStyle(.white).lineLimit(1) Spacer() } case .done(let title): row(bg: Sage.brand) { Text("업로드 완료 — \(title) (처리 대기 중)").font(.callout).foregroundStyle(.white).lineLimit(1) Spacer() closeButton } case .failed(let msg): row(bg: Sage.danger) { Text("업로드 실패 — \(msg)").font(.callout).foregroundStyle(.white).lineLimit(2) Spacer() closeButton } } } private var closeButton: some View { Button("닫기") { model.dismissUploadStatus() } .buttonStyle(.plain) .foregroundStyle(.white.opacity(0.85)) } private func row(bg: Color, @ViewBuilder _ content: () -> Content) -> some View { HStack(spacing: 10) { content() } .padding(.horizontal, 14) .padding(.vertical, 8) .background(bg) } } #if DEBUG #Preview("DS App — full shell") { @Previewable @State var model = AppModel.preview RootView() .environment(model) .frame(minWidth: 1100, minHeight: 700) } #endif