[Haik]: pt3 of going thru knip errors

This commit is contained in:
haikdc
2026-03-30 23:51:05 -07:00
parent 0dd56bb599
commit 30a0dc5a6f
23 changed files with 75 additions and 725 deletions
+3
View File
@@ -12,6 +12,9 @@
"knip": "knip"
},
"dependencies": {
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/react": "^0.12.21",
"@assistant-ui/react-lexical": "^0.0.3",
"@assistant-ui/react-markdown": "^0.12.7",
@@ -2,7 +2,7 @@ import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
export function formatTokenCount(n: number): string {
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
@@ -10,7 +10,7 @@ import { useComposerAttachments } from './useComposerAttachments';
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts';
import ModelModeSelector from '../ModelModeSelector';
export interface OpenSwarmComposerProps {
interface OpenSwarmComposerProps {
composerExtrasRef: MutableRefObject<ComposerExtras>;
mode: string;
onModeChange: (mode: string) => void;
@@ -2,19 +2,19 @@ import { useState, useCallback, useRef } from 'react';
import { API_BASE } from '@/shared/config';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
export interface AttachedImage {
interface AttachedImage {
data: string;
media_type: string;
preview: string;
}
export interface ForcedToolGroup {
interface ForcedToolGroup {
label: string;
tools: string[];
iconKey?: string;
}
export interface AttachedSkill {
interface AttachedSkill {
id: string;
name: string;
content: string;
@@ -1,58 +0,0 @@
import { useMemo, type MutableRefObject } from 'react';
import { useAui } from '@assistant-ui/react';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
export interface ForcedToolGroup {
label: string;
tools: string[];
iconKey?: string;
}
export interface ComposerHandle {
getConfig: () => {
prompt: string;
contextPaths: ContextPath[];
forcedTools: ForcedToolGroup[];
};
setContent: (
prompt: string,
contextPaths?: ContextPath[],
forcedTools?: ForcedToolGroup[],
) => void;
}
/**
* Provides a ChatInputHandle-compatible interface backed by the assistant-ui
* ComposerRuntime and a shared ComposerExtras ref.
*
* Agent 7 can wire this into useAgentChat to replace the old chatInputRef.
*/
export function useComposerHandle(
composerExtrasRef: MutableRefObject<ComposerExtras>,
): ComposerHandle {
const aui = useAui();
return useMemo<ComposerHandle>(
() => ({
getConfig: () => {
const text = aui.composer().getState().text;
const extras = composerExtrasRef.current;
const contextPaths: ContextPath[] = (extras.contextPaths ?? []) as ContextPath[];
const forcedTools: ForcedToolGroup[] = extras.forcedTools
? extras.forcedTools.map((t) => ({ label: t, tools: [t] }))
: [];
return { prompt: text, contextPaths, forcedTools };
},
setContent: (prompt, contextPaths, forcedTools) => {
aui.composer().setText(prompt);
composerExtrasRef.current = {
...composerExtrasRef.current,
contextPaths: contextPaths as ComposerExtras['contextPaths'],
forcedTools: forcedTools?.flatMap((g) => g.tools),
};
},
}),
[aui, composerExtrasRef],
);
}
@@ -12,7 +12,7 @@ import {
import type { AttachedImage } from '../ImageAttachments';
import type { ForcedToolGroup } from '../AttachmentChips';
export interface ChatSubmitParams {
interface ChatSubmitParams {
editorRef: React.RefObject<HTMLDivElement | null>; attachedSkillsRef: React.MutableRefObject<Record<string, AttachedSkill>>;
generalFileInputRef: React.RefObject<HTMLInputElement | null>;
disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[];
@@ -30,7 +30,7 @@ export interface DispatchableMessage {
selectedBrowserIds?: string[];
}
export interface RuntimeOptions {
interface RuntimeOptions {
composerExtrasRef?: MutableRefObject<ComposerExtras>;
dispatchMessage?: (msg: DispatchableMessage) => void;
}
@@ -20,7 +20,7 @@ const BranchChatContext = createContext<
>(undefined);
export const useBranchChatCallback = () => useContext(BranchChatContext);
export interface OpenSwarmThreadProps {
interface OpenSwarmThreadProps {
sessionId?: string;
onBranchChat?: (newSessionId: string) => void;
children?: ReactNode;
@@ -1,54 +1,16 @@
import type { ReactNode } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { MessageDraft } from '@/components/tool-ui/message-draft';
import { DataTable } from '@/components/tool-ui/data-table';
import { DataTable } from '@/components/tool-ui/data-table/data-table';
import {
parseMcpToolName, getGmailHeader, formatTimestamp, stripHtml,
getGmailHeader, formatTimestamp, stripHtml,
} from '../toolCallUtils';
export { BrowserFeedTracker } from './mcp-browser-feed';
// -- Helpers ----------------------------------------------------------------
/** Unwrap MCP result (string / content-block array / object) into data. */
export function extractMcpData(result: unknown): Record<string, any> {
if (result == null) return {};
let text: string | undefined;
if (typeof result === 'string') {
text = result;
} else if (typeof result === 'object') {
const r = result as Record<string, unknown>;
for (const k of ['text', 'output', 'content', 'result']) {
if (typeof r[k] === 'string') { text = r[k] as string; break; }
}
if (text === undefined) return result as Record<string, any>;
} else {
return {};
}
if (!text) return {};
try {
let parsed = JSON.parse(text);
if (
Array.isArray(parsed) &&
parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')
) {
const joined = parsed
.filter((b: any) => b?.type === 'text')
.map((b: any) => b.text)
.join('\n');
try { parsed = JSON.parse(joined); } catch { return {}; }
}
if (typeof parsed === 'object' && parsed !== null) return parsed;
} catch { /* not JSON */ }
return {};
}
export function extractEmailFields(msg: any) {
function extractEmailFields(msg: any) {
const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)';
const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || '';
const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || '';
@@ -210,15 +172,6 @@ export function renderParsedMcpData(
}
}
/** Full MCP dispatch — parses raw tool name + result, routes to renderer. */
export function renderMcpResult(
toolName: string, result: unknown, toolCallId: string,
): ReactNode | null {
const mcpInfo = parseMcpToolName(toolName);
const data = extractMcpData(result);
return renderParsedMcpData(mcpInfo.service, mcpInfo.action, data, toolCallId);
}
// -- Exported toolkit (empty — MCP tool names are dynamic; Agent 7 wires) ---
export const mcpToolkit: Partial<Toolkit> = {};
+4 -4
View File
@@ -14,14 +14,14 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface FileTreeNode {
interface FileTreeNode {
name: string;
path: string;
isDir: boolean;
children?: FileTreeNode[];
}
export function getFileIcon(filename: string): React.ReactNode {
function getFileIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase();
const size = 15;
switch (ext) {
@@ -74,7 +74,7 @@ export function buildFileTree(filePaths: string[]): FileTreeNode[] {
return root;
}
export interface FileTreeItemProps {
interface FileTreeItemProps {
node: FileTreeNode;
depth: number;
activeFile: string;
@@ -83,7 +83,7 @@ export interface FileTreeItemProps {
c: ReturnType<typeof useClaudeTokens>;
}
export const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
@@ -1,11 +1,5 @@
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export {
Accordion,
AccordionContent,
@@ -35,7 +35,7 @@ import type {
} from "./types";
import type { FormatConfig } from "./formatters";
export const DEFAULT_LOCALE = "en-US" as const;
const DEFAULT_LOCALE = "en-US" as const;
function isNumericFormat(format?: FormatConfig): boolean {
const kind = format?.kind;
@@ -60,8 +60,8 @@ const DataTableContext = React.createContext<
DataTableContextValue<any> | undefined
>(undefined);
export function useDataTable<T extends object = RowData>() {
const context = React.use(DataTableContext) as
function useDataTable<T extends object = RowData>() {
const context = React.useContext(DataTableContext) as
| DataTableContextValue<T>
| undefined;
if (!context) {
@@ -2,7 +2,48 @@
import * as React from "react";
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
import { resolveSafeNavigationHref } from "../shared/media";
function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
@@ -44,7 +85,7 @@ interface DeltaValueProps {
locale?: string;
}
export function DeltaValue({ value, options, locale }: DeltaValueProps) {
function DeltaValue({ value, options, locale }: DeltaValueProps) {
const decimals = options?.decimals ?? 2;
const upIsPositive = options?.upIsPositive ?? true;
const showSign = options?.showSign ?? true;
@@ -90,7 +131,7 @@ interface StatusBadgeProps {
options?: Extract<FormatConfig, { kind: "status" }>;
}
export function StatusBadge({ value, options }: StatusBadgeProps) {
function StatusBadge({ value, options }: StatusBadgeProps) {
const config = options?.statusMap?.[value] ?? {
tone: "neutral" as Tone,
label: value,
@@ -130,7 +171,7 @@ interface CurrencyValueProps {
locale?: string;
}
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
const currency = options?.currency ?? "USD";
const decimals = options?.decimals ?? 2;
@@ -150,7 +191,7 @@ interface PercentValueProps {
locale?: string;
}
export function PercentValue({ value, options, locale }: PercentValueProps) {
function PercentValue({ value, options, locale }: PercentValueProps) {
const decimals = options?.decimals ?? 2;
const showSign = options?.showSign ?? false;
const basis = options?.basis ?? "fraction";
@@ -173,7 +214,7 @@ interface DateValueProps {
locale?: string;
}
export function DateValue({ value, options, locale }: DateValueProps) {
function DateValue({ value, options, locale }: DateValueProps) {
const dateFormat = options?.dateFormat ?? "short";
const date = new Date(value);
@@ -248,7 +289,7 @@ interface BooleanValueProps {
options?: Extract<FormatConfig, { kind: "boolean" }>;
}
export function BooleanValue({ value, options }: BooleanValueProps) {
function BooleanValue({ value, options }: BooleanValueProps) {
const labels = options?.labels ?? { true: "Yes", false: "No" };
const label = value ? labels.true : labels.false;
const variant = value ? "secondary" : "outline";
@@ -265,7 +306,7 @@ interface LinkValueProps {
>;
}
export function LinkValue({ value, options, row }: LinkValueProps) {
function LinkValue({ value, options, row }: LinkValueProps) {
const rawHref =
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
const href = resolveSafeNavigationHref(rawHref);
@@ -300,7 +341,7 @@ interface NumberValueProps {
locale?: string;
}
export function NumberValue({ value, options, locale }: NumberValueProps) {
function NumberValue({ value, options, locale }: NumberValueProps) {
const decimals = options?.decimals ?? 0;
const unit = options?.unit ?? "";
const compact = options?.compact ?? false;
@@ -327,7 +368,7 @@ interface BadgeValueProps {
options?: Extract<FormatConfig, { kind: "badge" }>;
}
export function BadgeValue({ value, options }: BadgeValueProps) {
function BadgeValue({ value, options }: BadgeValueProps) {
const tone = options?.colorMap?.[value] ?? "neutral";
const variant =
@@ -362,7 +403,7 @@ interface ArrayValueProps {
options?: Extract<FormatConfig, { kind: "array" }>;
}
export function ArrayValue({ value, options }: ArrayValueProps) {
function ArrayValue({ value, options }: ArrayValueProps) {
const maxVisible = options?.maxVisible ?? 3;
const items: (string | number | boolean | null)[] = Array.isArray(value)
? value
@@ -1,29 +0,0 @@
export { DataTable, useDataTable } from "./data-table";
export { renderFormattedValue } from "./formatters";
export {
NumberValue,
CurrencyValue,
PercentValue,
DeltaValue,
DateValue,
BooleanValue,
LinkValue,
BadgeValue,
StatusBadge,
ArrayValue,
} from "./formatters";
export type {
Column,
DataTableProps,
DataTableSerializableProps,
DataTableClientProps,
DataTableRowData,
RowPrimitive,
RowData,
ColumnKey,
} from "./types";
export type { FormatConfig } from "./formatters";
export { sortData, parseNumericLike } from "./utilities";
@@ -1,345 +0,0 @@
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
import type { Column, DataTableProps, RowData } from "./types";
const AlignEnum = z.enum(["left", "right", "center"]);
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
const formatSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text") }),
z.object({
kind: z.literal("number"),
decimals: z.number().optional(),
unit: z.string().optional(),
compact: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("currency"),
currency: z.string(),
decimals: z.number().optional(),
}),
z.object({
kind: z.literal("percent"),
decimals: z.number().optional(),
showSign: z.boolean().optional(),
basis: z.enum(["fraction", "unit"]).optional(),
}),
z.object({
kind: z.literal("date"),
dateFormat: z.enum(["short", "long", "relative"]).optional(),
}),
z.object({
kind: z.literal("delta"),
decimals: z.number().optional(),
upIsPositive: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("status"),
statusMap: z.record(
z.string(),
z.object({
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
label: z.string().optional(),
}),
),
}),
z.object({
kind: z.literal("boolean"),
labels: z
.object({
true: z.string(),
false: z.string(),
})
.optional(),
}),
z.object({
kind: z.literal("link"),
hrefKey: z.string().optional(),
external: z.boolean().optional(),
}),
z.object({
kind: z.literal("badge"),
colorMap: z
.record(
z.string(),
z.enum(["success", "warning", "danger", "info", "neutral"]),
)
.optional(),
}),
z.object({
kind: z.literal("array"),
maxVisible: z.number().optional(),
}),
]);
export const serializableColumnSchema = z.object({
key: z.string(),
label: z.string(),
abbr: z.string().optional(),
sortable: z.boolean().optional(),
align: AlignEnum.optional(),
width: z.string().optional(),
truncate: z.boolean().optional(),
priority: PriorityEnum.optional(),
hideOnMobile: z.boolean().optional(),
format: formatSchema.optional(),
});
const JsonPrimitiveSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
/**
* Schema for serializable row data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* Does NOT support:
* - Functions
* - Class instances (Date, Map, Set, etc.)
* - Plain objects (use format configs instead)
*
* @example
* Valid row data:
* ```json
* {
* "name": "Widget",
* "price": 29.99,
* "active": true,
* "tags": ["electronics", "featured"],
* "metrics": [1.2, 3.4, 5.6],
* "flags": [true, false, true],
* "mixed": ["label", 42, true]
* }
* ```
*/
export const serializableDataSchema = z.record(
z.string(),
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
);
/**
* Zod schema for validating DataTable payloads from LLM tool calls.
*
* This schema validates the serializable parts of a DataTable:
* - id: Unique identifier for this tool UI in the conversation
* - columns: Column definitions (keys, labels, formatting, etc.)
* - data: Data rows (primitives only - no functions or class instances)
* - optional presentation props: rowIdKey, sort/defaultSort, locale, etc.
*
* Non-serializable props like `onSortChange`, `className`, and sibling action surfaces
* must be provided separately in your React component.
*
* @example
* ```ts
* const result = SerializableDataTableSchema.safeParse(llmResponse)
* if (result.success) {
* // result.data contains validated id, columns, and data
* }
* ```
*/
export const SerializableDataTableSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
columns: z.array(serializableColumnSchema),
data: z.array(serializableDataSchema),
rowIdKey: z.string().optional(),
defaultSort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
sort: z
.object({
by: z.string().optional(),
direction: z.enum(["asc", "desc"]).optional(),
})
.optional(),
emptyMessage: z.string().optional(),
maxHeight: z.string().optional(),
locale: z.string().optional(),
});
const SerializableDataTableSchemaContract = defineToolUiContract(
"DataTable",
SerializableDataTableSchema,
);
/**
* Type representing the serializable parts of a DataTable payload.
*
* This type includes only JSON-serializable data that can come from LLM tool calls:
* - Column definitions (format configs, alignment, labels, etc.)
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
*
* Excluded from this type:
* - Event handlers (`onSortChange`)
* - React-specific props (`className`)
*
* @example
* ```ts
* const payload: SerializableDataTable = {
* id: "data-table-expenses",
* columns: [
* { key: "name", label: "Name" },
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
* ],
* data: [
* { name: "Widget", price: 29.99 }
* ]
* }
* ```
*/
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
/**
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
*
* This function:
* 1. Validates the input against the `SerializableDataTableSchema`
* 2. Throws a descriptive error if validation fails
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
*
* The returned props are **serializable only** - you must provide client-side props
* separately (onSortChange, className).
*
* @param input - Unknown data to validate (typically from an LLM tool call)
* @returns Validated and typed DataTable serializable props (id, columns, data)
* @throws Error with validation details if input is invalid
*
* @example
* ```tsx
* function MyToolUI({ result }: { result: unknown }) {
* const serializableProps = parseSerializableDataTable(result)
*
* return (
* <DataTable
* {...serializableProps}
* />
* )
* }
* ```
*/
export function parseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> {
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = SerializableDataTableSchemaContract.parse(input);
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
export function safeParseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
| "id"
| "role"
| "receipt"
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "emptyMessage"
| "maxHeight"
| "locale"
> | null {
const res = SerializableDataTableSchemaContract.safeParse(input);
if (!res) return null;
const {
id,
role,
receipt,
columns,
data,
rowIdKey,
defaultSort,
sort,
emptyMessage,
maxHeight,
locale,
} = res;
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
rowIdKey: rowIdKey as keyof RowData | undefined,
defaultSort: defaultSort
? {
by: defaultSort.by as keyof RowData | undefined,
direction: defaultSort.direction,
}
: undefined,
sort: sort
? {
by: sort.by as keyof RowData | undefined,
direction: sort.direction,
}
: undefined,
emptyMessage,
maxHeight,
locale,
};
}
@@ -69,7 +69,7 @@ export function sortData<T, K extends Extract<keyof T, string>>(
* Accepts any JSON-serializable primitive or array of primitives.
* Arrays are converted to comma-separated strings.
*/
export function getRowIdentifier(
function getRowIdentifier(
row: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
@@ -210,7 +210,7 @@ export function getDataTableMobileDescriptionId(surfaceId: string): string {
* parseNumericLike("50%") // 50
* parseNumericLike("(1234)") // -1234
*/
export function parseNumericLike(input: string): number | null {
function parseNumericLike(input: string): number | null {
// Normalize whitespace (spaces, NBSPs, thin spaces)
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
if (!s) return null;
@@ -1,27 +0,0 @@
import { z } from "zod";
export const AspectRatioSchema = z
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
.default("auto");
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
export type MediaFit = z.infer<typeof MediaFitSchema>;
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
auto: "",
"1:1": "aspect-square",
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"9:16": "aspect-[9/16]",
};
export function getRatioClass(ratio: AspectRatio): string {
return RATIO_CLASS_MAP[ratio];
}
export function getFitClass(fit: MediaFit): string {
return fit === "cover" ? "object-cover" : "object-contain";
}
@@ -1,30 +0,0 @@
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.round(durationMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Format file size in bytes to human-readable string.
* @example formatFileSize(1024) => "1 KB"
* @example formatFileSize(1536000) => "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let size = bytes / 1024;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
@@ -1,19 +0,0 @@
export {
AspectRatioSchema,
MediaFitSchema,
RATIO_CLASS_MAP,
getRatioClass,
getFitClass,
type AspectRatio,
type MediaFit,
} from "./aspect-ratio";
export { OVERLAY_GRADIENT } from "./overlay-gradient";
export { formatDuration, formatFileSize } from "./format-utils";
export { sanitizeHref } from "./sanitize-href";
export {
resolveSafeNavigationHref,
openSafeNavigationHref,
} from "./safe-navigation";
@@ -1,19 +0,0 @@
export const OVERLAY_GRADIENT = `linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 8.3%,
hsla(0, 0%, 0%, 0.951) 16.6%,
hsla(0, 0%, 0%, 0.896) 24.6%,
hsla(0, 0%, 0%, 0.825) 32.5%,
hsla(0, 0%, 0%, 0.741) 40.1%,
hsla(0, 0%, 0%, 0.648) 47.6%,
hsla(0, 0%, 0%, 0.55) 54.8%,
hsla(0, 0%, 0%, 0.45) 61.7%,
hsla(0, 0%, 0%, 0.352) 68.3%,
hsla(0, 0%, 0%, 0.259) 74.5%,
hsla(0, 0%, 0%, 0.175) 80.4%,
hsla(0, 0%, 0%, 0.104) 86%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.8%,
hsla(0, 0%, 0%, 0) 100%
)` as const;
@@ -1,23 +0,0 @@
import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
export function openSafeNavigationHref(href: string | undefined): boolean {
if (!href || typeof window === "undefined") {
return false;
}
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
@@ -1,28 +0,0 @@
export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
-63
View File
@@ -3,7 +3,6 @@ import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
@@ -23,12 +22,6 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
@@ -79,43 +72,6 @@ function DialogContent({
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
@@ -129,28 +85,9 @@ function DialogTitle({
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}