Danger warning on approval cards (red card, reasons, no Always); file shelf in the top-left slot with 'На полку' after a drop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 22:51:35 +03:00
parent 3857776111
commit 37c01f991a
9 changed files with 296 additions and 6 deletions
+141
View File
@@ -0,0 +1,141 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
// MARK: - File shelf
//
// Drop files on the notch → "На полку": they wait in the top-left slot of the home
// view. Drag an icon out to put the file anywhere, click to open it, right-click to
// reveal or remove. The shelf keeps references (paths), not copies, and survives restarts;
// files that no longer exist are dropped silently.
struct ShelfItem: Identifiable, Codable, Equatable {
var id = UUID()
var path: String
var addedAt = Date()
var url: URL { URL(fileURLWithPath: path) }
var name: String { url.lastPathComponent }
}
@MainActor
final class FileShelf: ObservableObject {
static let shared = FileShelf()
static let maxItems = 12
private let key = "fileShelf"
@Published private(set) var items: [ShelfItem] = []
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) {
items = saved.filter { FileManager.default.fileExists(atPath: $0.path) }
}
}
func add(_ urls: [URL]) {
for url in urls where !items.contains(where: { $0.path == url.path }) {
items.insert(ShelfItem(path: url.path), at: 0)
}
if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) }
save()
}
func remove(_ item: ShelfItem) {
items.removeAll { $0.id == item.id }
save()
}
func clear() {
items = []
save()
}
/// Drops entries whose file was moved or deleted.
func prune() {
let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) }
if alive != items { items = alive; save() }
}
private func save() {
if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: key)
}
}
}
// MARK: - Shelf pill (home view, top-left slot)
struct ShelfPill: View {
@ObservedObject private var shelf = FileShelf.shared
var body: some View {
ZStack {
if shelf.items.isEmpty {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
Text("полка · брось файл на чёлку")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#4A4E55"))
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(hex: "#0E0F11"))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
HStack(spacing: 6) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(shelf.items) { item in
ShelfIcon(item: item)
}
}
.padding(.horizontal, 2)
}
Button {
shelf.clear()
SoundEngine.shared.play("close")
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#5F646D"))
.frame(width: 16, height: 16)
.background(Circle().fill(Color.white.opacity(0.06)))
}
.buttonStyle(.plain)
.help("Очистить полку")
}
.padding(.horizontal, 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { shelf.prune() }
}
}
struct ShelfIcon: View {
let item: ShelfItem
@State private var isHovered = false
var body: some View {
Image(nsImage: NSWorkspace.shared.icon(forFile: item.path))
.resizable()
.interpolation(.high)
.frame(width: 28, height: 28)
.padding(2)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0)))
.scaleEffect(isHovered ? 1.08 : 1)
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } }
.help(item.name)
.onTapGesture { NSWorkspace.shared.open(item.url) }
.onDrag {
SoundEngine.shared.play("pop")
return NSItemProvider(contentsOf: item.url) ?? NSItemProvider()
}
.contextMenu {
Button("Открыть") { NSWorkspace.shared.open(item.url) }
Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) }
Divider()
Button("Убрать с полки") { FileShelf.shared.remove(item) }
}
}
}