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
92 changes: 80 additions & 12 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -515,11 +515,11 @@ img, svg {
display: none;
}

/* Scroll-aware document title in the top nav. Collapsed to zero width when
inactive so it steals no space and causes no reflow at the top of a plan;
coplan--nav-title flips --visible once the header scrolls behind the bar.
It lives in the left "where am I?" cluster (see .site-nav__inner). */
.site-nav__doc-title {
/* Scroll-aware plan context in the top nav: containing folder + document title.
Collapsed to zero width while the masthead is visible, so it steals no
space and causes no reflow at the top of a plan. coplan--nav-title reveals
the whole context once #plan-header scrolls behind the bar. */
.site-nav__plan-context {
display: flex;
align-items: center;
gap: var(--space-xs);
Expand All @@ -529,6 +529,7 @@ img, svg {
slack — never the other way round. */
flex: 0 1 auto;
min-width: 0;
width: 0;
max-width: 0;
overflow: hidden;
opacity: 0;
Expand All @@ -541,17 +542,28 @@ img, svg {
pointer-events: none;
/* Grow a touch slower than the fade, so it reads as sliding in from the
links rather than shoving the search box aside. */
transition: max-width 0.28s ease, opacity 0.18s ease;
transition: width 0.28s ease, max-width 0.28s ease, opacity 0.18s ease;
}

.site-nav__doc-title--visible {
max-width: 22rem;
.site-nav__plan-context--visible {
width: 25rem;
max-width: 25rem;
opacity: 1;
pointer-events: auto;
}

.site-nav__doc-title {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
color: var(--color-text-muted);
text-decoration: none;
}

.site-nav__doc-title:hover {
color: var(--color-text);
text-decoration: none;
}

.site-nav__doc-title-text {
Expand All @@ -569,7 +581,7 @@ img, svg {
holds nothing else on the left. On a phone it's the ONLY plan context (the
Contents sidebar is hidden < 1024px), so let it take more of the row. */
@media (max-width: 640px) {
.site-nav__doc-title--visible {
.site-nav__plan-context--visible {
max-width: 58vw;
}
}
Expand Down Expand Up @@ -622,6 +634,47 @@ img, svg {
margin-bottom: -2px;
}

/* Compact "up to containing folder" control. In the masthead it lives in
the byline, where it takes no width away from the title; its accessible
name and tooltip carry the full folder path. */
.plan-location-link {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--color-text-muted);
background: transparent;
border: 0;
border-radius: 6px;
text-decoration: none;
transition: color 0.15s, background 0.15s;
}

.plan-location-link:hover,
.plan-location-link:focus-visible {
color: var(--color-primary);
background: var(--color-primary-light);
text-decoration: none;
}

.plan-location-link--masthead {
width: 24px;
height: 24px;
margin: -2px 0 -2px 2px;
}

.plan-location-link--nav {
width: 26px;
height: 26px;
color: var(--color-text);
background: var(--color-surface-muted);
}

.plan-location-link--nav svg {
width: 16px;
height: 16px;
}

/* Plan page header: one lockup — the file icon beside a text stack
(title over byline) that shares a single left edge. The icon anchors
to the title's first line (36px icon inside a ~38px line box), so it
Expand Down Expand Up @@ -5918,16 +5971,31 @@ img.avatar {
}

/* ---- Touch devices (any width) ----
A finger needs a bigger target than a cursor. On coarse pointers the bar's
icon controls and the menu rows grow to ~44px regardless of screen width,
so a touch tablet with the bell still on the bar is just as tappable. */
A finger needs a bigger target than a cursor. On coarse pointers the icon
controls and menu rows grow to ~44px regardless of screen width, so a touch
tablet with the bell still on the bar is just as tappable. */
@media (pointer: coarse) {
.site-nav__menu-btn,
.site-nav__bell {
min-width: 44px;
min-height: 44px;
}

/* Preserve a 44px touch target without giving the compact control 44px
of layout footprint in the byline or sticky plan context. */
.plan-location-link {
width: 44px;
height: 44px;
}

.plan-location-link--masthead {
margin: -12px -10px -12px -8px;
}

.plan-location-link--nav {
margin: -9px;
}

.menu__item {
min-height: 44px;
padding: 10px var(--space-sm);
Expand Down
52 changes: 40 additions & 12 deletions engine/app/controllers/coplan/libraries_controller.rb
Original file line number Diff line number Diff line change
@@ -1,37 +1,65 @@
module CoPlan
# A library is a data-model concept, not a destination: a person's
# library is browsed on their profile, so these routes redirect there
# (fragments like #folder-x survive the redirect). The standalone page
# only renders for a future non-user owner (e.g. a team) that has no
# profile to redirect to.
# Read-only folder navigation for someone else's library. Owners continue
# into their editable workspace; everyone else gets the same level-by-level
# folder model without drag, move, or create controls.
class LibrariesController < ApplicationController
def mine
redirect_to profile_path(current_user.username.presence || current_user.id)
redirect_to plans_path
end

def show
@library = Library.find(params[:id])
authorize!(@library, :show?)

if @library.owner.is_a?(CoPlan::User)
owner = @library.owner
redirect_to profile_path(owner.username.presence || owner.id)
if @library.writable_by?(current_user)
redirect_to plans_path(folder: params[:folder].presence)
return
end

@owner = @library.owner
@folders = @library.folders.order(:name).to_a
@folders_by_id = @folders.index_by(&:id)
@folder_children = @folders.group_by(&:parent_id)
@root_folders = @folder_children[nil] || []
@folder = @folders_by_id[params[:folder]] if params[:folder].present?
if params[:folder].present? && @folder.nil?
redirect_to library_path(@library), alert: "That folder no longer exists."
return
end

placements = @library.placements
.visible_to(current_user)
.where(plan: Plan.active)
.joins(:plan).order("coplan_plans.updated_at DESC")
.includes(plan: [ :created_by_user, :plan_type, :tags ])
.includes(:folder, plan: [ :created_by_user, :plan_type, :current_version_stub ])
.to_a
@placements_by_folder = placements.group_by(&:folder_id)
@plan_count = placements.size

@root_plans = if @owner.is_a?(CoPlan::User)
Plan.visible_to(current_user).active
.where(created_by_user_id: @owner.id)
.where.not(id: @library.placements.select(:plan_id))
.order(updated_at: :desc)
.includes(:created_by_user, :plan_type, :current_version_stub)
.to_a
else
[]
end

@breadcrumbs = []
node = @folder
while node
@breadcrumbs.unshift(node)
node = @folders_by_id[node.parent_id]
end
@subfolders = (@folder_children[@folder&.id] || []).sort_by { |folder| folder.name.downcase }
@plans = @folder ? (@placements_by_folder[@folder.id] || []).map(&:plan) : @root_plans
@plan_count = placements.size + @root_plans.size

direct_counts = @placements_by_folder.transform_values(&:size)
count_folder = lambda do |folder|
direct_counts.fetch(folder.id, 0) + (@folder_children[folder.id] || []).sum { |child| count_folder.call(child) }
end
@folder_counts = @folders.index_with { |folder| count_folder.call(folder) }.transform_keys(&:id)
end
end
end
7 changes: 5 additions & 2 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,12 @@ def show
# Old ?tab=history links: history is its own page now (the other
# former tabs are same-page sections).
return redirect_to history_plan_path(@plan) if params[:tab] == "history"
# The viewer's own placement (if any) drives the toolbar's
# Save/Saved state and the folder navigator's current-folder mark.
# Placements drive both the viewer-relative Save/Saved state and the
# compact jump up to the containing folder in the author's library.
@shelf_placements = @plan.placements
.includes(:library, folder: { parent: :parent })
.order(:created_at)
@author_placement = @shelf_placements.find { |placement| placement.library_id == @plan.created_by_user.library.id }
@my_folders = current_user.library.folders.order(:name).to_a
@threads = @plan.comment_threads.with_kept_comments.includes(:comments, :created_by_user).order(:created_at)
# The reader view joins auto-extracted resources to their Markdown
Expand Down Expand Up @@ -686,6 +687,7 @@ def set_plan

def broadcast_plan_update(plan)
Broadcaster.replace_to(plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: plan })
Broadcaster.replace_to(plan, target: "plan-nav-context", partial: "coplan/plans/nav_context", locals: { plan: plan })
end

# Turbo Streams for a visibility change: re-render the header (the
Expand All @@ -707,6 +709,7 @@ def visibility_streams(message)
def archive_streams(message)
[
turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }),
turbo_stream.replace("plan-nav-context", partial: "coplan/plans/nav_context", locals: { plan: @plan }),
turbo_stream.replace("plan-banner-slot", partial: "coplan/plans/banner", locals: { plan: @plan }),
turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }),
toast_stream(message, "notice")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"

// Fades the plan title into the sticky top nav once the document's own
// header has scrolled up behind the bar. Persistent wayfinding —
// Fades the plan context (containing-folder control + title) into the sticky top nav
// once the document's own header has scrolled up behind the bar. Persistent wayfinding —
// especially on mobile and on comment deep links, where you land centered
// on an anchor with the masthead already off-screen — that costs zero
// space while the header is still visible.
Expand Down Expand Up @@ -70,12 +70,14 @@ export default class extends Controller {
}

_setVisible(visible) {
this.element.classList.toggle("site-nav__doc-title--visible", visible)
this.element.classList.toggle("site-nav__plan-context--visible", visible)
// Collapsed, it's decorative and must stay out of the tab order; once
// shown it's a real return-to-top control, so expose it to keyboard and
// screen-reader users too.
// shown, both the containing-folder and return-to-top links are real controls,
// so expose them to keyboard and screen-reader users too.
this.element.setAttribute("aria-hidden", String(!visible))
this.element.tabIndex = visible ? 0 : -1
this.element.querySelectorAll("a").forEach(link => {
link.tabIndex = visible ? 0 : -1
})
}

// --nav-height is authored in rem; resolve it to px for rootMargin.
Expand Down
7 changes: 7 additions & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ def archived?
archived_at.present?
end

# A plan's containing location is the folder chosen by its author in
# their own library. Other people may save the same plan elsewhere, but
# those placements are personal organization rather than its home.
def author_placement
placements.find_by(library_id: created_by_user.library.id)
end

# Legacy API compatibility (see LEGACY_STATUSES). Emits the closest
# five-state equivalent of the current visibility/archival state.
def legacy_status
Expand Down
70 changes: 50 additions & 20 deletions engine/app/views/coplan/libraries/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<% owner_name = @owner.respond_to?(:name) ? @owner.name : @library.name %>
<% mine = @library.writable_by?(current_user) %>

<div class="library">
<header class="library__header">
Expand All @@ -8,33 +7,64 @@
<%= user_avatar(@owner, size: "lg") %>
<% end %>
<div>
<h1 class="library__title"><%= mine ? "Your library" : "#{owner_name}’s library" %></h1>
<h1 class="library__title"><%= owner_name %>’s library</h1>
<p class="library__subtitle text-muted">
<%= pluralize(@plan_count, "plan") %> across <%= pluralize(@folders.size, "folder") %>
<% if mine %> · the read-only view others see from your profile<% end %>
<% if !mine && @owner.is_a?(CoPlan::User) %> · curated by <%= profile_link(@owner) %><% end %>
<% if @owner.is_a?(CoPlan::User) %> · curated by <%= profile_link(@owner) %><% end %>
</p>
</div>
</div>
<% if mine %>
<%= link_to "Organize in workspace", plans_path, class: "btn btn--secondary btn--sm" %>
<% end %>
</header>

<% if @folders.empty? %>
<nav class="workspace-crumbs" aria-label="Folder location">
<%= link_to library_path(@library),
class: "workspace-crumbs__crumb #{'workspace-crumbs__crumb--current' if @breadcrumbs.empty?}",
aria: @breadcrumbs.empty? ? { current: "location" } : {},
data: { turbo_prefetch: true } do %>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<%= owner_name %>’s library
<% end %>
<% @breadcrumbs.each_with_index do |crumb, index| %>
<svg class="workspace-crumbs__sep" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
<% current = index == @breadcrumbs.size - 1 %>
<%= link_to crumb.name, library_path(@library, folder: crumb.id),
class: "workspace-crumbs__crumb #{'workspace-crumbs__crumb--current' if current}",
aria: current ? { current: "location" } : {},
data: { turbo_prefetch: true } %>
<% end %>
</nav>

<% @subfolders.each do |folder| %>
<%= link_to library_path(@library, folder: folder.id), class: "folder-row", data: { turbo_prefetch: true } do %>
<span class="folder-row__icon" aria-hidden="true">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
</span>
<span class="folder-row__name"><%= folder.name %></span>
<% count = @folder_counts[folder.id] %>
<span class="folder-row__count text-muted"><%= count.zero? ? "empty" : pluralize(count, "item") %></span>
<% end %>
<% end %>

<% @plans.each do |plan| %>
<% summary = plan.try(:summary).presence || plan_content_preview(plan) %>
<article class="plan-row">
<span class="plan-row__icon"><%= plan_type_icon(plan, size: :lg) %></span>
<div class="plan-row__main">
<div class="plan-row__line">
<%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_prefetch: true } %><%= plan_state_badge(plan) %>
</div>
<% if summary.present? %><p class="plan-row__summary text-muted"><%= summary %></p><% end %>
</div>
<div class="plan-row__meta">
<span class="plan-row__time text-muted"><%= time_ago_in_words(plan.updated_at) %> ago</span>
<span class="plan-row__avatar"><%= link_to user_avatar(plan.created_by_user), profile_path_for(plan.created_by_user), title: plan.created_by_user.name %></span>
</div>
</article>
<% end %>

<% if @subfolders.empty? && @plans.empty? %>
<div class="library__empty">
<p>Nothing on these shelves yet.</p>
<% if mine %>
<p class="text-muted text-sm">Create folders in your <%= link_to "workspace", plans_path %> sidebar and drag plans onto them — yours or anyone’s published work.</p>
<% else %>
<p class="text-muted text-sm"><%= owner_name %> hasn’t organized any plans into folders yet.</p>
<% end %>
</div>
<% else %>
<div class="library__shelves">
<% @root_folders.sort_by { |f| f.name.downcase }.each do |folder| %>
<%= render "coplan/libraries/shelf", folder: folder, depth: 1 %>
<% end %>
<p>Nothing in this folder yet.</p>
</div>
<% end %>
</div>
Loading
Loading