Add Bluesky file display components and utilities

- Add SkySection component for displaying Bluesky-specific file information
- Add byteCountToHumanSize utility for formatting file sizes
- Update PostFiles, FileCarousel, FileDetails, and DisplayedFile components
- Enhance posts helper with file display logic
- Update post model and view templates
- Remove deprecated file details sky section partial
This commit is contained in:
Dylan Knutson
2025-08-12 18:14:13 +00:00
parent 390f0939b0
commit 127dd9be51
11 changed files with 253 additions and 148 deletions

View File

@@ -196,29 +196,28 @@ module Domain::PostsHelper
sig do
params(
ok_files: T::Array[Domain::PostFile],
post_files: T::Array[Domain::PostFile],
initial_file_index: T.nilable(Integer),
).returns(T::Hash[Symbol, T.untyped])
end
def props_for_post_files(ok_files, initial_file_index: nil)
def props_for_post_files(post_files:, initial_file_index: nil)
files_data =
ok_files.map.with_index do |file, index|
post_files.map.with_index do |post_file, index|
thumbnail_path = nil
content_html = nil
file_details_html = nil
log_entry = post_file.log_entry
if file.log_entry&.status_code == 200
log_entry = file.log_entry
# Generate thumbnail path
if log_entry && (response_sha256 = log_entry.response_sha256)
thumbnail_path =
blob_path(
HexUtil.bin2hex(response_sha256),
format: "jpg",
thumb: "small",
)
if log_entry && (log_entry.status_code == 200)
if (response_sha256 = log_entry.response_sha256)
thumbnail_path = {
type: "url",
value:
blob_path(
HexUtil.bin2hex(response_sha256),
format: "jpg",
thumb: "small",
),
}
end
# Generate content HTML
@@ -232,35 +231,43 @@ module Domain::PostsHelper
current_user: nil,
},
)
# Generate file details HTML
file_details_html =
ApplicationController.renderer.render(
partial: "log_entries/file_details_sky_section",
locals: {
post_file: file,
},
assigns: {
current_user: nil,
},
)
elsif post_file.state_pending?
thumbnail_path = {
type: "icon",
value: "fa-solid fa-file-arrow-down",
}
end
{
id: file.id,
id: post_file.id,
fileState: post_file.state,
thumbnailPath: thumbnail_path,
hasContent: file.log_entry&.status_code == 200,
hasContent: post_file.log_entry&.status_code == 200,
index: index,
contentHtml: content_html,
fileDetailsHtml: file_details_html,
fileDetails:
(
if log_entry
{
contentType: log_entry.content_type,
fileSize: log_entry.response_size,
responseTimeMs: log_entry.response_time_ms,
responseStatusCode: log_entry.status_code,
postFileState: post_file.state,
logEntryId: log_entry.id,
logEntryPath: log_entry_path(log_entry),
}
else
nil
end
),
}
end
# Validate initial_file_index
validated_initial_index = 0
if initial_file_index && initial_file_index >= 0 &&
initial_file_index < ok_files.count
initial_file_index < post_files.count
validated_initial_index = initial_file_index
end

View File

@@ -16,17 +16,26 @@ export const DisplayedFile: React.FC<DisplayedFileProps> = ({ file }) => {
) : (
<section className="flex grow justify-center text-slate-500">
<div>
<i className="fa-solid fa-file-arrow-down"></i>
No file content available
<i className="fa-solid fa-file-arrow-down mr-2"></i>
{fileStateContent(file.fileState)}
</div>
</section>
)}
</div>
{/* File details */}
{file.fileDetailsHtml && <FileDetails html={file.fileDetailsHtml} />}
{file.fileDetails && <FileDetails {...file.fileDetails} />}
</>
);
};
function fileStateContent(fileState: FileData['fileState']) {
switch (fileState) {
case 'pending':
return 'File pending download';
}
return 'No file content available';
}
export default DisplayedFile;

View File

@@ -40,7 +40,7 @@ export const FileCarousel: React.FC<FileCarouselProps> = ({
isSelected ? 'border-blue-500' : 'border-gray-300',
];
if (file.thumbnailPath) {
if (file.thumbnailPath?.type === 'url') {
buttonClasses.push('overflow-hidden');
} else {
buttonClasses.push(
@@ -51,6 +51,19 @@ export const FileCarousel: React.FC<FileCarouselProps> = ({
);
}
const thumbnail =
file.thumbnailPath?.type === 'url' ? (
<img
src={file.thumbnailPath.value}
className="h-full w-full object-cover"
alt={`File ${file.index + 1}`}
/>
) : file.thumbnailPath?.type === 'icon' ? (
<i className={`${file.thumbnailPath.value} text-slate-500`}></i>
) : (
<i className="fa-solid fa-file text-gray-400"></i>
);
return (
<button
key={file.id}
@@ -60,15 +73,7 @@ export const FileCarousel: React.FC<FileCarouselProps> = ({
data-index={file.index}
title={`File ${file.index + 1} of ${totalFiles}`}
>
{file.thumbnailPath ? (
<img
src={file.thumbnailPath}
className="h-full w-full object-cover"
alt={`File ${file.index + 1}`}
/>
) : (
<i className="fa-solid fa-file text-gray-400"></i>
)}
{thumbnail}
</button>
);
})}

View File

@@ -1,15 +1,112 @@
import * as React from 'react';
import { PostFileState } from './PostFiles';
import { byteCountToHumanSize } from '../utils/byteCountToHumanSize';
import SkySection from './SkySection';
interface FileDetailsProps {
html: string;
export interface FileDetailsProps {
contentType: string;
fileSize: number;
responseTimeMs: number;
responseStatusCode: number;
postFileState: PostFileState;
logEntryId: number;
logEntryPath: string;
}
export const FileDetails: React.FC<FileDetailsProps> = ({ html }) => {
export const FileDetails: React.FC<FileDetailsProps> = ({
contentType,
fileSize,
responseTimeMs,
responseStatusCode,
postFileState,
logEntryId,
logEntryPath,
}) => {
return (
<SkySection
title="File Details"
contentClassName="grid grid-cols-3 sm:grid-cols-6 text-sm"
>
<TitleStat
label="Type"
value={contentType}
iconClass="fa-solid fa-file"
/>
<TitleStat
label="Size"
value={byteCountToHumanSize(fileSize)}
iconClass="fa-solid fa-weight-hanging"
/>
<TitleStat
label="Time"
value={responseTimeMs == -1 ? undefined : `${responseTimeMs}ms`}
iconClass="fa-solid fa-clock"
/>
<TitleStat
label="Status"
value={responseStatusCode}
textClass={
responseStatusCode == 200 ? 'text-green-600' : 'text-red-600'
}
iconClass="fa-solid fa-signal"
/>
<TitleStat
label="State"
value={postFileState}
textClass={postFileState == 'ok' ? 'text-green-600' : 'text-red-600'}
iconClass="fa-solid fa-circle-check"
/>
<TitleStat label="Log Entry" iconClass="fa-solid fa-file-pen">
<a
href={logEntryPath}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800"
>
#{logEntryId}
</a>
</TitleStat>
</SkySection>
);
};
const TitleStat: React.FC<{
label: string;
value?: string | number;
iconClass: string;
textClass?: string;
children?: React.ReactNode;
}> = ({ label, value, iconClass, textClass = 'text-slate-600', children }) => {
function valueElement(value: string | number | undefined) {
const defaultTextClass = 'font-normal';
if (value === undefined) {
return <span className="text-slate-500">&mdash;</span>;
} else if (typeof value === 'number') {
return (
<span className={`${textClass} ${defaultTextClass}`}>
{value.toLocaleString()}
</span>
);
} else {
return (
<span className={`${textClass} ${defaultTextClass}`}>{value}</span>
);
}
}
const gridInnerBorderClasses =
'border-r border-b border-slate-300 last:border-r-0 sm:last:border-r-0 [&:nth-child(3)]:border-r-0 sm:[&:nth-child(3)]:border-r [&:nth-last-child(-n+3)]:border-b-0 sm:[&:nth-last-child(-n+6)]:border-b-0';
return (
<div
className="file-details-section"
dangerouslySetInnerHTML={{ __html: html }}
/>
className={`flex flex-col justify-center px-2 py-1 ${gridInnerBorderClasses}`}
>
<div className="flex items-center gap-2 font-light text-slate-600">
<i className={iconClass}></i>
<span>{label}</span>
</div>
{children || valueElement(value)}
</div>
);
};

View File

@@ -2,14 +2,25 @@ import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { FileCarousel } from './FileCarousel';
import { DisplayedFile } from './DisplayedFile';
import { FileDetailsProps } from './FileDetails';
export type PostFileState =
| 'pending'
| 'ok'
| 'file_error'
| 'retryable_error'
| 'terminal_error'
| 'removed';
export interface FileData {
id: number;
thumbnailPath?: string;
fileState: PostFileState;
thumbnailPath?: { type: 'icon' | 'url'; value: string };
hasContent: boolean;
index: number;
contentHtml?: string;
fileDetailsHtml?: string;
fileDetails?: FileDetailsProps;
}
interface PostFilesProps {

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
export interface SkySectionProps {
title: string;
children?: React.ReactNode;
contentClassName?: string;
}
export const SkySection: React.FC<SkySectionProps> = ({
title,
children,
contentClassName,
}) => {
return (
<div className="sky-section w-full">
<SkySectionHeader title={title} />
<div className={contentClassName}>{children}</div>
</div>
);
};
export default SkySection;
export const SkySectionHeader: React.FC<SkySectionProps> = ({ title }) => {
return (
<div className="section-header flex items-center justify-between border-b py-2">
<span>{title}</span>
</div>
);
};

View File

@@ -0,0 +1,23 @@
/**
* Converts a byte count to a human-readable size string.
*
* @param bytes - The number of bytes to convert
* @param decimals - Number of decimal places to show (default: 1)
* @returns A human-readable size string (e.g., "1.2 KB", "3.4 MB")
*/
export function byteCountToHumanSize(
bytes: number,
decimals: number = 1,
): string {
if (bytes === 0) return '0 B';
if (bytes < 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${size} ${sizes[i]}`;
}

View File

@@ -132,7 +132,7 @@ class Domain::Post < ReduxApplicationRecord
sig { overridable.returns(Symbol) }
def self.post_order_attribute
:id
:posted_at
end
sig { overridable.returns(T::Boolean) }

View File

@@ -1,59 +1,20 @@
<% if policy(post).view_file? %>
<% ok_files = post.files.state_ok.order(:created_at).to_a %>
<% current_file = ok_files.first || post.primary_file_for_view %>
<% if ok_files.any? && current_file&.log_entry&.status_code == 200 %>
<!-- React PostFiles Component handles everything -->
<%
# Extract file index from URL parameter
idx_param = params[:idx]
initial_file_index = idx_param.present? ? idx_param.to_i : nil
%>
<%= react_component(
"PostFiles",
{
prerender: true,
props: props_for_post_files(ok_files, initial_file_index: initial_file_index),
html_options: {
id: "post-files-component"
}
}
) %>
<% elsif current_file.present? && (log_entry = current_file.log_entry) %>
<!-- Fallback for error states -->
<section id="file-display-section">
<section class="flex grow justify-center text-slate-500">
<div>
<i class="fa-solid fa-exclamation-triangle"></i>
File error
<% if log_entry.status_code == 404 %>
(404 not found)
<% else %>
(<%= log_entry.status_code %>)
<% end %>
</div>
</section>
</section>
<% elsif current_file.present? && current_file.state_pending? %>
<!-- Fallback for pending state -->
<section id="file-display-section">
<section class="flex grow justify-center text-slate-500">
<div>
<i class="fa-solid fa-file-arrow-down"></i>
File pending download
</div>
</section>
</section>
<% else %>
<!-- Fallback for no file -->
<section id="file-display-section">
<section class="flex grow justify-center overflow-clip">
<div class="text-slate-500">
<i class="fa-solid fa-file-arrow-down"></i>
No file
</div>
</section>
</section>
<% end %>
<% post_files = post.files.order(:created_at).to_a %>
<%=
react_component(
"PostFiles",
{
prerender: true,
props: props_for_post_files(
post_files:,
initial_file_index: params[:idx]&.to_i
),
html_options: {
id: "post-files-component"
}
}
)
%>
<% else %>
<section class="sky-section">
<%= link_to post.external_url_for_view.to_s,

View File

@@ -1,38 +0,0 @@
<%= sky_section_tag("File Details") do %>
<% log_entry = post_file.log_entry %>
<div class="flex flex-wrap gap-x-4 text-sm text-slate-600 justify-between">
<% ct = log_entry.content_type %>
<% ct = ct.split(";").first if ct %>
<%= render partial: "shared/title_stat", locals: {
label: "Type",
value: ct,
icon_class: "fa-solid fa-file",
} %>
<%= render partial: "shared/title_stat", locals: {
label: "Size",
value: number_to_human_size(log_entry.response_size),
icon_class: "fa-solid fa-weight-hanging",
} %>
<%= render partial: "shared/title_stat", locals: {
label: "Time",
value: log_entry.response_time_ms == -1 ? nil : "#{log_entry.response_time_ms}ms",
icon_class: "fa-solid fa-clock",
} %>
<%= render partial: "shared/title_stat", locals: {
label: "Status",
value: log_entry.status_code.to_s,
value_class: log_entry.status_code == 200 ? "text-green-600" : "text-red-600",
icon_class: "fa-solid fa-signal",
} %>
<%= render partial: "shared/title_stat", locals: {
label: "State",
value: post_file.state,
icon_class: "fa-solid fa-circle-check",
} %>
<%= render partial: "shared/title_stat", locals: {
label: "Log Entry",
value: link_to("##{log_entry.id}", log_entry_path(log_entry), class: "text-blue-600"),
icon_class: "fa-solid fa-link",
} %>
</div>
<% end if post_file&.log_entry %>