mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat(layouts): Phase 2 - workspace settings UI
Add per-workspace layout configuration UI with visual controls: **Core Changes:** - Add updateWorkspaceLayoutConfig() function to logic.ts for updating workspace layout settings - Expose updateWorkspaceLayoutConfig in useGrimoire hook **UI Components:** - Create WorkspaceSettings dialog with three sections: * Insertion Mode selector (Balanced/Horizontal/Vertical) with icons * Split Percentage slider (10-90%) with real-time preview * Insertion Position toggle (Left-Top/Right-Bottom) - Add settings icon (SlidersHorizontal) to workspace tabs that appears on hover - Settings button opens configuration dialog for that workspace **UX Details:** - Settings icon only visible on hover to reduce visual clutter - Clear visual feedback for selected options with primary color highlights - Preview section shows current configuration in plain language - Reset to Defaults button restores smart mode defaults - Prevents workspace switch when clicking settings icon **Icons Used:** - Sparkles: Balanced (smart auto-balancing) - SplitSquareHorizontal: Horizontal splits - SplitSquareVertical: Vertical splits - SlidersHorizontal: Settings access Each workspace can now have independent layout behavior configured through an intuitive UI.
This commit is contained in:
@@ -1,50 +1,85 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkspaceSettings } from "./WorkspaceSettings";
|
||||
import { useState } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
|
||||
const { workspaces, activeWorkspaceId } = state;
|
||||
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleNewTab = () => {
|
||||
createWorkspace();
|
||||
};
|
||||
|
||||
const handleSettingsClick = (e: React.MouseEvent, workspaceId: string) => {
|
||||
e.stopPropagation(); // Prevent workspace switch
|
||||
setSettingsWorkspaceId(workspaceId);
|
||||
};
|
||||
|
||||
// Sort workspaces by number
|
||||
const sortedWorkspaces = Object.values(workspaces).sort(
|
||||
(a, b) => a.number - b.number,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
|
||||
<div className="flex items-center gap-1 flex-nowrap">
|
||||
{sortedWorkspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => setActiveWorkspace(ws.id)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
|
||||
ws.id === activeWorkspaceId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
<>
|
||||
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
|
||||
<div className="flex items-center gap-1 flex-nowrap">
|
||||
{sortedWorkspaces.map((ws) => (
|
||||
<div key={ws.id} className="relative group flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveWorkspace(ws.id)}
|
||||
className={cn(
|
||||
"px-3 py-1 pr-7 text-xs font-mono rounded transition-colors whitespace-nowrap",
|
||||
ws.id === activeWorkspaceId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{ws.label && ws.label.trim()
|
||||
? `${ws.number} ${ws.label}`
|
||||
: ws.number}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, ws.id)}
|
||||
className={cn(
|
||||
"absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
ws.id === activeWorkspaceId
|
||||
? "text-primary-foreground hover:bg-primary-foreground/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
aria-label={`Settings for workspace ${ws.number}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1 flex-shrink-0"
|
||||
onClick={handleNewTab}
|
||||
aria-label="Create new workspace"
|
||||
>
|
||||
{ws.label && ws.label.trim()
|
||||
? `${ws.number} ${ws.label}`
|
||||
: ws.number}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1 flex-shrink-0"
|
||||
onClick={handleNewTab}
|
||||
aria-label="Create new workspace"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsWorkspaceId && (
|
||||
<WorkspaceSettings
|
||||
workspaceId={settingsWorkspaceId}
|
||||
open={settingsWorkspaceId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSettingsWorkspaceId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
231
src/components/WorkspaceSettings.tsx
Normal file
231
src/components/WorkspaceSettings.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Label } from "./ui/Label";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Sparkles,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import type { LayoutConfig } from "@/types/app";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkspaceSettingsProps {
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorkspaceSettings({
|
||||
workspaceId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: WorkspaceSettingsProps) {
|
||||
const { state, updateWorkspaceLayoutConfig } = useGrimoire();
|
||||
const workspace = state.workspaces[workspaceId];
|
||||
|
||||
// Local state for settings
|
||||
const [insertionMode, setInsertionMode] = useState<LayoutConfig["insertionMode"]>(
|
||||
workspace?.layoutConfig?.insertionMode || "smart"
|
||||
);
|
||||
const [splitPercentage, setSplitPercentage] = useState(
|
||||
workspace?.layoutConfig?.splitPercentage || 50
|
||||
);
|
||||
const [insertionPosition, setInsertionPosition] = useState<LayoutConfig["insertionPosition"]>(
|
||||
workspace?.layoutConfig?.insertionPosition || "second"
|
||||
);
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
updateWorkspaceLayoutConfig(workspaceId, {
|
||||
insertionMode,
|
||||
splitPercentage,
|
||||
insertionPosition,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setInsertionMode("smart");
|
||||
setSplitPercentage(50);
|
||||
setInsertionPosition("second");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Workspace {workspace.number}
|
||||
{workspace.label && ` - ${workspace.label}`} Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how new windows are inserted into this workspace layout.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Insertion Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Insertion Mode</Label>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
onClick={() => setInsertionMode("smart")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
|
||||
insertionMode === "smart"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-sm">Balanced (auto)</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Automatically balances horizontal and vertical splits
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setInsertionMode("row")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
|
||||
insertionMode === "row"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<SplitSquareHorizontal className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-sm">Horizontal (side-by-side)</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
New windows always split horizontally
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setInsertionMode("column")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
|
||||
insertionMode === "column"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<SplitSquareVertical className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-sm">Vertical (stacked)</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
New windows always split vertically
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Percentage */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Split Percentage</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{splitPercentage}% / {100 - splitPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="90"
|
||||
value={splitPercentage}
|
||||
onChange={(e) => setSplitPercentage(Number(e.target.value))}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Existing content gets {splitPercentage}%</span>
|
||||
<span>New window gets {100 - splitPercentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insertion Position */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Insertion Position</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setInsertionPosition("first")}
|
||||
className={cn(
|
||||
"p-3 rounded-md border-2 transition-all text-sm",
|
||||
insertionPosition === "first"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">Left / Top</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
New window first
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setInsertionPosition("second")}
|
||||
className={cn(
|
||||
"p-3 rounded-md border-2 transition-all text-sm",
|
||||
insertionPosition === "second"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">Right / Bottom</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
New window second
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<div className="text-xs text-muted-foreground mb-2">Preview:</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">
|
||||
{insertionMode === "smart" && "Smart mode"}
|
||||
{insertionMode === "row" && "Horizontal splits"}
|
||||
{insertionMode === "column" && "Vertical splits"}
|
||||
</span>
|
||||
{" · "}
|
||||
<span>
|
||||
{splitPercentage}%/{100 - splitPercentage}% split
|
||||
</span>
|
||||
{" · "}
|
||||
<span>
|
||||
New window on{" "}
|
||||
{insertionPosition === "first" ? "left/top" : "right/bottom"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -344,3 +344,32 @@ export const updateWindow = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the layout configuration for a workspace.
|
||||
* Controls how new windows are inserted into the workspace layout.
|
||||
*/
|
||||
export const updateWorkspaceLayoutConfig = (
|
||||
state: GrimoireState,
|
||||
workspaceId: string,
|
||||
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
|
||||
): GrimoireState => {
|
||||
const workspace = state.workspaces[workspaceId];
|
||||
if (!workspace) {
|
||||
return state; // Workspace doesn't exist, return unchanged
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
[workspaceId]: {
|
||||
...workspace,
|
||||
layoutConfig: {
|
||||
...workspace.layoutConfig,
|
||||
...layoutConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -229,6 +229,17 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWorkspaceLayoutConfig = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
|
||||
) =>
|
||||
setState((prev) =>
|
||||
Logic.updateWorkspaceLayoutConfig(prev, workspaceId, layoutConfig),
|
||||
),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
@@ -242,5 +253,6 @@ export const useGrimoire = () => {
|
||||
setActiveWorkspace,
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
updateWorkspaceLayoutConfig,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user