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>
368 lines
16 KiB
Swift
368 lines
16 KiB
Swift
import SwiftUI
|
||
import DSKit
|
||
|
||
/// 문서 = DEVONthink식 컬럼 브라우저. 소스트리(분류)는 글로벌 사이드바에 있고, 이 페이지는 detail
|
||
/// 전폭 안에서 내부 HSplitView 3-pane = 컬럼 리스트 | MD 리더 | 인스펙터(토글). 도메인 필터는
|
||
/// 사이드바가 model.loadDocuments(domain:) 로 서버 재조회.
|
||
struct DocumentsBrowser: View {
|
||
@Environment(AppModel.self) private var model
|
||
@State private var showInspector = true
|
||
@State private var sortOrder = [KeyPathComparator(\DocumentResponse.sortUpdated, order: .reverse)]
|
||
|
||
var body: some View {
|
||
HSplitView {
|
||
DocumentListTable(sortOrder: $sortOrder)
|
||
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
|
||
DocumentReader(showInspector: $showInspector)
|
||
.frame(minWidth: 420, maxWidth: .infinity)
|
||
if showInspector, let d = model.documentDetail {
|
||
DocumentInspector(detail: d)
|
||
.frame(minWidth: 280, idealWidth: 320, maxWidth: 360)
|
||
}
|
||
}
|
||
.task { await model.ensureDocumentsLoaded() } // 진입 시 현재 필터 전체 문서 load-all
|
||
}
|
||
}
|
||
|
||
// MARK: - Column list (sortable Table)
|
||
|
||
private extension DocumentResponse {
|
||
var sortTitle: String { title ?? downloadLabel }
|
||
var sortFormat: String { (originalFormat ?? fileFormat ?? "").lowercased() }
|
||
var sortUpdated: String { updatedAtRaw }
|
||
/// "PDF→MD" / "MD" 식 종류 배지 라벨.
|
||
var formatBadge: String {
|
||
if let orig = originalFormat, orig.lowercased() != (fileFormat ?? "").lowercased() {
|
||
return "\(orig.uppercased())→MD"
|
||
}
|
||
return displayFormat.uppercased()
|
||
}
|
||
}
|
||
|
||
struct DocumentListTable: View {
|
||
@Environment(AppModel.self) private var model
|
||
@Binding var sortOrder: [KeyPathComparator<DocumentResponse>]
|
||
|
||
private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) }
|
||
|
||
var body: some View {
|
||
let selection = Binding<Int?>(
|
||
get: { model.selectedDocumentID },
|
||
set: { if let id = $0 { Task { await model.openDocument(id) } } }
|
||
)
|
||
Group {
|
||
if model.documentList.isEmpty {
|
||
EmptyState(text: "문서가 없습니다")
|
||
} else {
|
||
Table(documents, selection: selection, sortOrder: $sortOrder) {
|
||
TableColumn("제목", value: \.sortTitle) { doc in
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(doc.title ?? doc.downloadLabel)
|
||
.font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Sage.ink).lineLimit(1)
|
||
Text(localizedDomain(doc.aiDomain))
|
||
.font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
TableColumn("종류", value: \.sortFormat) { doc in
|
||
Chip(doc.formatBadge, Sage.formatColor(doc.originalFormat ?? doc.displayFormat))
|
||
}
|
||
.width(min: 66, ideal: 74, max: 96)
|
||
TableColumn("수정", value: \.sortUpdated) { doc in
|
||
Text(doc.updatedAtRaw.prefix(10))
|
||
.font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||
}
|
||
.width(min: 78, ideal: 86, max: 110)
|
||
}
|
||
.tint(Sage.brand)
|
||
}
|
||
}
|
||
.background(Sage.card)
|
||
}
|
||
}
|
||
|
||
// MARK: - Reader
|
||
|
||
struct DocumentReader: View {
|
||
@Environment(AppModel.self) private var model
|
||
@Binding var showInspector: Bool
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let detail = model.documentDetail {
|
||
VStack(spacing: 0) {
|
||
ReaderHeader(detail: detail, showInspector: $showInspector)
|
||
ReaderBody(detail: detail)
|
||
}
|
||
} else {
|
||
EmptyState(text: "문서를 선택하세요")
|
||
}
|
||
}
|
||
.background(Sage.card)
|
||
}
|
||
}
|
||
|
||
private struct ReaderHeader: View {
|
||
let detail: DocumentDetailResponse
|
||
@Binding var showInspector: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(crumb).font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
|
||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||
Text(detail.base.title ?? detail.base.downloadLabel)
|
||
.font(.system(size: 18, weight: .heavy)).foregroundStyle(Sage.ink).lineLimit(2)
|
||
Spacer()
|
||
DownloadButton(doc: detail.base, compact: true)
|
||
inspectorToggle
|
||
}
|
||
metaBadges
|
||
tagRow
|
||
}
|
||
.padding(.horizontal, 26).padding(.vertical, 14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Sage.card)
|
||
.overlay(alignment: .bottom) { Rectangle().fill(Sage.line).frame(height: 1) }
|
||
}
|
||
|
||
private var crumb: String {
|
||
let dom = localizedDomain(detail.base.aiDomain)
|
||
if let sub = detail.base.aiSubGroup, !sub.isEmpty { return "\(dom) › \(sub)" }
|
||
return dom
|
||
}
|
||
|
||
/// 웹 상세 페이지 헤더 배지: 도메인 · 문서유형 · tier DEEP · 신뢰도 · PDF→MD success.
|
||
@ViewBuilder private var metaBadges: some View {
|
||
let b = detail.base
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 6) {
|
||
if let d = b.aiDomain { Chip(localizedDomain(d), Sage.domainColor(d)) }
|
||
if let t = b.documentType, !t.isEmpty { Chip(t, Sage.muted) }
|
||
if b.aiAnalysisTier == "deep" { Chip("tier DEEP", Sage.brand) }
|
||
if let c = b.aiConfidence { Chip("신뢰도 \(String(format: "%.2f", c))", Sage.brandDark) }
|
||
if detail.mdIsRenderable { Chip("PDF→MD success", Sage.mdStatusColor("completed")) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private var inspectorToggle: some View {
|
||
Button { withAnimation(.easeInOut(duration: 0.2)) { showInspector.toggle() } } label: {
|
||
Image(systemName: "info.circle").font(.system(size: 15))
|
||
.foregroundStyle(showInspector ? Sage.brandDark : Sage.muted)
|
||
.frame(width: 30, height: 30)
|
||
.background(showInspector ? Sage.brand.opacity(0.14) : Sage.card, in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(showInspector ? Sage.brand : Sage.line))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("인스펙터")
|
||
}
|
||
|
||
@ViewBuilder private var tagRow: some View {
|
||
let tags = detail.base.aiTags ?? []
|
||
if detail.mdStatus != nil || !tags.isEmpty {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 6) {
|
||
if let st = detail.mdStatus { Chip("MD \(st)", Sage.mdStatusColor(st)) }
|
||
ForEach(tags, id: \.self) { Chip($0, Sage.brand) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ReaderBody: View {
|
||
let detail: DocumentDetailResponse
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
HStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
if detail.mdIsRenderable, let md = detail.mdContent {
|
||
MarkdownView(md)
|
||
} else {
|
||
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
|
||
Text(detail.extractedText ?? "본문 없음")
|
||
.font(.body).foregroundStyle(Sage.muted)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
DownloadButton(doc: detail.base, compact: false)
|
||
}
|
||
}
|
||
.frame(maxWidth: 700, alignment: .leading)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, 28).padding(.top, 22).padding(.bottom, 44)
|
||
}
|
||
.background(Sage.card)
|
||
}
|
||
}
|
||
|
||
// MARK: - Inspector
|
||
|
||
struct DocumentInspector: View {
|
||
let detail: DocumentDetailResponse
|
||
|
||
private var base: DocumentResponse { detail.base }
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
// 인사이트 (웹 상세 페이지 양식: TL;DR · 핵심점 · 심층 · 불일치)
|
||
if let tldr = (base.aiTldr ?? base.aiSummary), !tldr.isEmpty {
|
||
InspectorSection("TL;DR") {
|
||
Text(tldr).font(.system(size: 12)).foregroundStyle(Sage.ink).lineSpacing(2)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
if let bullets = base.aiBullets, !bullets.isEmpty {
|
||
InspectorSection("핵심점") {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(bullets, id: \.self) { b in
|
||
HStack(alignment: .top, spacing: 6) {
|
||
Text("·").font(.system(size: 12, weight: .bold)).foregroundStyle(Sage.amber)
|
||
Text(b).font(.system(size: 12)).foregroundStyle(Sage.ink)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let deep = base.aiDetailSummary, !deep.isEmpty {
|
||
InspectorSection("심층") {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if base.aiAnalysisTier == "deep" { Chip("DEEP", Sage.brand) }
|
||
Text(deep).font(.system(size: 11.5)).foregroundStyle(Sage.ink).lineSpacing(2)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
if let inc = base.aiInconsistencies, !inc.isEmpty {
|
||
InspectorSection("불일치 \(inc.count)") {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach(inc, id: \.self) { x in
|
||
Text("· \(x)").font(.system(size: 11.5)).foregroundStyle(Sage.ink)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 정보
|
||
InspectorSection("정보") {
|
||
VStack(spacing: 0) {
|
||
KV("종류", base.formatBadge)
|
||
KV("도메인", localizedDomain(base.aiDomain))
|
||
KV("하위", base.aiSubGroup ?? "—")
|
||
KV("수정", String(base.updatedAtRaw.prefix(10)))
|
||
if let size = base.fileSize {
|
||
KV("원본", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
|
||
}
|
||
if let st = detail.mdStatus { KV("md 상태", st, color: Sage.mdStatusColor(st)) }
|
||
if let tier = base.aiAnalysisTier { KV("tier", tier, color: Sage.brandDark) }
|
||
if let c = base.aiConfidence { KV("신뢰도", String(format: "%.2f", c), color: Sage.brand) }
|
||
KV("읽음", "\(base.reads)회")
|
||
}
|
||
}
|
||
if let tags = base.aiTags, !tags.isEmpty {
|
||
InspectorSection("태그") { TagWrap(tags: tags) }
|
||
}
|
||
}
|
||
.padding(.horizontal, 16).padding(.vertical, 18)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Sage.sidebar)
|
||
.overlay(alignment: .leading) { Rectangle().fill(Sage.line).frame(width: 1) }
|
||
}
|
||
}
|
||
|
||
private struct InspectorSection<Content: View>: View {
|
||
let title: String
|
||
@ViewBuilder let content: Content
|
||
init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title; self.content = content() }
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(title).font(.system(size: 10, weight: .heavy)).tracking(0.8)
|
||
.textCase(.uppercase).foregroundStyle(Sage.muted.opacity(0.8))
|
||
content
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private struct KV: View {
|
||
let k: String
|
||
let v: String
|
||
var color: Color = Sage.ink
|
||
init(_ k: String, _ v: String, color: Color = Sage.ink) { self.k = k; self.v = v; self.color = color }
|
||
var body: some View {
|
||
HStack {
|
||
Text(k).font(.system(size: 12)).foregroundStyle(Sage.muted)
|
||
Spacer()
|
||
Text(v).font(.system(size: 12, weight: .semibold)).foregroundStyle(color)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
.padding(.vertical, 3)
|
||
}
|
||
}
|
||
|
||
/// 좁은 인스펙터용 태그 줄바꿈 (2개씩 한 줄 — 커스텀 Layout 없이 결정적).
|
||
private struct TagWrap: View {
|
||
let tags: [String]
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(Array(stride(from: 0, to: tags.count, by: 2)), id: \.self) { i in
|
||
HStack(spacing: 6) {
|
||
Chip(tags[i], Sage.brand)
|
||
if i + 1 < tags.count { Chip(tags[i + 1], Sage.brand) }
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Native download button (preserved)
|
||
|
||
/// 원본 파일 네이티브 다운로드 버튼. ?token= 인증 URL 을 NSSavePanel 로 고른 위치에 저장(브라우저
|
||
/// 핸드오프 아님). 진행 스피너 + 저장 결과/오류를 인라인 표시. note 문서는 다운로드 대상 없음 → 숨김.
|
||
struct DownloadButton: View {
|
||
@Environment(AppModel.self) private var model
|
||
let doc: DocumentResponse
|
||
/// compact = 헤더용 짧은 라벨(파일명만) / false = 본문 폴백용 긴 라벨.
|
||
var compact: Bool
|
||
|
||
@State private var busy = false
|
||
@State private var status: String?
|
||
@State private var isError = false
|
||
|
||
var body: some View {
|
||
if let url = model.downloadURL(for: doc) {
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
Task {
|
||
busy = true; status = nil; isError = false
|
||
let outcome = await FileDownloader.download(from: url, suggestedName: doc.downloadLabel)
|
||
busy = false
|
||
switch outcome {
|
||
case .saved(let dest): status = "저장됨: \(dest.lastPathComponent)"; isError = false
|
||
case .cancelled: status = nil
|
||
case .failed(let msg): status = msg; isError = true
|
||
}
|
||
}
|
||
} label: {
|
||
Label(compact ? doc.downloadLabel : "원본 다운로드 — \(doc.downloadLabel)",
|
||
systemImage: "arrow.down.circle")
|
||
.font(.callout.weight(.semibold))
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.disabled(busy)
|
||
if busy { ProgressView().controlSize(.small) }
|
||
if let s = status {
|
||
Text(s).font(.caption)
|
||
.foregroundStyle(isError ? Sage.danger : Sage.muted)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|