mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
Consolidate duplicate utility functions into shared modules
Extract repeated helper functions from components into shared utils (colors.ts, date.ts, file.ts) to reduce duplication: - isLightColor, parseFilamentColor, hexToColorName → utils/colors.ts - formatMediaTime, formatDurationFromHours → utils/date.ts - formatFileSize guard hardened for NaN/negative → utils/file.ts - formatBytes (ToastContext), formatTime (Timelapse*) deduplicated Functions with subtly different behavior are intentionally kept local (formatFilament, formatStorageSize, formatBytes, formatUptime, formatDateTime, formatDate, formatTimeAgo) to preserve exact UI output.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
|
||||
import { isLightColor } from '../utils/colors';
|
||||
|
||||
interface FilamentData {
|
||||
vendor: 'Bambu Lab' | 'Generic';
|
||||
@@ -136,17 +137,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
return '#22c55e'; // green
|
||||
};
|
||||
|
||||
// Determine if color is light (for text contrast on swatch)
|
||||
const isLightColor = (hex: string | null): boolean => {
|
||||
if (!hex) return false;
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const r = parseInt(cleanHex.slice(0, 2), 16);
|
||||
const g = parseInt(cleanHex.slice(2, 4), 16);
|
||||
const b = parseInt(cleanHex.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.6;
|
||||
};
|
||||
|
||||
const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
|
||||
const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
|
||||
|
||||
|
||||
@@ -247,7 +247,6 @@ function formatStorageSize(bytes: number): string {
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
|
||||
function getFileIcon(filename: string, isDirectory: boolean) {
|
||||
if (isDirectory) return Folder;
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string | null;
|
||||
}
|
||||
@@ -66,12 +72,6 @@ function StatusBadge({ status }: StatusBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function GitHubBackupSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -5,17 +5,16 @@ import { api } from '../api/client';
|
||||
import type { SpoolUsageRecord } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface SpoolUsageHistoryProps {
|
||||
spoolId: number;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
|
||||
' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
interface SpoolUsageHistoryProps {
|
||||
spoolId: number;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
completed: 'text-bambu-green',
|
||||
failed: 'text-red-400',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Button } from './Button';
|
||||
import { api } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatMediaTime } from '../utils/date';
|
||||
|
||||
interface TimelapseEditorModalProps {
|
||||
archiveId: number;
|
||||
@@ -28,12 +29,6 @@ interface TimelapseEditorModalProps {
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function TimelapseEditorModal({
|
||||
archiveId,
|
||||
timelapseSrc,
|
||||
@@ -321,7 +316,7 @@ export function TimelapseEditorModal({
|
||||
<Scissors className="w-4 h-4" />
|
||||
<span>Trim</span>
|
||||
<span className="ml-auto">
|
||||
{formatTime(trimStart)} - {formatTime(trimEnd)} ({formatTime(trimmedDuration)})
|
||||
{formatMediaTime(trimStart)} - {formatMediaTime(trimEnd)} ({formatMediaTime(trimmedDuration)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -435,7 +430,7 @@ export function TimelapseEditorModal({
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<Gauge className="w-4 h-4" />
|
||||
<span>Speed</span>
|
||||
<span className="ml-auto">{speed}x (output: {formatTime(outputDuration)})</span>
|
||||
<span className="ml-auto">{speed}x (output: {formatMediaTime(outputDuration)})</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{SPEED_OPTIONS.map((s) => (
|
||||
@@ -523,10 +518,10 @@ export function TimelapseEditorModal({
|
||||
{/* Summary */}
|
||||
<div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
|
||||
<p className="text-bambu-gray">
|
||||
<span className="text-white">Original:</span> {formatTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
|
||||
<span className="text-white">Original:</span> {formatMediaTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
|
||||
</p>
|
||||
<p className="text-bambu-gray">
|
||||
<span className="text-white">Output:</span> {formatTime(outputDuration)} @ {speed}x speed
|
||||
<span className="text-white">Output:</span> {formatMediaTime(outputDuration)} @ {speed}x speed
|
||||
{audioFile && ` + music overlay`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { TimelapseEditorModal } from './TimelapseEditorModal';
|
||||
import { formatMediaTime } from '../utils/date';
|
||||
|
||||
interface TimelapseViewerProps {
|
||||
src: string;
|
||||
@@ -97,12 +98,6 @@ export function TimelapseViewer({
|
||||
video.currentTime = Math.min(duration, video.currentTime + 5);
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = src;
|
||||
@@ -154,7 +149,7 @@ export function TimelapseViewer({
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-bambu-gray w-12 text-right">
|
||||
{formatTime(currentTime)}
|
||||
{formatMediaTime(currentTime)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -168,7 +163,7 @@ export function TimelapseViewer({
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-bambu-gray w-12">
|
||||
{formatTime(duration)}
|
||||
{formatMediaTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCi
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||
|
||||
@@ -76,14 +77,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const dispatchToastId = 'background-dispatch';
|
||||
const lastDispatchSummaryRef = useRef<string | null>(null);
|
||||
|
||||
const formatBytes = useCallback((bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}, []);
|
||||
|
||||
// Clean up all timeouts on unmount
|
||||
useEffect(() => {
|
||||
const timeouts = timeoutRefs.current;
|
||||
@@ -502,7 +495,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
{job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
|
||||
<div className="text-[11px] text-bambu-gray truncate">
|
||||
{formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
|
||||
{formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
|
||||
{typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,6 +71,7 @@ import { PrintModal } from '../components/PrintModal';
|
||||
import { PrinterInfoModal } from '../components/PrinterInfoModal';
|
||||
import { getGlobalTrayId } from '../utils/amsHelpers';
|
||||
import { getPrinterImage, getWifiStrength } from '../utils/printer';
|
||||
import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
|
||||
|
||||
// Complete Bambu Lab filament color mapping by tray_id_name
|
||||
// Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
|
||||
@@ -334,69 +335,6 @@ function getBambuColorName(trayIdName: string | null | undefined): string | null
|
||||
return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null;
|
||||
}
|
||||
|
||||
// Convert hex color to basic color name
|
||||
function hexToBasicColorName(hex: string | null | undefined): string {
|
||||
if (!hex || hex.length < 6) return 'Unknown';
|
||||
|
||||
// Parse RGB from hex (format: RRGGBBAA or RRGGBB)
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
// Calculate HSL for better color classification
|
||||
const max = Math.max(r, g, b) / 255;
|
||||
const min = Math.min(r, g, b) / 255;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
const rNorm = r / 255;
|
||||
const gNorm = g / 255;
|
||||
const bNorm = b / 255;
|
||||
|
||||
if (max === rNorm) {
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||
} else if (max === gNorm) {
|
||||
h = ((bNorm - rNorm) / d + 2) / 6;
|
||||
} else {
|
||||
h = ((rNorm - gNorm) / d + 4) / 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to degrees
|
||||
h = h * 360;
|
||||
|
||||
// Classify by lightness first
|
||||
if (l < 0.15) return 'Black';
|
||||
if (l > 0.85) return 'White';
|
||||
|
||||
// Low saturation = gray
|
||||
if (s < 0.15) {
|
||||
if (l < 0.4) return 'Dark Gray';
|
||||
if (l > 0.6) return 'Light Gray';
|
||||
return 'Gray';
|
||||
}
|
||||
|
||||
// Classify by hue
|
||||
// Brown is orange/yellow hue with lower lightness
|
||||
if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
|
||||
if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
|
||||
|
||||
if (h < 15 || h >= 345) return 'Red';
|
||||
if (h < 45) return 'Orange';
|
||||
if (h < 70) return 'Yellow';
|
||||
if (h < 150) return 'Green';
|
||||
if (h < 200) return 'Cyan';
|
||||
if (h < 260) return 'Blue';
|
||||
if (h < 290) return 'Purple';
|
||||
return 'Pink';
|
||||
}
|
||||
|
||||
// Format K value with 3 decimal places, default to 0.020 if null
|
||||
function formatKValue(k: number | null | undefined): string {
|
||||
const value = k ?? 0.020;
|
||||
@@ -418,25 +356,6 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse RGBA hex to CSS color (skip if empty or all zeros)
|
||||
function parseFilamentColor(rgba: string): string | null {
|
||||
if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
|
||||
const r = rgba.slice(0, 2);
|
||||
const g = rgba.slice(2, 4);
|
||||
const b = rgba.slice(4, 6);
|
||||
const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
|
||||
if (a === 0) return null;
|
||||
return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
|
||||
}
|
||||
|
||||
function isLightFilamentColor(rgba: string): boolean {
|
||||
if (!rgba || rgba.length < 6) return false;
|
||||
const r = parseInt(rgba.slice(0, 2), 16);
|
||||
const g = parseInt(rgba.slice(2, 4), 16);
|
||||
const b = parseInt(rgba.slice(4, 6), 16);
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
|
||||
}
|
||||
|
||||
// Expand nozzle type codes to material names
|
||||
// Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
|
||||
// Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
|
||||
@@ -830,7 +749,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
|
||||
{rackSlots.map((slot, i) => {
|
||||
const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
|
||||
const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
|
||||
const lightBg = filamentBg ? isLightFilamentColor(slot.filament_color) : false;
|
||||
const lightBg = filamentBg ? isLightColor(slot.filament_color) : false;
|
||||
|
||||
return (
|
||||
<NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
|
||||
@@ -2937,7 +2856,7 @@ function PrinterCard({
|
||||
const filamentData = tray?.tray_type ? {
|
||||
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
|
||||
colorHex: tray.tray_color || null,
|
||||
kFactor: formatKValue(tray.k),
|
||||
fillLevel: effectiveFill,
|
||||
@@ -3159,7 +3078,7 @@ function PrinterCard({
|
||||
const filamentData = tray?.tray_type ? {
|
||||
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
|
||||
colorHex: tray.tray_color || null,
|
||||
kFactor: formatKValue(tray.k),
|
||||
fillLevel: htEffectiveFill,
|
||||
@@ -3420,7 +3339,7 @@ function PrinterCard({
|
||||
const extFilamentData = {
|
||||
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
|
||||
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
|
||||
colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(extTray.tray_color),
|
||||
colorHex: extTray.tray_color || null,
|
||||
kFactor: formatKValue(extTray.k),
|
||||
fillLevel: extEffectiveFill,
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
|
||||
import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';
|
||||
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
@@ -45,15 +45,6 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { ProjectModal } from './ProjectsPage';
|
||||
import { getCurrencySymbol } from '../utils/currency';
|
||||
|
||||
function formatDuration(hours: number): string {
|
||||
if (hours < 1) {
|
||||
return `${Math.round(hours * 60)}m`;
|
||||
}
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function formatFilament(grams: number): string {
|
||||
if (grams >= 1000) {
|
||||
return `${(grams / 1000).toFixed(2)}kg`;
|
||||
@@ -186,11 +177,6 @@ function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return '';
|
||||
return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
|
||||
if (!dateString) return null;
|
||||
const dueDate = parseUTCDate(dateString);
|
||||
@@ -610,7 +596,7 @@ export function ProjectDetailPage() {
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label={t('projectDetail.stats.printTime')}
|
||||
value={formatDuration(stats.total_print_time_hours)}
|
||||
value={formatDurationFromHours(stats.total_print_time_hours)}
|
||||
color="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
@@ -738,7 +724,7 @@ export function ProjectDetailPage() {
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-sm text-white">{formatDate(project.due_date)}</span>
|
||||
<span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
|
||||
{getDueDateStatus(project.due_date, t) && (
|
||||
<span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
|
||||
({getDueDateStatus(project.due_date, t)!.label})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { spoolbuddyApi, type SpoolBuddyDevice, type DaemonUpdateCheck } from '../../api/client';
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
|
||||
@@ -83,6 +83,9 @@ export function hexToColorName(hex: string | null | undefined): string {
|
||||
if (l > 0.6) return 'Light Gray';
|
||||
return 'Gray';
|
||||
}
|
||||
// Brown is orange/yellow hue with lower lightness
|
||||
if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
|
||||
if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
|
||||
if (h < 15 || h >= 345) return 'Red';
|
||||
if (h < 45) return 'Orange';
|
||||
if (h < 70) return 'Yellow';
|
||||
@@ -125,3 +128,30 @@ export function resolveSpoolColorName(colorName: string | null, rgba: string | n
|
||||
// Return null (displayed as "-") — better than showing a code
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color.
|
||||
* Returns null for empty, all-zero, or fully transparent colors.
|
||||
*/
|
||||
export function parseFilamentColor(rgba: string): string | null {
|
||||
if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
|
||||
const r = rgba.slice(0, 2);
|
||||
const g = rgba.slice(2, 4);
|
||||
const b = rgba.slice(4, 6);
|
||||
const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
|
||||
if (a === 0) return null;
|
||||
return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hex color is light (for choosing text contrast).
|
||||
* Uses luminance formula: 0.299*R + 0.587*G + 0.114*B.
|
||||
*/
|
||||
export function isLightColor(hex: string | null): boolean {
|
||||
if (!hex || hex.length < 6) return false;
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const r = parseInt(cleanHex.slice(0, 2), 16);
|
||||
const g = parseInt(cleanHex.slice(2, 4), 16);
|
||||
const b = parseInt(cleanHex.slice(4, 6), 16);
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
|
||||
}
|
||||
|
||||
@@ -418,3 +418,28 @@ export function formatRelativeTime(
|
||||
// Older than 7 days
|
||||
return formatDateTime(dateStr, timeFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds as MM:SS for media/video player display.
|
||||
*
|
||||
* @param seconds - Total seconds
|
||||
* @returns Formatted string (e.g., "2:05", "0:30")
|
||||
*/
|
||||
export function formatMediaTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration given in hours to a human-readable string.
|
||||
*
|
||||
* @param hours - Duration in hours (e.g., 2.5)
|
||||
* @returns Formatted string (e.g., "2h 30m", "45m", "3h")
|
||||
*/
|
||||
export function formatDurationFromHours(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-PuCtc4fr.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BAl7JF0v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5I4wfky.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user