chore: remove unused code and dependencies identified by knip (Vibe Kanban) (#1499)

* Here's a summary of what was cleaned up:

## Removed Unused Files (11 files)
- `frontend/src/components/layout/ResponsiveTwoPane.tsx`
- `frontend/src/components/OrgMemberAvatars.tsx`
- `frontend/src/components/tasks/FollowUpStatusRow.tsx`
- `frontend/src/components/tasks/TaskRelationshipCard.tsx`
- `frontend/src/components/tasks/TaskRelationshipViewer.tsx`
- `frontend/src/components/ui/image-upload-section.tsx`
- `frontend/src/components/ui/tabs.tsx`
- `frontend/src/components/ui/title-description-editor.tsx`
- `frontend/src/lib/caretPosition.ts`
- `frontend/src/utils/markdownImages.ts`
- `remote-frontend/src/App.tsx`

## Removed Unused Dependencies (10 packages from frontend/package.json)
- `@dnd-kit/modifiers`
- `@lexical/utils`
- `@radix-ui/react-tabs`
- `@tailwindcss/typography`
- `@tanstack/react-devtools`
- `@tanstack/react-form-devtools`
- `@types/react-window`
- `react-use-websocket`
- `react-window`
- `zod`

## Removed Unused Exports
- `useTabNavigation` hook from `TabNavigationContext.tsx`

## Not Removed (false positives or intentional)
- `shared/types.ts` - auto-generated file used throughout the project
- `@rjsf/shadcn` - used in tailwind.config.js content paths
- `wa-sqlite` - used in vite.config.ts optimizeDeps
- `eslint-plugin-prettier`, `vite` (root) - kept per user preference
- Barrel re-exports in index.ts files (dialogs, hooks, ui) - intentional public APIs
- Type exports - part of public API contracts

* Done. Restored `remote-frontend/src/App.tsx` as a placeholder.

* Update pnpm lockfile after removing unused dependencies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Gabriel Gordon-Hall
2025-12-11 12:45:04 +00:00
committed by GitHub
parent b37672a475
commit 2366e4ad23
13 changed files with 1 additions and 1408 deletions

View File

@@ -20,7 +20,6 @@
"@codemirror/lint": "^6.8.5",
"@codemirror/view": "^6.38.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@ebay/nice-modal-react": "^1.2.13",
"@git-diff-view/file": "^0.0.30",
@@ -31,26 +30,20 @@
"@lexical/markdown": "^0.36.2",
"@lexical/react": "^0.36.2",
"@lexical/rich-text": "^0.36.2",
"@lexical/utils": "^0.36.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7",
"@rjsf/shadcn": "6.1.1",
"@sentry/react": "^9.34.0",
"@sentry/vite-plugin": "^3.5.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/electric-db-collection": "^0.2.6",
"@tanstack/react-db": "^0.1.50",
"@tanstack/react-devtools": "^0.8.0",
"@tanstack/react-form": "^1.23.8",
"@tanstack/react-form-devtools": "^0.1.8",
"@tanstack/react-query": "^5.85.5",
"@types/react-window": "^1.8.8",
"@uiw/react-codemirror": "^4.25.1",
"@virtuoso.dev/message-list": "^1.13.3",
"class-variance-authority": "^0.7.0",
@@ -72,16 +65,13 @@
"react-i18next": "^15.7.3",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.8.1",
"react-use-websocket": "^4.7.0",
"react-virtuoso": "^4.14.0",
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"simple-icons": "^15.16.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"vibe-kanban-web-companion": "^0.0.4",
"wa-sqlite": "^1.0.0",
"zod": "^4.1.12",
"zustand": "^4.5.4"
},
"devDependencies": {

View File

@@ -1,47 +0,0 @@
import { useOrganizationMembers } from '@/hooks/useOrganizationMembers';
import { UserAvatar } from '@/components/tasks/UserAvatar';
import { useTranslation } from 'react-i18next';
interface OrgMemberAvatarsProps {
limit?: number;
className?: string;
organizationId?: string;
}
export function OrgMemberAvatars({
limit = 5,
className = '',
organizationId,
}: OrgMemberAvatarsProps) {
const { t } = useTranslation('common');
const { data: members, isPending } = useOrganizationMembers(organizationId);
if (!organizationId || isPending || !members || members.length === 0) {
return null;
}
const displayMembers = members.slice(0, limit);
const remainingCount = members.length - limit;
return (
<div className={`flex items-center ${className}`}>
<div className="flex -space-x-2">
{displayMembers.map((member) => (
<UserAvatar
key={member.user_id}
firstName={member.first_name}
lastName={member.last_name}
username={member.username}
imageUrl={member.avatar_url}
className="h-6 w-6 ring-2 ring-background"
/>
))}
</div>
{remainingCount > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
{t('orgMembers.moreCount', { count: remainingCount })}
</span>
)}
</div>
);
}

View File

@@ -1,48 +0,0 @@
import React from 'react';
interface ResponsiveTwoPaneProps {
left: React.ReactNode;
right: React.ReactNode;
isRightOpen: boolean;
variant?: 'sidebar' | 'split';
}
export function ResponsiveTwoPane({
left,
right,
isRightOpen,
variant = 'sidebar',
}: ResponsiveTwoPaneProps) {
if (variant === 'split') {
return (
<div className="h-full min-h-0 grid grid-cols-2">
<div className="min-w-0 border-r overflow-auto">{left}</div>
<div className="min-w-0 overflow-auto">{right}</div>
</div>
);
}
return (
<div className="h-full min-h-0 overflow-hidden grid xl:grid-cols-[1fr_600px]">
<div className="min-w-0 min-h-0">{left}</div>
{isRightOpen && (
<div className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm xl:hidden" />
)}
<aside
className={[
'bg-background border-l min-h-0 min-w-0 flex flex-col overflow-hidden',
'xl:static xl:block xl:h-full',
isRightOpen
? 'fixed inset-y-0 right-0 left-auto w-full md:w-[600px] z-50 shadow-xl'
: 'hidden',
].join(' ')}
>
{right}
</aside>
</div>
);
}
export default ResponsiveTwoPane;

View File

@@ -1,126 +0,0 @@
import { memo, useEffect, useRef, useState } from 'react';
import { CheckCircle2, Clock, Loader2, Send, WifiOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'sent';
type Status = {
save: { state: SaveStatus; isSaving: boolean };
draft: { isLoaded: boolean; isSending: boolean };
queue: { isUnqueuing: boolean; isQueued: boolean };
};
type Props = { status: Status; pillBgClass?: string };
function FollowUpStatusRowImpl({ status, pillBgClass = 'bg-muted' }: Props) {
const { save, draft, queue } = status;
// Nonce keys to retrigger CSS animation; no JS timers.
const [savedNonce, setSavedNonce] = useState<number | null>(null);
const [sentNonce, setSentNonce] = useState<number | null>(null);
const prevIsSendingRef = useRef<boolean>(draft.isSending);
// Show "Draft saved" by bumping key to restart CSS animation
useEffect(() => {
if (save.state === 'saved') setSavedNonce(Date.now());
}, [save.state]);
// Show "Follow-up sent" on isSending rising edge
useEffect(() => {
const now = draft.isSending;
if (now && !prevIsSendingRef.current) {
setSentNonce(Date.now());
}
prevIsSendingRef.current = now;
}, [draft.isSending]);
return (
<div className="flex items-center justify-between text-xs min-h-6 h-6 px-0.5">
<div className="text-muted-foreground">
{save.state === 'saving' && save.isSaving ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
'italic',
pillBgClass
)}
>
<Loader2 className="animate-spin h-3 w-3" /> Saving
</span>
) : save.state === 'offline' ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-amber-700 animate-in fade-in-0',
pillBgClass
)}
>
<WifiOff className="h-3 w-3" /> Offline changes pending
</span>
) : sentNonce ? (
<span
key={sentNonce}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-emerald-700 animate-pill',
pillBgClass
)}
onAnimationEnd={() => setSentNonce(null)}
>
<Send className="h-3 w-3" /> Follow-up sent
</span>
) : savedNonce ? (
<span
key={savedNonce}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-emerald-700 animate-pill',
pillBgClass
)}
onAnimationEnd={() => setSavedNonce(null)}
>
<CheckCircle2 className="h-3 w-3" /> Draft saved
</span>
) : null}
</div>
<div className="text-muted-foreground">
{queue.isUnqueuing ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
pillBgClass
)}
>
<Loader2 className="animate-spin h-3 w-3" /> Unlocking
</span>
) : !draft.isLoaded ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
pillBgClass
)}
>
<Loader2 className="animate-spin h-3 w-3" /> Loading draft
</span>
) : draft.isSending ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
pillBgClass
)}
>
<Loader2 className="animate-spin h-3 w-3" /> Sending follow-up
</span>
) : queue.isQueued ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
pillBgClass
)}
>
<Clock className="h-3 w-3" /> Queued for next turn. Edits are
locked.
</span>
) : null}
</div>
</div>
);
}
export const FollowUpStatusRow = memo(FollowUpStatusRowImpl);

View File

@@ -1,106 +0,0 @@
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { Task } from 'shared/types';
interface TaskRelationshipCardProps {
task: Task;
isCurrentTask?: boolean;
onClick?: () => void;
className?: string;
}
export function TaskRelationshipCard({
task,
isCurrentTask = false,
onClick,
className,
}: TaskRelationshipCardProps) {
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'todo':
return 'secondary';
case 'inprogress':
return 'default';
case 'inreview':
return 'outline';
case 'done':
return 'default';
case 'cancelled':
return 'destructive';
default:
return 'secondary';
}
};
const truncateTitle = (title: string, maxLength: number = 50) => {
return title.length > maxLength
? `${title.substring(0, maxLength)}...`
: title;
};
const truncateDescription = (
description: string | null,
maxLength: number = 120
) => {
if (!description) return null;
return description.length > maxLength
? `${description.substring(0, maxLength)}...`
: description;
};
return (
<Card
className={cn(
'p-4 transition-all duration-200 cursor-pointer hover:shadow-md border',
'min-h-[100px] w-full', // More spacious and responsive
isCurrentTask && 'bg-accent/10 border-accent ring-1 ring-accent/50',
!isCurrentTask && 'hover:bg-accent/5',
onClick && 'cursor-pointer',
!onClick && 'cursor-default',
className
)}
onClick={onClick}
>
<div className="flex flex-col space-y-3">
{/* Title and Status Row */}
<div className="flex items-start justify-between gap-3">
<h4
className="font-medium text-sm leading-relaxed flex-1 min-w-0"
title={task.title}
>
{truncateTitle(task.title)}
</h4>
<div className="flex items-center space-x-1 shrink-0">
<Badge
variant={getStatusBadgeVariant(task.status)}
className="text-xs px-2 py-1 h-auto"
>
{task.status}
</Badge>
</div>
</div>
{/* Description */}
{task.description && (
<p
className="text-xs text-muted-foreground leading-relaxed"
title={task.description}
>
{truncateDescription(task.description)}
</p>
)}
{/* Current task indicator */}
{isCurrentTask && (
<div className="flex items-center gap-2 pt-1">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
<span className="text-xs text-primary font-medium">
Current Task
</span>
</div>
)}
</div>
</Card>
);
}

View File

@@ -1,169 +0,0 @@
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { TaskRelationshipCard } from './TaskRelationshipCard';
import { attemptsApi } from '@/lib/api';
import type {
TaskAttempt,
TaskRelationships,
TaskWithAttemptStatus,
} from 'shared/types';
import { ChevronDown, ChevronRight } from 'lucide-react';
interface TaskRelationshipViewerProps {
selectedAttempt: TaskAttempt | null;
onNavigateToTask?: (taskId: string) => void;
task?: TaskWithAttemptStatus | null;
tasksById?: Record<string, TaskWithAttemptStatus>;
}
export function TaskRelationshipViewer({
selectedAttempt,
onNavigateToTask,
task,
tasksById,
}: TaskRelationshipViewerProps) {
const [relationships, setRelationships] = useState<TaskRelationships | null>(
null
);
const [parentTask, setParentTask] = useState<TaskWithAttemptStatus | null>(
null
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [childrenExpanded, setChildrenExpanded] = useState(true);
// Effect for attempt-based relationships (existing behavior)
useEffect(() => {
if (!selectedAttempt?.id) {
setRelationships(null);
return;
}
const fetchRelationships = async () => {
setLoading(true);
setError(null);
try {
const relationshipData = await attemptsApi.getChildren(
selectedAttempt.id
);
setRelationships(relationshipData);
} catch (err) {
console.error('Failed to fetch task relationships:', err);
setError('Failed to load task relationships');
} finally {
setLoading(false);
}
};
fetchRelationships();
}, [selectedAttempt?.id]);
// Effect for parent task when child has no attempts (one request + tasksById lookup)
useEffect(() => {
if (selectedAttempt?.id) {
// If we have an attempt, clear parent task since relationships will handle it
setParentTask(null);
return;
}
if (task?.parent_task_attempt && tasksById) {
attemptsApi
.get(task.parent_task_attempt)
.then((parentAttempt) => {
// Use existing tasksById instead of second API call
const parentTaskData = tasksById[parentAttempt.task_id];
setParentTask(parentTaskData || null);
})
.catch(() => setParentTask(null));
} else {
setParentTask(null);
}
}, [selectedAttempt?.id, task?.parent_task_attempt, tasksById]);
const displayParentTask = relationships?.parent_task || parentTask;
const childTasks = relationships?.children || [];
const hasParent = displayParentTask !== null;
const hasChildren = childTasks.length > 0;
// Don't render if no relationships and no current task
if (!hasParent && !hasChildren && !loading && !error) {
return null;
}
return (
<div>
<Card className="bg-background p-3 border border-dashed text-sm">
Task Relationships
</Card>
<div className="p-3 space-y-6">
{loading ? (
<div className="text-sm text-muted-foreground py-8 text-center">
Loading relationships...
</div>
) : error ? (
<div className="text-sm text-destructive py-8 text-center">
{error}
</div>
) : (
<div className="space-y-6">
{/* Parent Task Section */}
{hasParent && displayParentTask && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Parent Task
</h4>
<div className="flex-1 h-px bg-border"></div>
</div>
<div className="flex justify-center">
<div className="w-full max-w-md">
<TaskRelationshipCard
task={displayParentTask}
isCurrentTask={false}
onClick={() => onNavigateToTask?.(displayParentTask.id)}
className="shadow-sm"
/>
</div>
</div>
</div>
)}
{/* Child Tasks Section */}
{hasChildren && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button
onClick={() => setChildrenExpanded(!childrenExpanded)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors"
>
{childrenExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
Child Tasks ({childTasks.length})
</button>
<div className="flex-1 h-px bg-border"></div>
</div>
{childrenExpanded && (
<div className="flex flex-col gap-4">
{childTasks.map((childTask) => (
<TaskRelationshipCard
key={childTask.id}
task={childTask}
isCurrentTask={false}
onClick={() => onNavigateToTask?.(childTask.id)}
className="shadow-sm hover:shadow-md transition-shadow"
/>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,352 +0,0 @@
import {
useState,
useCallback,
useRef,
useImperativeHandle,
useEffect,
forwardRef,
} from 'react';
import {
X,
Image as ImageIcon,
Upload,
ChevronRight,
AlertCircle,
} from 'lucide-react';
import { Button } from './button';
import { Alert, AlertDescription } from './alert';
import { cn, formatFileSize } from '@/lib/utils';
import { imagesApi } from '@/lib/api';
import type { ImageResponse } from 'shared/types';
interface ImageUploadSectionProps {
images: ImageResponse[];
onImagesChange: (images: ImageResponse[]) => void;
onUpload: (file: File) => Promise<ImageResponse>;
onDelete?: (imageId: string) => Promise<void>;
onImageUploaded?: (image: ImageResponse) => void; // Custom callback for upload success
isUploading?: boolean;
disabled?: boolean;
readOnly?: boolean;
collapsible?: boolean;
defaultExpanded?: boolean;
hideDropZone?: boolean; // Hide the drag and drop area
className?: string;
}
export interface ImageUploadSectionHandle {
addFiles: (files: FileList | File[] | null) => Promise<void>;
}
const MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const VALID_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
];
export const ImageUploadSection = forwardRef<
ImageUploadSectionHandle,
ImageUploadSectionProps
>(
(
{
images,
onImagesChange,
onUpload,
onDelete,
onImageUploaded,
isUploading = false,
disabled = false,
readOnly = false,
collapsible = true,
defaultExpanded = false,
hideDropZone = false,
className,
},
ref
) => {
const [isExpanded, setIsExpanded] = useState(
defaultExpanded || images.length > 0
);
const [isDragging, setIsDragging] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(
new Set()
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const latestImagesRef = useRef(images);
useEffect(() => {
latestImagesRef.current = images;
}, [images]);
useEffect(() => {
if (collapsible && images.length > 0 && !isExpanded) {
setIsExpanded(true);
}
}, [collapsible, images.length, isExpanded]);
const handleFiles = useCallback(
async (filesInput: FileList | File[] | null) => {
if (!filesInput || disabled || readOnly) return;
const files = Array.isArray(filesInput)
? filesInput
: Array.from(filesInput);
setErrorMessage(null);
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
const validFiles: File[] = [];
files.forEach((file) => {
if (!VALID_TYPES.includes(file.type.toLowerCase())) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_SIZE_BYTES) {
oversizedFiles.push(
`${file.name} (${(file.size / 1048576).toFixed(1)} MB)`
);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0 || oversizedFiles.length > 0) {
const errors: string[] = [];
if (invalidFiles.length > 0) {
errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`);
}
if (oversizedFiles.length > 0) {
errors.push(
`Files too large (max 20 MB): ${oversizedFiles.join(', ')}`
);
}
setErrorMessage(errors.join('. '));
}
for (const file of validFiles) {
const tempId = `uploading-${Date.now()}-${file.name}`;
setUploadingFiles((prev) => new Set(prev).add(tempId));
try {
const uploadedImage = await onUpload(file);
// Call custom upload callback if provided, otherwise use default behavior
if (onImageUploaded) {
onImageUploaded(uploadedImage);
} else {
const nextImages = [...latestImagesRef.current, uploadedImage];
latestImagesRef.current = nextImages;
onImagesChange(nextImages);
}
setErrorMessage(null);
} catch (error: unknown) {
console.error('Failed to upload image:', error);
const message =
error instanceof Error
? error.message
: 'Failed to upload image. Please try again.';
setErrorMessage(message);
} finally {
setUploadingFiles((prev) => {
const next = new Set(prev);
next.delete(tempId);
return next;
});
}
}
},
[disabled, readOnly, onUpload, onImageUploaded, onImagesChange]
);
useImperativeHandle(
ref,
() => ({
addFiles: async (files: FileList | File[] | null) => {
await handleFiles(files);
},
}),
[handleFiles]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
void handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleRemoveImage = useCallback(
async (imageId: string) => {
if (onDelete) {
try {
await onDelete(imageId);
} catch (error) {
console.error('Failed to delete image:', error);
}
}
onImagesChange(images.filter((img) => img.id !== imageId));
},
[images, onImagesChange, onDelete]
);
const content = (
<div className={cn('space-y-3', className)}>
{/* Error message */}
{errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{/* Read-only message */}
{readOnly && images.length === 0 && (
<p className="text-sm text-muted-foreground">No images attached</p>
)}
{/* Drop zone - only show when not read-only and not hidden */}
{!readOnly && !hideDropZone && (
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">
Drag and drop images here, or click to select
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || isUploading}
>
Select Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
}}
disabled={disabled}
/>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group border rounded-lg p-2 bg-background"
>
<div className="flex items-center gap-2">
<img
src={imagesApi.getImageUrl(image.id)}
alt={image.original_name}
className="h-16 w-16 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{image.original_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size_bytes)}
</p>
</div>
</div>
{!disabled && !readOnly && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveImage(image.id)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{/* Uploading indicators */}
{uploadingFiles.size > 0 && (
<div className="space-y-1">
{Array.from(uploadingFiles).map((tempId) => (
<div
key={tempId}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Uploading...</span>
</div>
))}
</div>
)}
</div>
);
if (!collapsible) {
return content;
}
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn(
'h-3 w-3 transition-transform',
isExpanded && 'rotate-90'
)}
/>
<ImageIcon className="h-4 w-4" />
<span>Images {images.length > 0 && `(${images.length})`}</span>
</button>
{isExpanded && content}
</div>
);
}
);
ImageUploadSection.displayName = 'ImageUploadSection';

View File

@@ -1,53 +0,0 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,37 +0,0 @@
import { Input } from './input';
import WYSIWYGEditor from './wysiwyg';
type Props = {
title: string;
description: string | null | undefined;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
projectId?: string;
};
const TitleDescriptionEditor = ({
title,
description,
onTitleChange,
onDescriptionChange,
projectId,
}: Props) => {
return (
<div className="space-y-3 flex-1">
<Input
className="text-2xl h-auto border-0 p-0"
placeholder="Title*"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
/>
<WYSIWYGEditor
placeholder="Description"
value={description ?? ''}
onChange={onDescriptionChange}
projectId={projectId}
/>
</div>
);
};
export default TitleDescriptionEditor;

View File

@@ -1,4 +1,4 @@
import { createContext, useContext } from 'react';
import { createContext } from 'react';
import type { TabType } from '@/types/tabs';
interface TabNavContextType {
@@ -7,11 +7,3 @@ interface TabNavContextType {
}
export const TabNavContext = createContext<TabNavContextType | null>(null);
export const useTabNavigation = () => {
const context = useContext(TabNavContext);
if (!context) {
throw new Error('useTabNavigation must be used within TabNavContext');
}
return context;
};

View File

@@ -1,73 +0,0 @@
const CARET_PROBE_CHARACTER = '\u200b';
const mirrorStyleProperties = [
'boxSizing',
'fontFamily',
'fontSize',
'fontStyle',
'fontWeight',
'letterSpacing',
'lineHeight',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'textAlign',
'textTransform',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderTopStyle',
'borderRightStyle',
'borderBottomStyle',
'borderLeftStyle',
] as const;
type MirrorStyleProperty = (typeof mirrorStyleProperties)[number];
export const getCaretClientRect = (
textarea: HTMLTextAreaElement,
targetIndex?: number
) => {
if (typeof window === 'undefined') return null;
const selectionIndex =
typeof targetIndex === 'number'
? Math.min(Math.max(targetIndex, 0), textarea.value.length)
: (textarea.selectionEnd ?? textarea.value.length);
const textBeforeCaret = textarea.value.slice(0, selectionIndex);
const textareaRect = textarea.getBoundingClientRect();
const computedStyle = window.getComputedStyle(textarea);
const mirror = document.createElement('div');
mirror.setAttribute('data-caret-mirror', 'true');
mirror.style.position = 'absolute';
mirror.style.top = `${textareaRect.top + window.scrollY}px`;
mirror.style.left = `${textareaRect.left + window.scrollX}px`;
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordBreak = 'break-word';
mirror.style.overflow = 'hidden';
mirror.style.width = `${textareaRect.width}px`;
mirrorStyleProperties.forEach((property: MirrorStyleProperty) => {
const value = computedStyle[property];
if (value) {
mirror.style[property] = value;
}
});
mirror.textContent = textBeforeCaret;
const probe = document.createElement('span');
probe.textContent = CARET_PROBE_CHARACTER;
mirror.appendChild(probe);
document.body.appendChild(mirror);
const caretRect = probe.getBoundingClientRect();
document.body.removeChild(mirror);
return caretRect;
};

View File

@@ -1,15 +0,0 @@
import type { ImageResponse } from 'shared/types';
export function imageToMarkdown(image: ImageResponse): string {
return `![${image.original_name}](${image.file_path})`;
}
export function appendImageMarkdown(
prev: string,
image: ImageResponse
): string {
const markdownText = imageToMarkdown(image);
if (prev.trim() === '') return markdownText + '\n';
const needsNewline = !prev.endsWith('\n');
return prev + (needsNewline ? '\n' : '') + markdownText + '\n';
}