mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 17:51:12 +02:00
ui: simpler streams
This commit is contained in:
28
TODO.md
28
TODO.md
@@ -116,20 +116,34 @@ All event renderers now protected with error boundaries:
|
||||
|
||||
### Layout System Enhancements
|
||||
**Completed**: 2024-12-18
|
||||
**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx`, `src/core/logic.ts`, `src/core/state.ts`
|
||||
**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx`
|
||||
|
||||
Quick-win improvements to window management:
|
||||
- **Balance Splits**: New action to equalize all split percentages to 50/50 after manual resizing
|
||||
- Recursive tree traversal preserves window IDs and directions
|
||||
- Added to Actions section in LayoutControls dropdown
|
||||
- Smooth animation on balance operation
|
||||
- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by number
|
||||
- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by position
|
||||
- Browser-safe shortcuts (prevents default browser behavior)
|
||||
- Switches by visual position in tab bar, not workspace number
|
||||
- Significantly faster workflow for power users
|
||||
- Comprehensive test coverage for balanceLayout function
|
||||
- **Adaptive Layout Presets**: All presets now handle any number of windows
|
||||
- Grid layout adapts to any N ≥ 2 windows (2×2, 2×3, 3×3, etc.)
|
||||
- Side-by-side handles 2-4 windows with equal splits
|
||||
- Main+sidebar naturally adapts to any number
|
||||
- Comprehensive test coverage for grid layouts with odd numbers
|
||||
|
||||
## Window Management Improvements
|
||||
|
||||
### Balance Splits
|
||||
**Priority**: Medium | **Effort**: Low (1 hour)
|
||||
**Description**: Action to equalize all split percentages to 50/50 after manual resizing
|
||||
**Implementation**:
|
||||
- Recursive tree traversal that preserves window IDs and directions
|
||||
- Reset all `splitPercentage` values to 50
|
||||
- Add to layout dropdown (once basic features are validated)
|
||||
- Smooth animation on balance operation
|
||||
|
||||
**Use Case**: After manually resizing windows, quickly restore clean 50/50 proportions
|
||||
|
||||
**Note**: Deferred until core preset/insertion features are validated in real usage
|
||||
|
||||
### Fullscreen Mode
|
||||
**Priority**: High | **Effort**: Medium (2-3 hours)
|
||||
**Description**: Toggle window to fill entire workspace with minimal chrome
|
||||
|
||||
75
package-lock.json
generated
75
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"applesauce-accounts": "^4.1.0",
|
||||
@@ -2949,6 +2950,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"applesauce-accounts": "^4.1.0",
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
Sparkles,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Slider } from "./ui/slider";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAllPresets } from "@/lib/layout-presets";
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
} from "./ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import type { LayoutConfig } from "@/types/app";
|
||||
import { useState } from "react";
|
||||
|
||||
export function LayoutControls() {
|
||||
const { state, applyPresetLayout, balanceLayout, updateLayoutConfig } =
|
||||
useGrimoire();
|
||||
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
|
||||
const { workspaces, activeWorkspaceId, layoutConfig } = state;
|
||||
|
||||
// Local state for immediate slider feedback (debounced persistence)
|
||||
const [localSplitPercentage, setLocalSplitPercentage] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const activeWorkspace = workspaces[activeWorkspaceId];
|
||||
const windowCount = activeWorkspace?.windowIds.length || 0;
|
||||
const presets = getAllPresets();
|
||||
@@ -71,13 +76,13 @@ export function LayoutControls() {
|
||||
const getPresetIcon = (presetId: string) => {
|
||||
switch (presetId) {
|
||||
case "side-by-side":
|
||||
return <Columns2 className="h-4 w-4" />;
|
||||
return <Columns2 className="h-4 w-4 text-muted-foreground" />;
|
||||
case "main-sidebar":
|
||||
return <Split className="h-4 w-4" />;
|
||||
return <Split className="h-4 w-4 text-muted-foreground" />;
|
||||
case "grid":
|
||||
return <Grid2X2 className="h-4 w-4" />;
|
||||
return <Grid2X2 className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Grid2X2 className="h-4 w-4" />;
|
||||
return <Grid2X2 className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,33 +96,9 @@ export function LayoutControls() {
|
||||
{ id: "column", label: "Vertical", icon: SplitSquareVertical },
|
||||
];
|
||||
|
||||
const handleSplitChange = (increment: number) => {
|
||||
const newValue = Math.max(
|
||||
20,
|
||||
Math.min(80, layoutConfig.splitPercentage + increment)
|
||||
);
|
||||
updateLayoutConfig({ splitPercentage: newValue });
|
||||
};
|
||||
|
||||
const handleBalance = () => {
|
||||
try {
|
||||
// Enable animations for smooth transition
|
||||
document.body.classList.add("animating-layout");
|
||||
|
||||
balanceLayout();
|
||||
|
||||
// Remove animation class after transition completes
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove("animating-layout");
|
||||
}, 180);
|
||||
} catch (error) {
|
||||
document.body.classList.remove("animating-layout");
|
||||
toast.error(`Failed to balance layout`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
// Current split percentage (local state during drag, global state otherwise)
|
||||
const displayedSplitPercentage =
|
||||
localSplitPercentage ?? layoutConfig.splitPercentage;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -128,29 +109,16 @@ export function LayoutControls() {
|
||||
className="h-6 w-6"
|
||||
aria-label="Layout settings"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{/* Presets Section */}
|
||||
{/* Layouts Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Presets
|
||||
Layouts
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const hasMin = windowCount >= preset.minSlots;
|
||||
const hasMax = !preset.maxSlots || windowCount <= preset.maxSlots;
|
||||
const canApply = hasMin && hasMax;
|
||||
|
||||
let statusText = "";
|
||||
if (!hasMin) {
|
||||
statusText = `Needs ${preset.minSlots}+ (have ${windowCount})`;
|
||||
} else if (!hasMax) {
|
||||
statusText = `Max ${preset.maxSlots} (have ${windowCount})`;
|
||||
} else if (preset.maxSlots) {
|
||||
statusText = `${preset.minSlots}-${preset.maxSlots} windows`;
|
||||
} else {
|
||||
statusText = `${preset.minSlots}+ windows`;
|
||||
}
|
||||
const canApply = windowCount >= preset.minSlots;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -162,9 +130,6 @@ export function LayoutControls() {
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -172,9 +137,12 @@ export function LayoutControls() {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Insertion Mode Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Insert Mode
|
||||
{/* Placement Section */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Placement
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Window insertion</div>
|
||||
</div>
|
||||
{insertionModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
@@ -185,7 +153,7 @@ export function LayoutControls() {
|
||||
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1">{mode.label}</span>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
@@ -198,64 +166,32 @@ export function LayoutControls() {
|
||||
|
||||
{/* Split Ratio Section */}
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">Split</span>
|
||||
<span className="text-foreground">
|
||||
{layoutConfig.splitPercentage}/
|
||||
{100 - layoutConfig.splitPercentage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleSplitChange(-10)}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="80"
|
||||
value={layoutConfig.splitPercentage}
|
||||
onChange={(e) =>
|
||||
updateLayoutConfig({
|
||||
splitPercentage: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="flex-1 h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleSplitChange(10)}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Split Ratio
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default split for new windows
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[displayedSplitPercentage]}
|
||||
onValueChange={([value]) => setLocalSplitPercentage(value)}
|
||||
onValueCommit={([value]) => {
|
||||
updateLayoutConfig({ splitPercentage: value });
|
||||
setLocalSplitPercentage(null); // Clear local state after persist
|
||||
}}
|
||||
min={20}
|
||||
max={80}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Actions
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={handleBalance}
|
||||
disabled={windowCount < 2}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Scale className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Balance Splits</span>
|
||||
{windowCount < 2 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Need 2+ windows
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getAllPresets } from "@/lib/layout-presets";
|
||||
import type { LayoutPreset } from "@/lib/layout-presets";
|
||||
import { Button } from "./ui/button";
|
||||
import { Grid2X2, Columns2, Split, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LayoutViewerProps {
|
||||
presetId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LAYOUT viewer - displays available layout presets and allows applying them
|
||||
*/
|
||||
export function LayoutViewer({ presetId, error }: LayoutViewerProps) {
|
||||
const { state, applyPresetLayout } = useGrimoire();
|
||||
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
|
||||
const windowCount = activeWorkspace.windowIds.length;
|
||||
const presets = getAllPresets();
|
||||
const [applying, setApplying] = useState<string | null>(null);
|
||||
|
||||
// If a preset was specified via command line, show error or success
|
||||
const specifiedPreset = presetId
|
||||
? presets.find((p) => p.id === presetId)
|
||||
: null;
|
||||
|
||||
const handleApplyPreset = async (preset: LayoutPreset) => {
|
||||
if (windowCount < preset.slots) {
|
||||
toast.error(`Not enough windows`, {
|
||||
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(preset.id);
|
||||
try {
|
||||
applyPresetLayout(preset);
|
||||
toast.success(`Layout applied`, {
|
||||
description: `Applied "${preset.name}" preset to workspace ${activeWorkspace.number}`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Failed to apply layout`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
} finally {
|
||||
setApplying(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetIcon = (presetId: string) => {
|
||||
switch (presetId) {
|
||||
case "side-by-side":
|
||||
return <Columns2 className="h-8 w-8" />;
|
||||
case "main-sidebar":
|
||||
return <Split className="h-8 w-8" />;
|
||||
case "grid":
|
||||
return <Grid2X2 className="h-8 w-8" />;
|
||||
default:
|
||||
return <Grid2X2 className="h-8 w-8" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetDiagram = (preset: LayoutPreset) => {
|
||||
// Visual representation of the layout
|
||||
switch (preset.id) {
|
||||
case "side-by-side":
|
||||
return (
|
||||
<div className="flex gap-2 h-16">
|
||||
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
case "main-sidebar":
|
||||
return (
|
||||
<div className="flex gap-2 h-16">
|
||||
<div className="flex-[7] border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="flex-[3] border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
case "grid":
|
||||
return (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-2 h-16">
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Layout Presets</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Apply preset layouts to reorganize windows in workspace{" "}
|
||||
{activeWorkspace.number}
|
||||
</p>
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="text-muted-foreground">Current windows: </span>
|
||||
<span className="font-semibold">{windowCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command-line specified preset with error */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/50 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-destructive mb-1">
|
||||
Command Error
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command-line specified preset (valid) */}
|
||||
{specifiedPreset && !error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-accent/10 border border-accent/50 flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1">
|
||||
Preset: {specifiedPreset.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{specifiedPreset.description}
|
||||
</div>
|
||||
{windowCount < specifiedPreset.slots ? (
|
||||
<div className="text-sm text-destructive">
|
||||
⚠️ Not enough windows (requires {specifiedPreset.slots}, have{" "}
|
||||
{windowCount})
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleApplyPreset(specifiedPreset)}
|
||||
size="sm"
|
||||
disabled={applying === specifiedPreset.id}
|
||||
>
|
||||
{applying === specifiedPreset.id
|
||||
? "Applying..."
|
||||
: "Apply Preset"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset Gallery */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.slots;
|
||||
const isApplying = applying === preset.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className={`p-4 rounded-lg border transition-colors ${
|
||||
canApply
|
||||
? "border-border hover:border-accent/50 hover:bg-accent/5"
|
||||
: "border-muted-foreground/20 opacity-60"
|
||||
}`}
|
||||
>
|
||||
{/* Icon and Title */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-muted-foreground">
|
||||
{getPresetIcon(preset.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preset.slots} windows
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* Visual Diagram */}
|
||||
<div className="mb-3">{getPresetDiagram(preset)}</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
{canApply ? (
|
||||
<Button
|
||||
onClick={() => handleApplyPreset(preset)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">
|
||||
Requires {preset.slots} windows (have {windowCount})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,6 @@ const DebugViewer = lazy(() =>
|
||||
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
||||
);
|
||||
const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const LayoutViewer = lazy(() =>
|
||||
import("./LayoutViewer").then((m) => ({ default: m.LayoutViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -165,14 +162,6 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "conn":
|
||||
content = <ConnViewer />;
|
||||
break;
|
||||
case "layout":
|
||||
content = (
|
||||
<LayoutViewer
|
||||
presetId={window.props.presetId}
|
||||
error={window.props.error}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -6,12 +6,10 @@ import {
|
||||
getLiveHost,
|
||||
} from "@/lib/live-activity";
|
||||
import { VideoPlayer } from "@/components/live/VideoPlayer";
|
||||
import { ChatView } from "@/components/nostr/ChatView";
|
||||
import { StatusBadge } from "@/components/live/StatusBadge";
|
||||
import { UserName } from "../UserName";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
|
||||
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
|
||||
|
||||
interface LiveActivityDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
@@ -24,36 +22,6 @@ export function LiveActivityDetailRenderer({
|
||||
const status = useMemo(() => getLiveStatus(event), [event]);
|
||||
const hostPubkey = useMemo(() => getLiveHost(event), [event]);
|
||||
|
||||
// Get host's relay list for chat
|
||||
const { relays: hostRelays } = useOutboxRelays({
|
||||
authors: [hostPubkey],
|
||||
});
|
||||
|
||||
// Combine stream relays + host relays for chat events
|
||||
const allRelays = useMemo(
|
||||
() => Array.from(new Set([...activity.relays, ...hostRelays])),
|
||||
[activity.relays, hostRelays],
|
||||
);
|
||||
|
||||
// Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream
|
||||
const timelineFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1311, 9735],
|
||||
"#a": [
|
||||
`${event.kind}:${event.pubkey}:${event.tags.find((t) => t[0] === "d")?.[1] || ""}`,
|
||||
],
|
||||
limit: 100,
|
||||
}),
|
||||
[event],
|
||||
);
|
||||
|
||||
const { events: chatEvents } = useLiveTimeline(
|
||||
`stream-feed-${event.id}`,
|
||||
timelineFilter,
|
||||
allRelays,
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
const videoUrl =
|
||||
status === "live" && activity.streaming
|
||||
? activity.streaming
|
||||
@@ -62,7 +30,7 @@ export function LiveActivityDetailRenderer({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full bg-background overflow-y-auto">
|
||||
{/* Video Section */}
|
||||
<div className="flex-shrink-0">
|
||||
{videoUrl ? (
|
||||
@@ -94,27 +62,48 @@ export function LiveActivityDetailRenderer({
|
||||
) : (
|
||||
<div className="aspect-video bg-neutral-800 flex items-center justify-center">
|
||||
<div className="text-center text-neutral-400">
|
||||
<StatusBadge status={status} size="md" />
|
||||
<StatusBadge status={status} />
|
||||
<p className="mt-4">No stream available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact title bar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-lg font-bold flex-1 line-clamp-1">
|
||||
{activity.title || "Untitled Live Activity"}
|
||||
</h1>
|
||||
{/* Stream Info Section */}
|
||||
<div className="flex-1 p-2 space-y-3">
|
||||
{/* Title and Status Badge */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-balance">
|
||||
{activity.title || "Untitled Live Activity"}
|
||||
</h1>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
{/* Host */}
|
||||
<UserName
|
||||
pubkey={hostPubkey}
|
||||
className="text-sm font-semibold line-clamp-1"
|
||||
className="text-sm text-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Section */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatView events={chatEvents} className="h-full" />
|
||||
{/* Description */}
|
||||
{activity.summary && (
|
||||
<p className="text-base text-muted-foreground leading-relaxed">
|
||||
{activity.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{activity.hashtags.filter((t) => !t.includes(":")).length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activity.hashtags
|
||||
.filter((t) => !t.includes(":"))
|
||||
.map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ export function LiveActivityRenderer({ event }: LiveActivityRendererProps) {
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{/* Hashtags */}
|
||||
{activity.hashtags
|
||||
.filter((t) => !t.startsWith("internal:"))
|
||||
.filter((t) => !t.includes(":"))
|
||||
.map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
|
||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@@ -2,11 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
|
||||
import { insertWindow } from "@/lib/layout-utils";
|
||||
import {
|
||||
applyPresetToLayout,
|
||||
balanceLayout,
|
||||
type LayoutPreset,
|
||||
} from "@/lib/layout-presets";
|
||||
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
|
||||
|
||||
/**
|
||||
* Finds the lowest available workspace number.
|
||||
@@ -398,28 +394,3 @@ export const applyPresetLayout = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Balances all split percentages in the active workspace to 50/50.
|
||||
* Useful for equalizing splits after manual resizing.
|
||||
*/
|
||||
export const balanceLayoutInWorkspace = (
|
||||
state: GrimoireState,
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
const ws = state.workspaces[activeId];
|
||||
|
||||
// Balance the layout tree
|
||||
const balancedLayout = balanceLayout(ws.layout);
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
[activeId]: {
|
||||
...ws,
|
||||
layout: balancedLayout,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -241,11 +241,6 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const balanceLayout = useCallback(
|
||||
() => setState((prev) => Logic.balanceLayoutInWorkspace(prev)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
@@ -261,6 +256,5 @@ export const useGrimoire = () => {
|
||||
setActiveAccountRelays,
|
||||
updateLayoutConfig,
|
||||
applyPresetLayout,
|
||||
balanceLayout,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getPreset, getAllPresets } from "./layout-presets";
|
||||
|
||||
export interface LayoutCommandResult {
|
||||
/** The preset ID to apply, or undefined to show preset list */
|
||||
presetId?: string;
|
||||
/** Error message if parsing failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the /layout command arguments
|
||||
*
|
||||
* Usage:
|
||||
* /layout - Show all available presets
|
||||
* /layout side-by-side - Apply the side-by-side preset
|
||||
* /layout grid - Apply the grid preset
|
||||
*/
|
||||
export function parseLayoutCommand(args: string[]): LayoutCommandResult {
|
||||
// No arguments - show preset list
|
||||
if (args.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get the preset ID (first argument)
|
||||
const presetId = args[0].toLowerCase();
|
||||
|
||||
// Validate preset exists
|
||||
const preset = getPreset(presetId);
|
||||
if (!preset) {
|
||||
const availablePresets = getAllPresets()
|
||||
.map((p) => p.id)
|
||||
.join(", ");
|
||||
return {
|
||||
error: `Unknown preset "${presetId}". Available presets: ${availablePresets}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { presetId };
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
collectWindowIds,
|
||||
applyPresetToLayout,
|
||||
balanceLayout,
|
||||
BUILT_IN_PRESETS,
|
||||
} from "./layout-presets";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
@@ -151,16 +150,23 @@ describe("layout-presets", () => {
|
||||
expect(windowIds).toEqual(["w1", "w2", "w3"]);
|
||||
});
|
||||
|
||||
it("handles 4 windows (max allowed)", () => {
|
||||
it("handles 4 windows", () => {
|
||||
const layout = sideBySidePreset.generate(["w1", "w2", "w3", "w4"]);
|
||||
const windowIds = collectWindowIds(layout);
|
||||
expect(windowIds).toEqual(["w1", "w2", "w3", "w4"]);
|
||||
});
|
||||
|
||||
it("throws error for 5+ windows", () => {
|
||||
expect(() =>
|
||||
sideBySidePreset.generate(["w1", "w2", "w3", "w4", "w5"])
|
||||
).toThrow("maximum 4 windows");
|
||||
it("handles 5 windows", () => {
|
||||
const layout = sideBySidePreset.generate(["w1", "w2", "w3", "w4", "w5"]);
|
||||
const windowIds = collectWindowIds(layout);
|
||||
expect(windowIds).toEqual(["w1", "w2", "w3", "w4", "w5"]);
|
||||
});
|
||||
|
||||
it("handles 8 windows (many splits)", () => {
|
||||
const windows = Array.from({ length: 8 }, (_, i) => `w${i + 1}`);
|
||||
const layout = sideBySidePreset.generate(windows);
|
||||
const windowIds = collectWindowIds(layout);
|
||||
expect(windowIds).toEqual(windows);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,84 +200,6 @@ describe("layout-presets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("balanceLayout", () => {
|
||||
it("returns null for null layout", () => {
|
||||
expect(balanceLayout(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns single window unchanged", () => {
|
||||
expect(balanceLayout("w1")).toBe("w1");
|
||||
});
|
||||
|
||||
it("balances a simple binary split", () => {
|
||||
const unbalanced: MosaicNode<string> = {
|
||||
direction: "row",
|
||||
first: "w1",
|
||||
second: "w2",
|
||||
splitPercentage: 70,
|
||||
};
|
||||
const balanced = balanceLayout(unbalanced);
|
||||
expect(balanced).toEqual({
|
||||
direction: "row",
|
||||
first: "w1",
|
||||
second: "w2",
|
||||
splitPercentage: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("balances nested splits recursively", () => {
|
||||
const unbalanced: MosaicNode<string> = {
|
||||
direction: "row",
|
||||
first: {
|
||||
direction: "column",
|
||||
first: "w1",
|
||||
second: "w2",
|
||||
splitPercentage: 30,
|
||||
},
|
||||
second: {
|
||||
direction: "column",
|
||||
first: "w3",
|
||||
second: "w4",
|
||||
splitPercentage: 80,
|
||||
},
|
||||
splitPercentage: 60,
|
||||
};
|
||||
const balanced = balanceLayout(unbalanced);
|
||||
|
||||
// All splits should be 50%
|
||||
expect(balanced).toMatchObject({
|
||||
splitPercentage: 50,
|
||||
first: { splitPercentage: 50 },
|
||||
second: { splitPercentage: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves window IDs and directions", () => {
|
||||
const original: MosaicNode<string> = {
|
||||
direction: "column",
|
||||
first: "w1",
|
||||
second: {
|
||||
direction: "row",
|
||||
first: "w2",
|
||||
second: "w3",
|
||||
splitPercentage: 75,
|
||||
},
|
||||
splitPercentage: 25,
|
||||
};
|
||||
const balanced = balanceLayout(original);
|
||||
const windowIds = collectWindowIds(balanced);
|
||||
expect(windowIds).toEqual(["w1", "w2", "w3"]);
|
||||
|
||||
// Check directions preserved
|
||||
if (balanced && typeof balanced !== "string") {
|
||||
expect(balanced.direction).toBe("column");
|
||||
if (typeof balanced.second !== "string") {
|
||||
expect(balanced.second.direction).toBe("row");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPresetToLayout", () => {
|
||||
it("throws error if too few windows", () => {
|
||||
const layout: MosaicNode<string> = "w1";
|
||||
@@ -280,33 +208,6 @@ describe("layout-presets", () => {
|
||||
).toThrow("at least 2 windows");
|
||||
});
|
||||
|
||||
it("throws error if too many windows for side-by-side", () => {
|
||||
const layout: MosaicNode<string> = {
|
||||
direction: "row",
|
||||
first: {
|
||||
direction: "row",
|
||||
first: "w1",
|
||||
second: "w2",
|
||||
splitPercentage: 50,
|
||||
},
|
||||
second: {
|
||||
direction: "row",
|
||||
first: {
|
||||
direction: "row",
|
||||
first: "w3",
|
||||
second: "w4",
|
||||
splitPercentage: 50,
|
||||
},
|
||||
second: "w5",
|
||||
splitPercentage: 50,
|
||||
},
|
||||
splitPercentage: 50,
|
||||
};
|
||||
expect(() =>
|
||||
applyPresetToLayout(layout, BUILT_IN_PRESETS["side-by-side"])
|
||||
).toThrow("maximum 4 windows");
|
||||
});
|
||||
|
||||
it("applies grid preset to existing layout", () => {
|
||||
const existingLayout: MosaicNode<string> = {
|
||||
direction: "row",
|
||||
|
||||
@@ -114,13 +114,9 @@ export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
|
||||
"side-by-side": {
|
||||
id: "side-by-side",
|
||||
name: "Side by Side",
|
||||
description: "All windows in a single row (max 4)",
|
||||
description: "All windows in a single row",
|
||||
minSlots: 2,
|
||||
maxSlots: 4,
|
||||
generate: (windowIds: string[]) => {
|
||||
if (windowIds.length > 4) {
|
||||
throw new Error("Side-by-side layout supports maximum 4 windows");
|
||||
}
|
||||
return buildHorizontalRow(windowIds);
|
||||
},
|
||||
},
|
||||
@@ -203,31 +199,6 @@ export function applyPresetToLayout(
|
||||
return preset.generate(windowIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances all split percentages in a layout tree to 50/50
|
||||
* Useful for equalizing splits after manual resizing
|
||||
*/
|
||||
export function balanceLayout(
|
||||
layout: MosaicNode<string> | null
|
||||
): MosaicNode<string> | null {
|
||||
if (layout === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Leaf node (window ID), return as-is
|
||||
if (typeof layout === "string") {
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Branch node, balance this split and recurse
|
||||
return {
|
||||
direction: layout.direction,
|
||||
first: balanceLayout(layout.first),
|
||||
second: balanceLayout(layout.second),
|
||||
splitPercentage: 50,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preset by ID
|
||||
*/
|
||||
|
||||
@@ -15,8 +15,7 @@ export type AppId =
|
||||
| "decode"
|
||||
| "relay"
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "layout";
|
||||
| "conn";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AppId } from "./app";
|
||||
import { parseOpenCommand } from "@/lib/open-parser";
|
||||
import { parseProfileCommand } from "@/lib/profile-parser";
|
||||
import { parseRelayCommand } from "@/lib/relay-parser";
|
||||
import { parseLayoutCommand } from "@/lib/layout-parser";
|
||||
import { resolveNip05Batch } from "@/lib/nip05";
|
||||
|
||||
export interface ManPageEntry {
|
||||
@@ -458,30 +457,4 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
category: "System",
|
||||
defaultProps: {},
|
||||
},
|
||||
layout: {
|
||||
name: "layout",
|
||||
section: "1",
|
||||
synopsis: "layout [preset-name]",
|
||||
description:
|
||||
"Apply a preset layout to reorganize windows in the current workspace. Presets provide common layout arrangements like side-by-side splits, main+sidebar configurations, and grid layouts. Running without arguments shows all available presets.",
|
||||
options: [
|
||||
{
|
||||
flag: "[preset-name]",
|
||||
description: "Preset layout to apply (side-by-side, main-sidebar, grid)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"layout View all available presets",
|
||||
"layout side-by-side Apply 50/50 horizontal split (2 windows)",
|
||||
"layout main-sidebar Apply 70/30 horizontal split (2 windows)",
|
||||
"layout grid Apply 2×2 grid layout (4 windows)",
|
||||
],
|
||||
seeAlso: ["man"],
|
||||
appId: "layout",
|
||||
category: "System",
|
||||
argParser: (args: string[]) => {
|
||||
const result = parseLayoutCommand(args);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user