Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -2460,39 +2460,94 @@ body:has(.comment-toolbar) .main-content {
margin-bottom: var(--space-md);
}

.attachments-upload {
/* Drag-and-drop upload zone (see dropzone_controller.js). The whole inner
area is a <label>, so clicking anywhere opens the file picker; the real
input is visually hidden (not display:none, so it stays focusable). */
.attachments-dropzone__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
gap: var(--space-xs);
padding: var(--space-xl) var(--space-lg);
border: 2px dashed var(--color-border-strong);
border-radius: var(--radius);
background: var(--color-surface-muted);
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}

.attachments-dropzone__inner:hover,
.attachments-dropzone--active .attachments-dropzone__inner {
border-color: var(--color-primary);
background: var(--color-primary-light);
}

.attachments-dropzone__inner:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}

.attachments-dropzone--uploading .attachments-dropzone__inner {
opacity: 0.6;
pointer-events: none;
}

.attachments-dropzone__icon {
font-size: 1.75rem;
line-height: 1;
}

.attachments-upload__input {
.attachments-dropzone__text {
font-size: var(--text-sm);
color: var(--color-text);
}

.attachments-upload__hint {
margin: var(--space-xs) 0 0;
.attachments-dropzone__browse {
color: var(--color-primary);
text-decoration: underline;
}

.attachments-dropzone__hint {
font-size: 0.75rem;
color: var(--color-text-muted);
}

.attachments-dropzone__input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}

.attachments-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}

.attachments-list__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--color-border);
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
transition: border-color 0.15s, box-shadow 0.15s;
}

.attachments-list__item:last-child {
border-bottom: none;
.attachments-list__item:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow);
}

.attachments-list__icon {
Expand Down
38 changes: 38 additions & 0 deletions engine/app/javascript/controllers/coplan/dropzone_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Controller } from "@hotwired/stimulus"

// Drag-and-drop upload zone wrapping a visually-hidden file input. Files
// upload immediately on selection or drop — there is no separate submit
// button — so the controller sits on the <form> and posts via
// requestSubmit(), letting Turbo handle the redirect.
export default class extends Controller {
static targets = ["input", "text"]

dragOver(event) {
event.preventDefault()
this.element.classList.add("attachments-dropzone--active")
}

dragLeave(event) {
event.preventDefault()
this.element.classList.remove("attachments-dropzone--active")
}

drop(event) {
event.preventDefault()
this.element.classList.remove("attachments-dropzone--active")
if (!event.dataTransfer?.files?.length) return
this.inputTarget.files = event.dataTransfer.files
this.changed()
}

changed() {
const files = this.inputTarget.files
if (!files.length) return
if (this.hasTextTarget) {
this.textTarget.textContent =
files.length === 1 ? `Uploading ${files[0].name}…` : `Uploading ${files.length} files…`
}
this.element.classList.add("attachments-dropzone--uploading")
this.element.requestSubmit()
}
}
27 changes: 19 additions & 8 deletions engine/app/views/coplan/plans/_attachments.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@
<div class="attachments-section" id="plan-attachments">
<% if can_edit %>
<div class="attachments-section__header">
<%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, class: "attachments-upload" do |f| %>
<%= f.file_field :files, multiple: true, name: "files[]", required: true, class: "attachments-upload__input",
accept: CoPlan::Plan::ATTACHMENT_CONTENT_TYPES.join(",") %>
<%= f.submit "Upload", class: "btn btn--primary btn--sm" %>
<%= form_with url: plan_attachments_path(plan), method: :post, multipart: true,
class: "attachments-dropzone",
data: {
controller: "coplan--dropzone",
action: "dragover->coplan--dropzone#dragOver dragleave->coplan--dropzone#dragLeave drop->coplan--dropzone#drop"
} do |f| %>
<label class="attachments-dropzone__inner">
<span class="attachments-dropzone__icon" aria-hidden="true">🖇️</span>
<span class="attachments-dropzone__text" data-coplan--dropzone-target="text">
<strong>Drag &amp; drop files here</strong> or <span class="attachments-dropzone__browse">browse</span>
</span>
<span class="attachments-dropzone__hint">
Up to <%= CoPlan::Plan::ATTACHMENT_MAX_BYTES / 1.megabyte %> MB per file ·
images, PDF, text/markdown/CSV/JSON, and ZIP
</span>
<%= f.file_field :files, multiple: true, name: "files[]", class: "attachments-dropzone__input",
accept: CoPlan::Plan::ATTACHMENT_CONTENT_TYPES.join(","),
data: { "coplan--dropzone-target": "input", action: "change->coplan--dropzone#changed" } %>
</label>
<% end %>
<p class="attachments-upload__hint text-muted">
Up to <%= CoPlan::Plan::ATTACHMENT_MAX_BYTES / 1.megabyte %> MB per file.
Images, PDF, text/markdown/CSV/JSON, and ZIP files.
</p>
</div>
<% end %>

Expand Down
4 changes: 2 additions & 2 deletions spec/requests/attachments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@
expect(response.body).to include("diagram.png")
expect(response.body).to include("disposition=attachment")
# Viewers can't upload or delete.
expect(response.body).not_to include("attachments-upload")
expect(response.body).not_to include("attachments-dropzone")
end

it "shows the upload form to the plan author" do
sign_in_as(author)
get plan_path(plan, tab: "attachments")

expect(response).to have_http_status(:ok)
expect(response.body).to include("attachments-upload")
expect(response.body).to include("attachments-dropzone")
end
end
end
Loading