mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Fix: memory and context views mobile friendly (#818)
* Changes from fix/memory-and-context-mobile-friendly * fix: Improve file extension detection and add path traversal protection * refactor: Extract file extension utilities and add path traversal guards Code review improvements: - Extract isMarkdownFilename and isImageFilename to shared image-utils.ts - Remove duplicated code from context-view.tsx and memory-view.tsx - Add path traversal guard for context fixture utilities (matching memory) - Add 7 new tests for context fixture path traversal protection - Total 61 tests pass Addresses code review feedback from PR #813 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add e2e tests for profiles crud and board background persistence * Update apps/ui/playwright.config.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Add robust test navigation handling and file filtering * fix: Format NODE_OPTIONS configuration on single line * test: Update profiles and board background persistence tests * test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency * Update apps/ui/src/components/views/context-view.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: Remove test project directory * feat: Filter context files by type and improve mobile menu visibility --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -98,6 +98,7 @@ export function HeaderActionsPanelTrigger({
|
||||
onClick={onToggle}
|
||||
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
|
||||
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
||||
data-testid="header-actions-panel-trigger"
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
FilePlus,
|
||||
FileUp,
|
||||
MoreVertical,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
@@ -42,7 +44,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeFilename } from '@/lib/image-utils';
|
||||
import { sanitizeFilename, isMarkdownFilename, isImageFilename } from '@/lib/image-utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -54,6 +56,16 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const logger = createLogger('ContextView');
|
||||
|
||||
// Responsive layout classes
|
||||
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
|
||||
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
|
||||
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
|
||||
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
|
||||
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
|
||||
|
||||
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
|
||||
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
type: 'text' | 'image';
|
||||
@@ -103,6 +115,9 @@ export function ContextView() {
|
||||
// File input ref for import
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||
() => [
|
||||
@@ -122,18 +137,6 @@ export function ContextView() {
|
||||
return `${currentProject.path}/.automaker/context`;
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
// Determine if a file is an image based on extension
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
// Load context metadata
|
||||
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
||||
const contextPath = getContextPath();
|
||||
@@ -195,10 +198,15 @@ export function ContextView() {
|
||||
const result = await api.readdir(contextPath);
|
||||
if (result.success && result.entries) {
|
||||
const files: ContextFile[] = result.entries
|
||||
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile &&
|
||||
entry.name !== 'context-metadata.json' &&
|
||||
(isMarkdownFilename(entry.name) || isImageFilename(entry.name))
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||
type: isImageFilename(entry.name) ? 'image' : 'text',
|
||||
path: `${contextPath}/${entry.name}`,
|
||||
description: metadata.files[entry.name]?.description,
|
||||
}));
|
||||
@@ -232,11 +240,10 @@ export function ContextView() {
|
||||
|
||||
// Select a file
|
||||
const handleSelectFile = (file: ContextFile) => {
|
||||
if (hasChanges) {
|
||||
// Could add a confirmation dialog here
|
||||
}
|
||||
// Note: Unsaved changes warning could be added here in the future
|
||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
||||
loadFileContent(file);
|
||||
setIsPreviewMode(isMarkdownFile(file.name));
|
||||
setIsPreviewMode(isMarkdownFilename(file.name));
|
||||
};
|
||||
|
||||
// Save current file
|
||||
@@ -341,7 +348,7 @@ export function ContextView() {
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const isImage = isImageFile(file.name);
|
||||
const isImage = isImageFilename(file.name);
|
||||
|
||||
let filePath: string;
|
||||
let fileName: string;
|
||||
@@ -582,7 +589,7 @@ export function ContextView() {
|
||||
// Update selected file with new name and path
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? 'image' : 'text',
|
||||
type: isImageFilename(newName) ? 'image' : 'text',
|
||||
path: newPath,
|
||||
content: result.content,
|
||||
description: metadata.files[newName]?.description,
|
||||
@@ -790,7 +797,17 @@ export function ContextView() {
|
||||
)}
|
||||
|
||||
{/* Left Panel - File List */}
|
||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
|
||||
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
|
||||
<div
|
||||
className={cn(
|
||||
FILE_LIST_BASE_CLASSES,
|
||||
FILE_LIST_DESKTOP_CLASSES,
|
||||
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
|
||||
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
|
||||
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
|
||||
)}
|
||||
>
|
||||
<div className="p-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Context Files ({contextFiles.length})
|
||||
@@ -844,7 +861,12 @@ export function ContextView() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||
className={cn(
|
||||
'p-1 hover:bg-accent rounded transition-opacity',
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
aria-label={`Actions for ${file.name}`}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`context-file-menu-${file.name}`}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
@@ -881,12 +903,31 @@ export function ContextView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Editor/Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
|
||||
<div
|
||||
className={cn(
|
||||
EDITOR_PANEL_BASE_CLASSES,
|
||||
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
|
||||
)}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
{/* File toolbar */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Mobile-only: Back button to return to file list */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedFile(null)}
|
||||
className="shrink-0 -ml-1"
|
||||
aria-label="Back"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === 'image' ? (
|
||||
<ImageIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
@@ -894,23 +935,26 @@ export function ContextView() {
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
||||
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
||||
{selectedFile.type === 'text' && isMarkdownFilename(selectedFile.name) && (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
data-testid="toggle-preview-mode"
|
||||
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
|
||||
title={isPreviewMode ? 'Edit' : 'Preview'}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
<Pencil className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Edit</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
<Eye className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Preview</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -921,20 +965,31 @@ export function ContextView() {
|
||||
onClick={saveFile}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-context-file"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
<Save className="w-4 h-4" />
|
||||
{!isMobile && (
|
||||
<span className="ml-2">
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-context-file"
|
||||
aria-label="Delete"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-context-file"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1072,7 +1127,7 @@ export function ContextView() {
|
||||
.filter((f): f is globalThis.File => f !== null);
|
||||
}
|
||||
|
||||
const mdFile = files.find((f) => isMarkdownFile(f.name));
|
||||
const mdFile = files.find((f) => isMarkdownFilename(f.name));
|
||||
if (mdFile) {
|
||||
const content = await mdFile.text();
|
||||
setNewMarkdownContent(content);
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
Pencil,
|
||||
FilePlus,
|
||||
MoreVertical,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isMarkdownFilename } from '@/lib/image-utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -41,6 +44,16 @@ import {
|
||||
|
||||
const logger = createLogger('MemoryView');
|
||||
|
||||
// Responsive layout classes
|
||||
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
|
||||
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
|
||||
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
|
||||
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
|
||||
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
|
||||
|
||||
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
|
||||
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
|
||||
|
||||
interface MemoryFile {
|
||||
name: string;
|
||||
content?: string;
|
||||
@@ -68,17 +81,15 @@ export function MemoryView() {
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Get memory directory path
|
||||
const getMemoryPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
return `${currentProject.path}/.automaker/memory`;
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
// Load memory files
|
||||
const loadMemoryFiles = useCallback(async () => {
|
||||
const memoryPath = getMemoryPath();
|
||||
@@ -95,7 +106,7 @@ export function MemoryView() {
|
||||
const result = await api.readdir(memoryPath);
|
||||
if (result.success && result.entries) {
|
||||
const files: MemoryFile[] = result.entries
|
||||
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
|
||||
.filter((entry) => entry.isFile && isMarkdownFilename(entry.name))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: `${memoryPath}/${entry.name}`,
|
||||
@@ -130,9 +141,8 @@ export function MemoryView() {
|
||||
|
||||
// Select a file
|
||||
const handleSelectFile = (file: MemoryFile) => {
|
||||
if (hasChanges) {
|
||||
// Could add a confirmation dialog here
|
||||
}
|
||||
// Note: Unsaved changes warning could be added here in the future
|
||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
||||
loadFileContent(file);
|
||||
setIsPreviewMode(true);
|
||||
};
|
||||
@@ -381,7 +391,17 @@ export function MemoryView() {
|
||||
{/* Main content area with file list and editor */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel - File List */}
|
||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
|
||||
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
|
||||
<div
|
||||
className={cn(
|
||||
FILE_LIST_BASE_CLASSES,
|
||||
FILE_LIST_DESKTOP_CLASSES,
|
||||
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
|
||||
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
|
||||
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
|
||||
)}
|
||||
>
|
||||
<div className="p-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Memory Files ({memoryFiles.length})
|
||||
@@ -455,31 +475,53 @@ export function MemoryView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Editor/Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
|
||||
<div
|
||||
className={cn(
|
||||
EDITOR_PANEL_BASE_CLASSES,
|
||||
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
|
||||
)}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
{/* File toolbar */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Mobile-only: Back button to return to file list */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedFile(null)}
|
||||
className="shrink-0 -ml-1"
|
||||
aria-label="Back"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
||||
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
data-testid="toggle-preview-mode"
|
||||
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
|
||||
title={isPreviewMode ? 'Edit' : 'Preview'}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
<Pencil className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Edit</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
<Eye className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Preview</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -488,19 +530,30 @@ export function MemoryView() {
|
||||
onClick={saveFile}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-memory-file"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-memory-file"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Save className="w-4 h-4" />
|
||||
{!isMobile && (
|
||||
<span className="ml-2">
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-memory-file"
|
||||
aria-label="Delete"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export const ACCEPTED_TEXT_TYPES = ['text/plain', 'text/markdown', 'text/x-markd
|
||||
// File extensions for text files (used for validation when MIME type is unreliable)
|
||||
export const ACCEPTED_TEXT_EXTENSIONS = ['.txt', '.md'];
|
||||
|
||||
// File extensions for markdown files
|
||||
export const MARKDOWN_EXTENSIONS = ['.md', '.markdown'];
|
||||
|
||||
// File extensions for image files (used for display filtering)
|
||||
export const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||
|
||||
// Default max file size (10MB)
|
||||
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
@@ -234,3 +240,29 @@ export function getTextFileMimeType(filename: string): string {
|
||||
}
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename has a markdown extension
|
||||
*
|
||||
* @param filename - The filename to check
|
||||
* @returns True if the filename has a .md or .markdown extension
|
||||
*/
|
||||
export function isMarkdownFilename(filename: string): boolean {
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0) return false;
|
||||
const ext = filename.toLowerCase().substring(dotIndex);
|
||||
return MARKDOWN_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename has an image extension
|
||||
*
|
||||
* @param filename - The filename to check
|
||||
* @returns True if the filename has an image extension
|
||||
*/
|
||||
export function isImageFilename(filename: string): boolean {
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0) return false;
|
||||
const ext = filename.toLowerCase().substring(dotIndex);
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user