f527c63232
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바) - FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리) - 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴) - 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저 (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all - 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
Swift
312 lines
11 KiB
Swift
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<AppModel.Section?>(
|
|
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<Content: View>(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
|