generic collapsable sections

This commit is contained in:
Dylan Knutson
2025-03-01 04:52:35 +00:00
parent 585cd1b293
commit 7f0762318e
13 changed files with 302 additions and 121 deletions

View File

@@ -56,6 +56,6 @@
@apply flex items-center border-b border-slate-200 px-2 py-1 text-sm group-hover:bg-slate-50;
}
#rich-text-content blockquote {
.rich-text-content blockquote {
@apply my-4 border-s-4 border-gray-300 bg-slate-200 p-4 italic leading-relaxed;
}

View File

@@ -35,6 +35,7 @@ module Domain::DomainsHelper
furaffinity.net
boosty.to
hipolink.me
archiveofourown.org
].freeze
DOMAIN_TO_ICON_PATH =

View File

@@ -304,12 +304,17 @@ module LogEntriesHelper
},
transformers: [
quote_transformer,
is_plain_text ? remove_empty_newline_transformer : nil,
# is_plain_text ? remove_empty_newline_transformer : nil,
is_plain_text ? plain_text_transformer : nil,
is_plain_text ? remove_multiple_br_transformer : nil,
# is_plain_text ? remove_multiple_br_transformer : nil,
].compact,
)
raw sanitizer.fragment(document_html).strip
fragment = sanitizer.fragment(document_html).strip
if is_plain_text
fragment.gsub!("<br>", "")
fragment.gsub!("<br />", "")
end
raw fragment
end
end

View File

@@ -8,21 +8,34 @@ module UiHelper
sig do
params(
title: String,
has_collapse_widget: T::Boolean,
collapsible: T::Boolean,
initially_collapsed: T::Boolean,
font_size_adjustable: T::Boolean,
kwargs: T.untyped,
block: T.proc.void,
).returns(String)
end
def sky_section_tag(title, has_collapse_widget: false, **kwargs, &block)
def sky_section_tag(
title,
collapsible: false,
initially_collapsed: false,
font_size_adjustable: false,
**kwargs,
&block
)
content = capture(&block)
kwargs[:class] ||= "bg-slate-100 p-4"
content_tag(:div, class: "sky-section") do
concat content_tag(:div, title, class: "sky-section-header")
concat(
content_tag(:div, class: "sky-section-body #{kwargs[:class]}") do
concat content
end,
)
end
render(
partial: "shared/section_controls/sky_section",
locals: {
title: title,
content: content,
collapsible: collapsible,
initially_collapsed: initially_collapsed,
font_size_adjustable: font_size_adjustable,
container_class: kwargs[:class],
},
)
end
end

View File

@@ -0,0 +1,181 @@
/**
* Collapsible Section Controls
*
* This module contains functions to control collapsible sections with font size controls.
* Features include:
* - Toggling between expanded and collapsed view
* - Increasing and decreasing font size
*/
/**
* Class representing a collapsible section with font size controls
*/
class CollapsibleSection {
private readonly section: Element;
private readonly content: HTMLElement;
private readonly toggleTextElement: Element | null;
private readonly toggleIconElement: HTMLElement | null;
private readonly fontDisplayElement: Element | null;
private readonly baseFontSize: number;
private readonly initiallyCollapsed: boolean;
/**
* Create a new collapsible section
* @param rootElement The root DOM element for this section
*/
constructor(rootElement: Element) {
this.section = rootElement;
this.initiallyCollapsed = rootElement.hasAttribute(
'data-initially-collapsed',
);
// Find and cache all necessary DOM elements
const contentElement = rootElement.querySelector(
'[data-collapsable="content"]',
);
if (!(contentElement instanceof HTMLElement)) {
throw new Error(`Section is missing a content element`);
}
this.content = contentElement;
// Get single elements instead of arrays
this.toggleTextElement = rootElement.querySelector(
'[data-collapsable="toggle-text"]',
);
const iconElement = rootElement.querySelector(
'[data-collapsable="toggle-icon"]',
);
this.toggleIconElement =
iconElement instanceof HTMLElement ? iconElement : null;
this.fontDisplayElement = rootElement.querySelector(
'[data-collapsable="font-display"]',
);
// Initialize font size info
this.baseFontSize = parseFloat(
window.getComputedStyle(this.content).fontSize,
);
this.updateFontSizeDisplay();
}
/**
* Initialize event listeners for this section
*/
initEventListeners(): void {
// Setup handlers for all interactive elements
this.section
.querySelectorAll('[data-collapsable="toggle"]')
.forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.toggle();
});
});
this.section
.querySelectorAll('[data-collapsable="decrease"]')
.forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.decreaseFontSize();
});
});
this.section
.querySelectorAll('[data-collapsable="increase"]')
.forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.increaseFontSize();
});
});
}
/**
* Toggle between expanded and collapsed view
*/
toggle(): void {
// Determine current state from DOM
const isCollapsed = this.content.classList.contains('line-clamp-6');
// Toggle line-clamp class
this.content.classList.toggle('line-clamp-6');
// Update toggle text
if (this.toggleTextElement) {
this.toggleTextElement.textContent = isCollapsed
? 'Show Less'
: 'Show More';
}
// Update toggle icon
if (this.toggleIconElement) {
this.toggleIconElement.style.transform = isCollapsed
? 'rotate(180deg)'
: 'rotate(0deg)';
}
}
/**
* Adjust font size of content within min/max bounds
*/
adjustFontSize(delta: number, min = 12, max = 22): void {
const currentSize = parseFloat(
window.getComputedStyle(this.content).fontSize,
);
const newSize = Math.min(Math.max(currentSize + delta, min), max);
this.content.style.fontSize = `${newSize}px`;
this.updateFontSizeDisplay();
}
/**
* Increase font size (max 22px)
*/
increaseFontSize(): void {
this.adjustFontSize(2);
}
/**
* Decrease font size (min 12px)
*/
decreaseFontSize(): void {
this.adjustFontSize(-2);
}
/**
* Update the font size percentage display
*/
updateFontSizeDisplay(): void {
if (!this.fontDisplayElement) return;
const currentSize = parseFloat(
window.getComputedStyle(this.content).fontSize,
);
const percentage = Math.round((currentSize / this.baseFontSize) * 100);
this.fontDisplayElement.textContent = `${percentage}%`;
}
}
/**
* Initialize all collapsible sections on the page
*/
export function initCollapsibleSections(): void {
document
.querySelectorAll('[data-collapsable="root"]')
.forEach((sectionElement) => {
try {
// Create a new section instance
const section = new CollapsibleSection(sectionElement);
// Set up event handlers
section.initEventListeners();
} catch (error) {
console.error('Failed to initialize section:', error);
}
});
}

View File

@@ -2,9 +2,15 @@ import ReactOnRails from 'react-on-rails';
import UserSearchBar from '../bundles/Main/components/UserSearchBar';
import { UserMenu } from '../bundles/Main/components/UserMenu';
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
// This is how react_on_rails can see the components in the browser.
ReactOnRails.register({
UserSearchBar,
UserMenu,
});
// Initialize collapsible sections
document.addEventListener('DOMContentLoaded', function () {
initCollapsibleSections();
});

View File

@@ -95,6 +95,11 @@ class Domain::Post::InkbunnyPost < Domain::Post
"inkbunny.net"
end
sig { override.returns(T.nilable(String)) }
def description_html_for_view
self.description
end
sig { override.returns(T.nilable(String)) }
def title
super

View File

@@ -1,5 +1,5 @@
<% description_html = sanitize_description_html(post) %>
<%= sky_section_tag("Description", class: description_html ? "bg-slate-700 p-4 text-slate-200" : nil) do %>
<%= sky_section_tag("Description", collapsible: true, class: description_html ? "bg-slate-700 p-4 text-slate-200" : nil) do %>
<% if description_html %>
<%= description_html %>
<% else %>

View File

@@ -1,27 +1,22 @@
<% if log_entry %>
<section class="sky-section">
<div class="section-header">File Details</div>
<div class="bg-slate-100 p-4">
<div class="flex flex-wrap gap-x-4 text-sm text-slate-600">
<span>
<i class="fa-regular fa-file mr-1"></i>
<% ct = log_entry.content_type %>
<% ct = ct.split(";").first if ct %>
Type: <%= ct %>
</span>
<span>
<i class="fa-solid fa-weight-hanging mr-1"></i>
Size: <%= number_to_human_size(log_entry.response_size) %>
</span>
<span>
<i class="fa-solid fa-clock mr-1"></i>
Response Time: <%= log_entry.response_time_ms == -1 ? "(unknown)" : "#{log_entry.response_time_ms}ms" %>
</span>
<span>
<i class="fa-solid fa-signal mr-1"></i>
Status: <span class="<%= log_entry.status_code == 200 ? 'text-green-600' : 'text-red-600' %>"><%= log_entry.status_code %></span>
</span>
</div>
</div>
</section>
<% end %>
<%= sky_section_tag("File Details") do %>
<div class="flex flex-wrap gap-x-4 text-sm text-slate-600">
<span>
<i class="fa-regular fa-file mr-1"></i>
<% ct = log_entry.content_type %>
<% ct = ct.split(";").first if ct %>
Type: <%= ct %>
</span>
<span>
<i class="fa-solid fa-weight-hanging mr-1"></i>
Size: <%= number_to_human_size(log_entry.response_size) %>
</span>
<span>
<i class="fa-solid fa-clock mr-1"></i>
Response Time: <%= log_entry.response_time_ms == -1 ? "(unknown)" : "#{log_entry.response_time_ms}ms" %>
</span>
<span>
<i class="fa-solid fa-signal mr-1"></i>
Status: <span class="<%= log_entry.status_code == 200 ? 'text-green-600' : 'text-red-600' %>"><%= log_entry.status_code %></span>
</span>
</div>
<% end if log_entry %>

View File

@@ -1,86 +1,17 @@
<section class="sky-section w-full">
<% rich_text_content = render_rich_text_content(log_entry) %>
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
<span><%= pretty_content_type(log_entry.content_type) %></span>
<div class="flex items-center gap-3">
<% if rich_text_content.present? %>
<div class="flex items-center gap-2">
<span id="font-size-percentage" class="text-sm text-gray-600 font-medium">100%</span>
<button onclick="decreaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
<i class="fa-solid fa-minus"></i>
</button>
<button onclick="increaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<button onclick="toggleRichTextContent()" id="rich-text-toggle" class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 bg-gray-100 border border-gray-300 rounded-md px-2 py-1 shadow-sm hover:bg-gray-200">
<span>Show More</span>
<i id="rich-text-toggle-icon" class="fa-solid fa-chevron-down w-4 h-4"></i>
</button>
<% end %>
</div>
</div>
<% rich_text_content = render_rich_text_content(log_entry) %>
<%= sky_section_tag(
pretty_content_type(log_entry.content_type),
collapsible: rich_text_content.present?,
initially_collapsed: rich_text_content.present?,
font_size_adjustable: rich_text_content.present?,
) do %>
<% if rich_text_content.present? %>
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif">
<div id="rich-text-content" class="line-clamp-6 leading-loose">
<%= rich_text_content %>
</div>
<script>
// Store the base font size when page loads
let baseFontSize;
document.addEventListener('DOMContentLoaded', function() {
const content = document.getElementById('rich-text-content');
baseFontSize = parseFloat(window.getComputedStyle(content).fontSize);
updateFontSizePercentage(baseFontSize);
});
function toggleRichTextContent() {
const content = document.getElementById('rich-text-content');
const button = document.getElementById('rich-text-toggle');
const icon = document.getElementById('rich-text-toggle-icon');
if (content.classList.contains('line-clamp-6')) {
content.classList.remove('line-clamp-6');
button.firstElementChild.textContent = 'Show Less';
icon.style.transform = 'rotate(180deg)';
} else {
content.classList.add('line-clamp-6');
button.firstElementChild.textContent = 'Show More';
icon.style.transform = 'rotate(0deg)';
}
}
function increaseFontSize() {
const content = document.getElementById('rich-text-content');
const currentSize = window.getComputedStyle(content).fontSize;
const newSize = Math.min(parseFloat(currentSize) + 2, 22); // Don't allow larger than 22px
content.style.fontSize = `${newSize}px`;
updateFontSizePercentage(newSize);
}
function decreaseFontSize() {
const content = document.getElementById('rich-text-content');
const currentSize = window.getComputedStyle(content).fontSize;
const newSize = Math.max(parseFloat(currentSize) - 2, 12); // Don't allow smaller than 12px
content.style.fontSize = `${newSize}px`;
updateFontSizePercentage(newSize);
}
function updateFontSizePercentage(currentSize) {
// If baseFontSize isn't set yet, set it
if (!baseFontSize) {
baseFontSize = currentSize;
}
const percentageElement = document.getElementById('font-size-percentage');
const percentage = Math.round((currentSize / baseFontSize) * 100);
percentageElement.textContent = `${percentage}%`;
}
</script>
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif rich-text-content">
<%= rich_text_content %>
</div>
<% else %>
<div class="bg-slate-100 p-4 text-slate-500 italic text-center">
<span>Error rendering document</span>
</div>
<% end %>
</section>
<% end %>

View File

@@ -0,0 +1,11 @@
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 font-medium" data-collapsable="font-display">100%</span>
<button class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200"
data-collapsable="decrease">
<i class="fa-solid fa-minus"></i>
</button>
<button class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200"
data-collapsable="increase">
<i class="fa-solid fa-plus"></i>
</button>
</div>

View File

@@ -0,0 +1,24 @@
<div class="sky-section w-full" <%= 'data-collapsable=root' if collapsible %><%= ' data-initially-collapsed=true' if initially_collapsed %>>
<%# Section header with controls %>
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
<span><%= title %></span>
<% if collapsible || font_size_adjustable %>
<div class="flex items-center gap-3">
<% if font_size_adjustable %>
<%= render partial: 'shared/section_controls/font_size_controls' %>
<% end %>
<% if collapsible %>
<%= render partial: 'shared/section_controls/toggle_button', locals: { initially_collapsed: initially_collapsed } %>
<% end %>
</div>
<% end %>
</div>
<%# Content container %>
<div class="<%= container_class %>">
<% content_classes = "leading-loose" %>
<% content_classes += " line-clamp-6" if collapsible && initially_collapsed %>
<div class="<%= content_classes %>"<%= ' data-collapsable=content' if collapsible %>>
<%= content.html_safe %>
</div>
</div>
</div>

View File

@@ -0,0 +1,9 @@
<%
button_text = initially_collapsed ? "Show More" : "Show Less"
icon_style = initially_collapsed ? "" : "style=\"transform: rotate(180deg)\""
%>
<button class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 bg-gray-100 border border-gray-300 rounded-md px-2 py-1 shadow-sm hover:bg-gray-200"
data-collapsable="toggle">
<span data-collapsable="toggle-text"><%= button_text %></span>
<i class="fa-solid fa-chevron-down w-4 h-4" data-collapsable="toggle-icon" <%= icon_style.html_safe %>></i>
</button>