Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions apps/web/src/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,37 @@ import {
ChevronRightIcon,
ExternalLinkIcon,
GlobeIcon,
LaptopIcon,
LoaderCircleIcon,
MaximizeIcon,
MonitorIcon,
PlusIcon,
RefreshCwIcon,
SmartphoneIcon,
StarIcon,
TabletIcon,
WrenchIcon,
XIcon,
} from "lucide-react";

import { validateHttpPreviewUrl } from "@okcode/shared/preview";
import { readDesktopPreviewBridge } from "~/desktopPreview";
import { type BrowserPresetId, BROWSER_PRESETS, getBrowserPreset } from "~/lib/browserPresets";
import { cn } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
import { usePreviewStateStore } from "~/previewStateStore";

import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Menu,
MenuGroupLabel,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator,
MenuTrigger,
} from "./ui/menu";

const EMPTY_TABS_STATE: PreviewTabsState = {
tabs: [],
Expand All @@ -38,6 +53,17 @@ const HIDDEN_PREVIEW_BOUNDS = {
viewportHeight: 0,
} as const;

const PRESET_ICONS: Record<BrowserPresetId, typeof SmartphoneIcon> = {
mobile: SmartphoneIcon,
tablet: TabletIcon,
laptop: LaptopIcon,
desktop: MonitorIcon,
ultrawide: MonitorIcon,
};

/** Sentinel value used by the radio group to represent "no preset" (responsive). */
const RESPONSIVE_VALUE = "__responsive__";

function getActiveTab(state: PreviewTabsState): PreviewTabState | null {
if (!state.activeTabId) return null;
return state.tabs.find((t) => t.tabId === state.activeTabId) ?? null;
Expand Down Expand Up @@ -66,6 +92,10 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
const setGlobalOpen = usePreviewStateStore((state) => state.setGlobalOpen);
const favoriteUrls = usePreviewStateStore((state) => state.favoriteUrls);
const toggleFavoriteUrl = usePreviewStateStore((state) => state.toggleFavoriteUrl);
const presetId = usePreviewStateStore((state) => state.presetByThreadId[threadId] ?? null);
const setThreadPreset = usePreviewStateStore((state) => state.setThreadPreset);
const activePreset = presetId ? getBrowserPreset(presetId) : null;
const PresetIcon = presetId ? PRESET_ICONS[presetId] : null;

const [tabsState, setTabsState] = useState<PreviewTabsState>(EMPTY_TABS_STATE);
const [inputUrl, setInputUrl] = useState("");
Expand Down Expand Up @@ -299,6 +329,61 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
</form>
</div>
<div className="flex items-center gap-1">
<Menu>
<MenuTrigger
className={cn(
"inline-flex h-6 cursor-default items-center gap-1 rounded-md px-1.5 text-[11px] transition-colors",
presetId
? "bg-accent/60 text-foreground"
: "text-muted-foreground/55 hover:bg-accent/40 hover:text-foreground",
)}
aria-label="Viewport preset"
>
{PresetIcon ? (
<PresetIcon className="size-3" />
) : (
<MaximizeIcon className="size-3" />
)}
<span className="max-sm:hidden">
{activePreset ? activePreset.label : "Responsive"}
</span>
</MenuTrigger>
<MenuPopup side="bottom" align="end" sideOffset={6}>
<MenuGroupLabel>Viewport</MenuGroupLabel>
<MenuRadioGroup
value={presetId ?? RESPONSIVE_VALUE}
onValueChange={(value) => {
setThreadPreset(
threadId,
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
);
}}
>
<MenuRadioItem value={RESPONSIVE_VALUE}>
<span className="flex items-center gap-2">
<MaximizeIcon className="size-3.5 opacity-60" />
Responsive
</span>
</MenuRadioItem>
<MenuSeparator />
{BROWSER_PRESETS.map((preset) => {
const Icon = PRESET_ICONS[preset.id];
return (
<MenuRadioItem key={preset.id} value={preset.id}>
<span className="flex items-center gap-2">
<Icon className="size-3.5 opacity-60" />
<span>{preset.label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
{preset.width}&times;{preset.height}
</span>
</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuPopup>
</Menu>

<Button
type="button"
size="icon-xs"
Expand Down Expand Up @@ -418,10 +503,28 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
)}

{/* Content area */}
<div className="flex min-h-0 flex-1 flex-col p-3">
<div
className={cn(
"flex min-h-0 flex-1 p-3",
activePreset ? "items-center justify-center" : "flex-col",
)}
>
<div
ref={surfaceRef}
className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border/70 bg-card/20"
className={cn(
"relative overflow-hidden rounded-lg border border-border/70 bg-card/20",
!activePreset && "min-h-0 flex-1",
)}
style={
activePreset
? {
width: activePreset.width,
height: activePreset.height,
maxWidth: "100%",
maxHeight: "100%",
}
: undefined
}
>
{!showEmbeddedSurface ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground/70">
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/lib/browserPresets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export type BrowserPresetId = "mobile" | "tablet" | "laptop" | "desktop" | "ultrawide";

export interface BrowserPreset {
id: BrowserPresetId;
label: string;
width: number;
height: number;
}

export const BROWSER_PRESETS: readonly BrowserPreset[] = [
{ id: "mobile", label: "Mobile", width: 390, height: 844 },
{ id: "tablet", label: "Tablet", width: 768, height: 1024 },
{ id: "laptop", label: "Laptop", width: 1366, height: 768 },
{ id: "desktop", label: "Desktop", width: 1920, height: 1080 },
{ id: "ultrawide", label: "Ultrawide", width: 2560, height: 1080 },
] as const;

export function getBrowserPreset(id: BrowserPresetId): BrowserPreset | undefined {
return BROWSER_PRESETS.find((p) => p.id === id);
}
1 change: 1 addition & 0 deletions apps/web/src/previewStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("previewStateStore", () => {
globalOpen: false,
dockByThreadId: {},
sizeByThreadId: {},
presetByThreadId: {},
favoriteUrls: [],
});
storage.clear();
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/previewStateStore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { ThreadId } from "@okcode/contracts";
import { create } from "zustand";

import type { BrowserPresetId } from "./lib/browserPresets";

export type PreviewDock = "left" | "right" | "top" | "bottom";

interface PersistedPreviewUiState {
globalOpen: boolean;
dockByThreadId: Record<string, PreviewDock>;
sizeByThreadId: Record<string, number>;
presetByThreadId: Record<string, BrowserPresetId>;
favoriteUrls: string[];
}

Expand All @@ -16,18 +19,26 @@ interface PreviewStateStore extends PersistedPreviewUiState {
setThreadDock: (threadId: ThreadId, dock: PreviewDock) => void;
toggleThreadLayout: (threadId: ThreadId) => void;
setThreadSize: (threadId: ThreadId, size: number) => void;
setThreadPreset: (threadId: ThreadId, preset: BrowserPresetId | null) => void;
addFavoriteUrl: (url: string) => void;
removeFavoriteUrl: (url: string) => void;
toggleFavoriteUrl: (url: string) => void;
}

const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v3";

const VALID_PRESETS = new Set<string>(["mobile", "tablet", "laptop", "desktop", "ultrawide"]);

function isValidPresetId(value: unknown): value is BrowserPresetId {
return typeof value === "string" && VALID_PRESETS.has(value);
}

function createEmptyPersistedPreviewUiState(): PersistedPreviewUiState {
return {
globalOpen: false,
dockByThreadId: {},
sizeByThreadId: {},
presetByThreadId: {},
favoriteUrls: [],
};
}
Expand Down Expand Up @@ -77,6 +88,15 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState {
}),
)
: {},
presetByThreadId:
parsed.presetByThreadId && typeof parsed.presetByThreadId === "object"
? Object.fromEntries(
Object.entries(parsed.presetByThreadId).filter(
(entry): entry is [string, BrowserPresetId] =>
typeof entry[0] === "string" && isValidPresetId(entry[1]),
),
)
: {},
favoriteUrls: Array.isArray(parsed.favoriteUrls)
? parsed.favoriteUrls.filter(
(u): u is string => typeof u === "string" && u.trim().length > 0,
Expand All @@ -100,6 +120,7 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void {
globalOpen: state.globalOpen,
dockByThreadId: state.dockByThreadId,
sizeByThreadId: state.sizeByThreadId,
presetByThreadId: state.presetByThreadId,
favoriteUrls: state.favoriteUrls,
} satisfies PersistedPreviewUiState),
);
Expand All @@ -113,6 +134,7 @@ function snapshotState(state: PreviewStateStore): PersistedPreviewUiState {
globalOpen: state.globalOpen,
dockByThreadId: state.dockByThreadId,
sizeByThreadId: state.sizeByThreadId,
presetByThreadId: state.presetByThreadId,
favoriteUrls: state.favoriteUrls,
};
}
Expand Down Expand Up @@ -180,6 +202,22 @@ export const usePreviewStateStore = create<PreviewStateStore>((set, get) => ({
});
},

setThreadPreset: (threadId, preset) => {
set((state) => {
const nextPresetByThreadId = { ...state.presetByThreadId };
if (preset === null) {
delete nextPresetByThreadId[threadId];
} else {
nextPresetByThreadId[threadId] = preset;
}
persistPreviewUiState({
...snapshotState(state),
presetByThreadId: nextPresetByThreadId,
});
return { presetByThreadId: nextPresetByThreadId };
});
},

addFavoriteUrl: (url) => {
const normalized = url.trim();
if (normalized.length === 0) return;
Expand Down
Loading