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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">—</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
app/javascript/bundles/Main/components/SkySection.tsx
Normal file
30
app/javascript/bundles/Main/components/SkySection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
app/javascript/bundles/Main/utils/byteCountToHumanSize.ts
Normal file
23
app/javascript/bundles/Main/utils/byteCountToHumanSize.ts
Normal 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]}`;
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %>
|
||||
Reference in New Issue
Block a user