mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
100
web/components/mobile/BottomSheet.tsx
Normal file
100
web/components/mobile/BottomSheet.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface BottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style bottom sheet.
|
||||
* - Slides up from the bottom of the screen
|
||||
* - Swipe down on the drag handle or sheet body to close
|
||||
* - Tap backdrop to close
|
||||
* - Locks body scroll while open
|
||||
*/
|
||||
export function BottomSheet({ isOpen, onClose, title, children, className }: BottomSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose });
|
||||
|
||||
// Lock body scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/60",
|
||||
"transition-opacity duration-300",
|
||||
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ?? "Options"}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50",
|
||||
"bg-surface-900 border-t border-surface-800 rounded-t-2xl",
|
||||
"max-h-[85dvh] flex flex-col",
|
||||
"transition-transform duration-300 ease-out",
|
||||
isOpen ? "translate-y-0" : "translate-y-full",
|
||||
className
|
||||
)}
|
||||
{...swipeHandlers}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1 flex-shrink-0 cursor-grab active:cursor-grabbing">
|
||||
<div className="w-10 h-1 bg-surface-600 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Optional title */}
|
||||
{title && (
|
||||
<div className="px-4 pb-2 flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold text-surface-100 text-center">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* iOS safe area bottom padding */}
|
||||
<div className="pb-safe flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
web/components/mobile/MobileFileViewer.tsx
Normal file
107
web/components/mobile/MobileFileViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface MobileFileViewerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileName?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen file viewer overlay for mobile.
|
||||
* - Slides up from the bottom on open
|
||||
* - Swipe down to close
|
||||
* - Back/close button in header
|
||||
* - Content area is scrollable with pinch-to-zoom enabled on images
|
||||
*/
|
||||
export function MobileFileViewer({
|
||||
isOpen,
|
||||
onClose,
|
||||
fileName,
|
||||
children,
|
||||
className,
|
||||
}: MobileFileViewerProps) {
|
||||
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose, threshold: 80 });
|
||||
|
||||
// Lock body scroll and close on Escape
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-surface-950 flex flex-col",
|
||||
"transition-transform duration-300 ease-out",
|
||||
isOpen ? "translate-y-0" : "translate-y-full",
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={fileName ?? "File viewer"}
|
||||
>
|
||||
{/* Header — swipe-down handle zone */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm h-[52px] flex-shrink-0"
|
||||
{...swipeHandlers}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-600 rounded-full" />
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Close file viewer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="flex-1 text-sm font-medium text-surface-100 truncate">{fileName}</span>
|
||||
|
||||
<button
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Download file"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File content — pinch-to-zoom enabled via touch-action */}
|
||||
<div
|
||||
className="flex-1 overflow-auto overscroll-contain"
|
||||
style={{ touchAction: "pan-x pan-y pinch-zoom" }}
|
||||
>
|
||||
{children ?? (
|
||||
<div className="flex items-center justify-center h-full text-surface-500 text-sm">
|
||||
No file selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* iOS safe area inset */}
|
||||
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} className="flex-shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
web/components/mobile/MobileHeader.tsx
Normal file
59
web/components/mobile/MobileHeader.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, ChevronLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
title?: string;
|
||||
onMenuOpen: () => void;
|
||||
onBack?: () => void;
|
||||
right?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact top bar for mobile: hamburger (or back) on the left, title in centre, optional actions on the right.
|
||||
* Tap targets are at least 44×44 px per WCAG / Apple HIG guidelines.
|
||||
*/
|
||||
export function MobileHeader({
|
||||
title = "Chat",
|
||||
onMenuOpen,
|
||||
onBack,
|
||||
right,
|
||||
className,
|
||||
}: MobileHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm",
|
||||
"h-[52px] flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left action — back or hamburger */}
|
||||
{onBack ? (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onMenuOpen}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="flex-1 text-sm font-medium text-surface-100 truncate">{title}</h1>
|
||||
|
||||
{/* Right actions */}
|
||||
{right && <div className="flex items-center">{right}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
26
web/components/mobile/MobileInput.tsx
Normal file
26
web/components/mobile/MobileInput.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ChatInput } from "@/components/chat/ChatInput";
|
||||
|
||||
interface MobileInputProps {
|
||||
conversationId: string;
|
||||
/** Height of the software keyboard in px — shifts input above it */
|
||||
keyboardHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-optimised chat input wrapper.
|
||||
* Uses a paddingBottom equal to the keyboard height so the input floats
|
||||
* above the virtual keyboard without relying on position:fixed (which
|
||||
* breaks on iOS Safari when the keyboard is open).
|
||||
*/
|
||||
export function MobileInput({ conversationId, keyboardHeight }: MobileInputProps) {
|
||||
return (
|
||||
<div
|
||||
style={{ paddingBottom: keyboardHeight }}
|
||||
className="transition-[padding] duration-100"
|
||||
>
|
||||
<ChatInput conversationId={conversationId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
web/components/mobile/MobileSidebar.tsx
Normal file
75
web/components/mobile/MobileSidebar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in drawer sidebar for mobile / tablet.
|
||||
* - Opens from the left as an overlay
|
||||
* - Swipe left or tap backdrop to close
|
||||
* - Traps focus while open and restores on close
|
||||
* - Locks body scroll while open
|
||||
*/
|
||||
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
// Swipe left on the drawer to close
|
||||
const swipeHandlers = useTouchGesture({ onSwipeLeft: onClose });
|
||||
|
||||
// Lock body scroll while drawer is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/60 lg:hidden",
|
||||
"transition-opacity duration-300",
|
||||
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 left-0 bottom-0 z-50 w-72 lg:hidden",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
{...swipeHandlers}
|
||||
>
|
||||
<Sidebar onNavigate={onClose} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
web/components/mobile/SwipeableRow.tsx
Normal file
152
web/components/mobile/SwipeableRow.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SwipeAction {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
leftActions?: SwipeAction[];
|
||||
rightActions?: SwipeAction[];
|
||||
className?: string;
|
||||
/** Width of each action button in px (default 72) */
|
||||
actionWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row that reveals swipe actions when the user drags left (right-actions)
|
||||
* or right (left-actions). Used in the sidebar conversation list for
|
||||
* one-swipe delete.
|
||||
*/
|
||||
export function SwipeableRow({
|
||||
children,
|
||||
leftActions = [],
|
||||
rightActions = [],
|
||||
className,
|
||||
actionWidth = 72,
|
||||
}: SwipeableRowProps) {
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startXRef = useRef<number | null>(null);
|
||||
const currentXRef = useRef(0);
|
||||
|
||||
const maxLeft = leftActions.length * actionWidth;
|
||||
const maxRight = rightActions.length * actionWidth;
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
startXRef.current = e.touches[0].clientX;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (startXRef.current === null) return;
|
||||
const dx = e.touches[0].clientX - startXRef.current + currentXRef.current;
|
||||
const clamped = Math.max(-maxRight, Math.min(maxLeft, dx));
|
||||
setTranslateX(clamped);
|
||||
},
|
||||
[maxLeft, maxRight]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
startXRef.current = null;
|
||||
|
||||
// Snap: if dragged > half an action width, show actions; otherwise reset
|
||||
if (translateX < -(actionWidth / 2) && maxRight > 0) {
|
||||
const snapped = -maxRight;
|
||||
setTranslateX(snapped);
|
||||
currentXRef.current = snapped;
|
||||
} else if (translateX > actionWidth / 2 && maxLeft > 0) {
|
||||
const snapped = maxLeft;
|
||||
setTranslateX(snapped);
|
||||
currentXRef.current = snapped;
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
currentXRef.current = 0;
|
||||
}
|
||||
}, [translateX, actionWidth, maxLeft, maxRight]);
|
||||
|
||||
const resetPosition = useCallback(() => {
|
||||
setTranslateX(0);
|
||||
currentXRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
{/* Left action buttons (revealed on swipe-right) */}
|
||||
{leftActions.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 flex"
|
||||
style={{ width: maxLeft }}
|
||||
>
|
||||
{leftActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
resetPosition();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
|
||||
"bg-brand-600 text-white",
|
||||
action.className
|
||||
)}
|
||||
style={{ width: actionWidth }}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right action buttons (revealed on swipe-left) */}
|
||||
{rightActions.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 flex"
|
||||
style={{ width: maxRight }}
|
||||
>
|
||||
{rightActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
resetPosition();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
|
||||
"bg-red-600 text-white",
|
||||
action.className
|
||||
)}
|
||||
style={{ width: actionWidth }}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content row */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 bg-surface-900",
|
||||
!isDragging && "transition-transform duration-200"
|
||||
)}
|
||||
style={{ transform: `translateX(${translateX}px)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user