feat(ui): add responsive layout support for mobile and small screens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,8 @@ import {
|
|||||||
import type { AgentType } from "@/lib/types"
|
import type { AgentType } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
|
||||||
function FolderDocumentTitle() {
|
function FolderDocumentTitle() {
|
||||||
const { folder } = useFolderContext()
|
const { folder } = useFolderContext()
|
||||||
@@ -246,6 +248,90 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileWorkspaceContent({ children }: { children: React.ReactNode }) {
|
||||||
|
const { mode } = useWorkspaceContext()
|
||||||
|
|
||||||
|
// On mobile, fusion mode falls back to conversation view
|
||||||
|
const showConversation = mode === "conversation" || mode === "fusion"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full min-h-0 overflow-hidden">
|
||||||
|
{showConversation ? (
|
||||||
|
<section className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<TabBar />
|
||||||
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<FileWorkspaceTabBar />
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<FileWorkspacePanel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileFolderWorkspaceShell({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { isOpen: sidebarOpen, toggle: toggleSidebar } = useSidebarContext()
|
||||||
|
const { isOpen: auxOpen, toggle: toggleAux } = useAuxPanelContext()
|
||||||
|
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
{/* Sidebar Sheet (left) */}
|
||||||
|
<Sheet open={sidebarOpen} onOpenChange={toggleSidebar}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="w-[85%] max-w-[360px] p-0"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">Sidebar</SheetTitle>
|
||||||
|
<Sidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Main workspace */}
|
||||||
|
<main className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
|
<MobileWorkspaceContent>{children}</MobileWorkspaceContent>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Aux panel Sheet (right) */}
|
||||||
|
<Sheet open={auxOpen} onOpenChange={toggleAux}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="w-[85%] max-w-[360px] p-0"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">Panel</SheetTitle>
|
||||||
|
<AuxPanel />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Terminal Sheet (bottom) */}
|
||||||
|
<Sheet open={terminalOpen} onOpenChange={toggleTerminal}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!h-[70vh] p-0"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">Terminal</SheetTitle>
|
||||||
|
<div className="h-full min-h-0 overflow-hidden">
|
||||||
|
<TerminalPanel />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FolderWorkspaceShell({ children }: { children: React.ReactNode }) {
|
function FolderWorkspaceShell({ children }: { children: React.ReactNode }) {
|
||||||
const {
|
const {
|
||||||
isOpen: sidebarOpen,
|
isOpen: sidebarOpen,
|
||||||
@@ -661,6 +747,27 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FolderLayoutShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col overflow-hidden">
|
||||||
|
<FolderTitleBar />
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileFolderWorkspaceShell>{children}</MobileFolderWorkspaceShell>
|
||||||
|
) : (
|
||||||
|
<FolderWorkspaceShell>{children}</FolderWorkspaceShell>
|
||||||
|
)}
|
||||||
|
<StatusBar />
|
||||||
|
<AppToaster
|
||||||
|
position="bottom-right"
|
||||||
|
duration={TOAST_DURATION_MS}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const folderId = Number(searchParams.get("id") ?? "0")
|
const folderId = Number(searchParams.get("id") ?? "0")
|
||||||
@@ -693,18 +800,7 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
folderId={normalizedFolderId}
|
folderId={normalizedFolderId}
|
||||||
>
|
>
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
<div className="flex h-screen flex-col overflow-hidden">
|
<FolderLayoutShell>{children}</FolderLayoutShell>
|
||||||
<FolderTitleBar />
|
|
||||||
<FolderWorkspaceShell>
|
|
||||||
{children}
|
|
||||||
</FolderWorkspaceShell>
|
|
||||||
<StatusBar />
|
|
||||||
<AppToaster
|
|
||||||
position="bottom-right"
|
|
||||||
duration={TOAST_DURATION_MS}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TerminalProvider>
|
</TerminalProvider>
|
||||||
</AuxPanelProvider>
|
</AuxPanelProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -167,6 +167,10 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import { JetBrains_Mono } from "next/font/google"
|
import { JetBrains_Mono } from "next/font/google"
|
||||||
import { NextIntlClientProvider } from "next-intl"
|
import { NextIntlClientProvider } from "next-intl"
|
||||||
@@ -13,6 +13,12 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "codeg",
|
title: "codeg",
|
||||||
description: "AI Coding Agent Conversation Manager",
|
description: "AI Coding Agent Conversation Manager",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder="Access Token"
|
placeholder="Access Token"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1493,7 +1493,7 @@ export function MessageInput({
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
placeholder={resolvedPlaceholder}
|
placeholder={resolvedPlaceholder}
|
||||||
className="min-h-0 flex-1 overflow-y-auto rounded-none border-0 bg-transparent text-sm shadow-none focus-visible:border-0 focus-visible:ring-0"
|
className="min-h-0 flex-1 overflow-y-auto rounded-none border-0 bg-transparent text-base md:text-sm shadow-none focus-visible:border-0 focus-visible:ring-0"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
<div className="@container flex shrink-0 items-end justify-between gap-2 px-2 pb-2">
|
<div className="@container flex shrink-0 items-end justify-between gap-2 px-2 pb-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { usePlatform } from "@/hooks/use-platform"
|
import { usePlatform } from "@/hooks/use-platform"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { WindowControls } from "./window-controls"
|
import { WindowControls } from "./window-controls"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function AppTitleBar({
|
|||||||
showWindowControls = true,
|
showWindowControls = true,
|
||||||
}: AppTitleBarProps) {
|
}: AppTitleBarProps) {
|
||||||
const { isMac, isWindows } = usePlatform()
|
const { isMac, isWindows } = usePlatform()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const isDesktopRuntime =
|
const isDesktopRuntime =
|
||||||
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
|
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
|
||||||
const hasDesktopWindowChrome = showWindowControls && isDesktopRuntime
|
const hasDesktopWindowChrome = showWindowControls && isDesktopRuntime
|
||||||
@@ -38,7 +40,8 @@ export function AppTitleBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-8 shrink-0 border-b bg-muted/70 select-none",
|
"relative shrink-0 border-b bg-muted/70 select-none",
|
||||||
|
isMobile ? "h-11" : "h-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import {
|
import {
|
||||||
Columns2,
|
Columns2,
|
||||||
|
EllipsisVertical,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
@@ -40,6 +42,13 @@ import { CommandDropdown } from "./command-dropdown"
|
|||||||
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
||||||
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
const MODE_TABS = [
|
const MODE_TABS = [
|
||||||
{
|
{
|
||||||
@@ -196,6 +205,7 @@ export function FolderTitleBar() {
|
|||||||
setBranch(null)
|
setBranch(null)
|
||||||
}
|
}
|
||||||
}, [folderPath])
|
}, [folderPath])
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const modeContainerRef = useRef<HTMLDivElement>(null)
|
const modeContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
const [modeIndicator, setModeIndicator] = useState<{
|
const [modeIndicator, setModeIndicator] = useState<{
|
||||||
@@ -243,168 +253,234 @@ export function FolderTitleBar() {
|
|||||||
[setMode]
|
[setMode]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const modeTabsElement = (
|
||||||
|
<div
|
||||||
|
ref={modeContainerRef}
|
||||||
|
role="tablist"
|
||||||
|
aria-label={tModes("workspaceModesAria")}
|
||||||
|
className="relative inline-flex h-[27px] items-center rounded-full border border-border/50 bg-muted/50 p-0.5"
|
||||||
|
>
|
||||||
|
{modeIndicator && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0.5 bottom-0.5 rounded-full bg-background shadow-sm ring-1 ring-border/50 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
|
style={{
|
||||||
|
left: modeIndicator.left,
|
||||||
|
width: modeIndicator.width,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{MODE_TABS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = mode === item.mode
|
||||||
|
const title = tModes(item.titleKey)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.mode}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
modeItemRefs.current.set(item.mode, el)
|
||||||
|
} else {
|
||||||
|
modeItemRefs.current.delete(item.mode)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 m-0 flex h-[23px] cursor-pointer select-none items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 align-middle text-xs font-medium leading-none transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
||||||
|
isActive ? "px-2.5" : "px-2",
|
||||||
|
isActive
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
|
)}
|
||||||
|
onClick={() => setMode(item.mode)}
|
||||||
|
onKeyDown={(event) => handleModeKeyDown(event, item.mode)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
title={!isActive ? title : undefined}
|
||||||
|
aria-label={title}
|
||||||
|
aria-selected={isActive}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="block h-3 w-3 shrink-0"
|
||||||
|
shapeRendering="geometricPrecision"
|
||||||
|
/>
|
||||||
|
{/* Hide text labels on mobile to save space */}
|
||||||
|
{!isMobile && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid transition-[grid-template-columns] duration-300",
|
||||||
|
isActive ? "grid-cols-[1fr]" : "grid-cols-[0fr]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden whitespace-nowrap transition-opacity duration-300",
|
||||||
|
isActive ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppTitleBar
|
<AppTitleBar
|
||||||
centerInteractive
|
centerInteractive
|
||||||
left={
|
left={
|
||||||
<div className="flex min-w-0 items-center gap-4">
|
isMobile ? (
|
||||||
<FolderNameDropdown />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<BranchDropdown
|
|
||||||
branch={branch}
|
|
||||||
parentBranch={folder?.parent_branch ?? null}
|
|
||||||
onBranchChange={refreshBranch}
|
|
||||||
/>
|
|
||||||
<div data-tauri-drag-region className="h-8 flex-1" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
center={
|
|
||||||
<div
|
|
||||||
ref={modeContainerRef}
|
|
||||||
role="tablist"
|
|
||||||
aria-label={tModes("workspaceModesAria")}
|
|
||||||
className="relative inline-flex h-[27px] items-center rounded-full border border-border/50 bg-muted/50 p-0.5"
|
|
||||||
>
|
|
||||||
{modeIndicator && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute top-0.5 bottom-0.5 rounded-full bg-background shadow-sm ring-1 ring-border/50 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
||||||
style={{
|
|
||||||
left: modeIndicator.left,
|
|
||||||
width: modeIndicator.width,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{MODE_TABS.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const isActive = mode === item.mode
|
|
||||||
const title = tModes(item.titleKey)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.mode}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
modeItemRefs.current.set(item.mode, el)
|
|
||||||
} else {
|
|
||||||
modeItemRefs.current.delete(item.mode)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="tab"
|
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 m-0 flex h-[23px] cursor-pointer select-none items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 align-middle text-xs font-medium leading-none transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
||||||
isActive ? "px-2.5" : "px-2",
|
|
||||||
isActive
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground/70"
|
|
||||||
)}
|
|
||||||
onClick={() => setMode(item.mode)}
|
|
||||||
onKeyDown={(event) => handleModeKeyDown(event, item.mode)}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
title={!isActive ? title : undefined}
|
|
||||||
aria-label={title}
|
|
||||||
aria-selected={isActive}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="block h-3 w-3 shrink-0"
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid transition-[grid-template-columns] duration-300",
|
|
||||||
isActive ? "grid-cols-[1fr]" : "grid-cols-[0fr]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 overflow-hidden whitespace-nowrap transition-opacity duration-300",
|
|
||||||
isActive ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
<div className="flex items-center gap-10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CommandDropdown />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 hover:text-foreground/80"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar(isOpen ? "hideSidebar" : "showSidebar"),
|
|
||||||
shortcut: formatShortcutLabel(
|
|
||||||
shortcuts.toggle_sidebar,
|
|
||||||
isMac
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<PanelLeft className="h-3.5 w-3.5" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
|
|
||||||
onClick={toggleAuxPanel}
|
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar("toggleAuxPanel"),
|
|
||||||
shortcut: formatShortcutLabel(
|
|
||||||
shortcuts.toggle_aux_panel,
|
|
||||||
isMac
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<PanelRight className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
|
||||||
onClick={() => toggleTerminal()}
|
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar("toggleTerminal"),
|
|
||||||
shortcut: formatShortcutLabel(
|
|
||||||
shortcuts.toggle_terminal,
|
|
||||||
isMac
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<SquareTerminal className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 hover:text-foreground/80"
|
|
||||||
onClick={() => setSearchOpen(true)}
|
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar("search"),
|
|
||||||
shortcut: formatShortcutLabel(shortcuts.toggle_search, isMac),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Search className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 hover:text-foreground/80"
|
|
||||||
onClick={handleOpenSettings}
|
|
||||||
title={tTitleBar("withShortcut", {
|
|
||||||
label: tTitleBar("openSettings"),
|
|
||||||
shortcut: formatShortcutLabel(shortcuts.open_settings, isMac),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<FolderNameDropdown />
|
||||||
|
<BranchDropdown
|
||||||
|
branch={branch}
|
||||||
|
parentBranch={folder?.parent_branch ?? null}
|
||||||
|
onBranchChange={refreshBranch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<FolderNameDropdown />
|
||||||
|
<BranchDropdown
|
||||||
|
branch={branch}
|
||||||
|
parentBranch={folder?.parent_branch ?? null}
|
||||||
|
onBranchChange={refreshBranch}
|
||||||
|
/>
|
||||||
|
<div data-tauri-drag-region className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
center={isMobile ? undefined : modeTabsElement}
|
||||||
|
right={
|
||||||
|
isMobile ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CommandDropdown />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
title={tTitleBar("search")}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<EllipsisVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={toggleAuxPanel}>
|
||||||
|
<PanelRight className="h-3.5 w-3.5" />
|
||||||
|
{tTitleBar("toggleAuxPanel")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => toggleTerminal()}>
|
||||||
|
<SquareTerminal className="h-3.5 w-3.5" />
|
||||||
|
{tTitleBar("toggleTerminal")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleOpenSettings}>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
{tTitleBar("openSettings")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CommandDropdown />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 hover:text-foreground/80"
|
||||||
|
onClick={toggle}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar(isOpen ? "hideSidebar" : "showSidebar"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.toggle_sidebar,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
|
||||||
|
onClick={toggleAuxPanel}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar("toggleAuxPanel"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.toggle_aux_panel,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||||
|
onClick={() => toggleTerminal()}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar("toggleTerminal"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.toggle_terminal,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SquareTerminal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 hover:text-foreground/80"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar("search"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.toggle_search,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 hover:text-foreground/80"
|
||||||
|
onClick={handleOpenSettings}
|
||||||
|
title={tTitleBar("withShortcut", {
|
||||||
|
label: tTitleBar("openSettings"),
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
shortcuts.open_settings,
|
||||||
|
isMac
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
type SidebarConversationListHandle,
|
type SidebarConversationListHandle,
|
||||||
} from "@/components/conversations/sidebar-conversation-list"
|
} from "@/components/conversations/sidebar-conversation-list"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const t = useTranslations("Folder.sidebar")
|
const t = useTranslations("Folder.sidebar")
|
||||||
const { folder } = useFolderContext()
|
const { folder } = useFolderContext()
|
||||||
const { openNewConversationTab } = useTabContext()
|
const { openNewConversationTab } = useTabContext()
|
||||||
const { isOpen } = useSidebarContext()
|
const { isOpen, toggle } = useSidebarContext()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const listRef = useRef<SidebarConversationListHandle>(null)
|
const listRef = useRef<SidebarConversationListHandle>(null)
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
@@ -70,7 +72,22 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarConversationList ref={listRef} />
|
{/* On mobile, clicking a conversation card auto-closes the Sheet */}
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 overflow-hidden"
|
||||||
|
onClick={
|
||||||
|
isMobile
|
||||||
|
? (e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest("[data-conversation-id]")) {
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SidebarConversationList ref={listRef} />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ import { StatusBarTasks } from "@/components/layout/status-bar-tasks"
|
|||||||
import { StatusBarTokens } from "@/components/layout/status-bar-tokens"
|
import { StatusBarTokens } from "@/components/layout/status-bar-tokens"
|
||||||
import { StatusBarConnection } from "@/components/layout/status-bar-connection"
|
import { StatusBarConnection } from "@/components/layout/status-bar-connection"
|
||||||
import { StatusBarAlerts } from "@/components/layout/status-bar-alerts"
|
import { StatusBarAlerts } from "@/components/layout/status-bar-alerts"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="h-7 shrink-0 border-t border-border bg-muted px-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<StatusBarConnection />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBarTasks />
|
||||||
|
<StatusBarAlerts />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-8 shrink-0 border-t border-border bg-muted px-4 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="h-8 shrink-0 border-t border-border bg-muted px-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useState,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Menu,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
Palette,
|
Palette,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
@@ -26,6 +28,8 @@ import { AppToaster } from "@/components/ui/app-toaster"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { detectEnvironment } from "@/lib/transport/detect"
|
import { detectEnvironment } from "@/lib/transport/detect"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
|
||||||
interface SettingsNavItem {
|
interface SettingsNavItem {
|
||||||
href: string
|
href: string
|
||||||
@@ -118,6 +122,8 @@ export function SettingsShell({ children }: SettingsShellProps) {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const normalizedPathname = normalizePath(pathname)
|
const normalizedPathname = normalizePath(pathname)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t("title")} - codeg`
|
document.title = `${t("title")} - codeg`
|
||||||
@@ -129,67 +135,108 @@ export function SettingsShell({ children }: SettingsShellProps) {
|
|||||||
|
|
||||||
const target = normalizePath(href)
|
const target = normalizePath(href)
|
||||||
const current = normalizePath(window.location.pathname)
|
const current = normalizePath(window.location.pathname)
|
||||||
if (current === target) return
|
if (current === target) {
|
||||||
|
setNavOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isWindowsRuntime()) {
|
if (isWindowsRuntime()) {
|
||||||
// WebView2 on Windows: hard navigation is more reliable than client routing.
|
|
||||||
window.location.assign(target)
|
window.location.assign(target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS/Linux: keep client-side routing for snappier transitions.
|
|
||||||
router.push(target)
|
router.push(target)
|
||||||
|
setNavOpen(false)
|
||||||
},
|
},
|
||||||
[router]
|
[router, setNavOpen]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredNavItems = SETTINGS_NAV_ITEMS.filter(
|
||||||
|
(item) =>
|
||||||
|
!(item.labelKey === "web_service" && detectEnvironment() === "web")
|
||||||
|
)
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<>
|
||||||
|
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{t("preferences")}
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{filteredNavItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const translationKey = `nav.${item.labelKey}` as const
|
||||||
|
const active =
|
||||||
|
normalizedPathname === item.href ||
|
||||||
|
normalizedPathname.startsWith(`${item.href}/`)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.href}
|
||||||
|
variant={active ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className={cn("w-full justify-start")}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigateTo(item.href)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(translationKey)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||||
<AppTitleBar
|
<AppTitleBar
|
||||||
|
left={
|
||||||
|
isMobile ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setNavOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
center={
|
center={
|
||||||
<div className="text-sm font-bold tracking-tight">{t("title")}</div>
|
<div className="text-sm font-bold tracking-tight">{t("title")}</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 flex">
|
<div className="flex-1 min-h-0 flex">
|
||||||
<aside className="w-56 shrink-0 border-r p-3">
|
{/* Desktop sidebar */}
|
||||||
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
{!isMobile && (
|
||||||
{t("preferences")}
|
<aside className="w-56 shrink-0 border-r p-3">{navContent}</aside>
|
||||||
</div>
|
)}
|
||||||
<nav className="space-y-1">
|
|
||||||
{SETTINGS_NAV_ITEMS.filter(
|
|
||||||
(item) =>
|
|
||||||
!(
|
|
||||||
item.labelKey === "web_service" &&
|
|
||||||
detectEnvironment() === "web"
|
|
||||||
)
|
|
||||||
).map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const translationKey = `nav.${item.labelKey}` as const
|
|
||||||
const active =
|
|
||||||
normalizedPathname === item.href ||
|
|
||||||
normalizedPathname.startsWith(`${item.href}/`)
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={item.href}
|
|
||||||
variant={active ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
className={cn("w-full justify-start")}
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigateTo(item.href)}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{t(translationKey)}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="flex-1 min-w-0 min-h-0 p-4">{children}</section>
|
{/* Mobile navigation Sheet */}
|
||||||
|
{isMobile && (
|
||||||
|
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="w-[260px] p-3"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">{t("title")}</SheetTitle>
|
||||||
|
{navContent}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-0 min-h-0 overflow-auto",
|
||||||
|
isMobile ? "p-3" : "p-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<AppToaster position="bottom-right" closeButton duration={4000} />
|
<AppToaster position="bottom-right" closeButton duration={4000} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -15,9 +15,9 @@ const buttonVariants = cva(
|
|||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function CommandDialog({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 rounded-2xl max-w-lg">
|
<DialogContent className="overflow-hidden p-0 rounded-2xl max-w-lg md:max-w-xl lg:max-w-2xl">
|
||||||
<VisuallyHidden.Root>
|
<VisuallyHidden.Root>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</VisuallyHidden.Root>
|
</VisuallyHidden.Root>
|
||||||
|
|||||||
143
src/components/ui/sheet.tsx
Normal file
143
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-4 right-4"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-base font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -15,11 +15,13 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
export function WelcomeScreen() {
|
export function WelcomeScreen() {
|
||||||
const t = useTranslations("WelcomePage")
|
const t = useTranslations("WelcomePage")
|
||||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const { shortcuts } = useShortcutSettings()
|
const { shortcuts } = useShortcutSettings()
|
||||||
|
|
||||||
const handleOpenSettings = useCallback(() => {
|
const handleOpenSettings = useCallback(() => {
|
||||||
@@ -83,8 +85,20 @@ export function WelcomeScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div
|
||||||
<div className="w-60 shrink-0 flex flex-col border-r">
|
className={
|
||||||
|
isMobile
|
||||||
|
? "flex-1 flex flex-col overflow-hidden"
|
||||||
|
: "flex-1 flex overflow-hidden"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "shrink-0 flex flex-col border-b"
|
||||||
|
: "w-60 shrink-0 flex flex-col border-r"
|
||||||
|
}
|
||||||
|
>
|
||||||
<SoftwareInfo />
|
<SoftwareInfo />
|
||||||
<FolderActions />
|
<FolderActions />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,9 +64,12 @@ export function SidebarProvider({ children, folderId }: SidebarProviderProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = loadPersistedPanelState(storageKey)
|
const stored = loadPersistedPanelState(storageKey)
|
||||||
|
// On mobile (< 768px), always start closed regardless of persisted state.
|
||||||
|
const isMobileViewport = window.innerWidth < 768
|
||||||
|
const defaultOpen = isMobileViewport ? false : DEFAULT_IS_OPEN
|
||||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN)
|
setIsOpen(isMobileViewport ? false : (stored?.isOpen ?? defaultOpen))
|
||||||
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
|
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
|
||||||
setRestored(true)
|
setRestored(true)
|
||||||
}, [storageKey])
|
}, [storageKey])
|
||||||
|
|||||||
15
src/hooks/use-media-query.ts
Normal file
15
src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(callback) => {
|
||||||
|
const mql = window.matchMedia(query)
|
||||||
|
mql.addEventListener("change", callback)
|
||||||
|
return () => mql.removeEventListener("change", callback)
|
||||||
|
},
|
||||||
|
() => window.matchMedia(query).matches,
|
||||||
|
() => false
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/hooks/use-mobile.ts
Normal file
9
src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMediaQuery } from "./use-media-query"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = "(max-width: 767px)"
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery(MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user