diff options
Diffstat (limited to 'apps/web/src/components')
67 files changed, 4444 insertions, 4725 deletions
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index e70bda37..2dea73e2 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { toast } from "sonner"; import { useEditor } from "@/hooks/use-editor"; -import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons"; +import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; import Image from "next/image"; @@ -118,32 +118,30 @@ function ProjectDropdown() { /> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="z-100 w-52"> + <DropdownMenuContent align="start" className="z-100 w-44"> <DropdownMenuItem - className="flex items-center gap-1.5" onClick={handleExit} disabled={isExiting} + icon={<HugeiconsIcon icon={Logout05Icon} />} > - <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" /> Exit project </DropdownMenuItem> - <DropdownMenuSeparator /> <DropdownMenuItem - className="flex items-center gap-1.5" onClick={() => setOpenDialog("shortcuts")} + icon={<HugeiconsIcon icon={CommandIcon} />} > - <HugeiconsIcon icon={CommandIcon} className="size-4" /> - Keyboard shortcuts + Shortcuts </DropdownMenuItem> - <DropdownMenuItem asChild> + + <DropdownMenuSeparator /> + + <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}> <Link href={SOCIAL_LINKS.discord} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1.5" > - <FaDiscord className="size-4" /> Discord </Link> </DropdownMenuItem> diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index f39c18e2..bc44e650 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -23,7 +23,11 @@ import { type ExportQuality, type ExportResult, } from "@/types/export"; -import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { + Section, + SectionContent, + SectionHeader, +} from "@/components/editor/panels/properties/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; @@ -162,78 +166,83 @@ function ExportPopover({ {!isExporting && ( <> <div className="flex flex-col"> - <PropertyGroup - title="Format" - defaultExpanded={false} - hasBorderTop={false} - > - <RadioGroup - value={format} - onValueChange={(value) => { - if (isExportFormat(value)) { - setFormat(value); - } - }} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="mp4" id="mp4" /> - <Label htmlFor="mp4"> - MP4 (H.264) - Better compatibility - </Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="webm" id="webm" /> - <Label htmlFor="webm"> - WebM (VP9) - Smaller file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <Section collapsible defaultOpen={false} hasBorderTop={false}> + <SectionHeader title="Format" /> + <SectionContent> + <RadioGroup + value={format} + onValueChange={(value) => { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="mp4" id="mp4" /> + <Label htmlFor="mp4"> + MP4 (H.264) - Better compatibility + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="webm" id="webm" /> + <Label htmlFor="webm"> + WebM (VP9) - Smaller file size + </Label> + </div> + </RadioGroup> + </SectionContent> + </Section> - <PropertyGroup title="Quality" defaultExpanded={false}> - <RadioGroup - value={quality} - onValueChange={(value) => { - if (isExportQuality(value)) { - setQuality(value); - } - }} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="low" id="low" /> - <Label htmlFor="low">Low - Smallest file size</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="medium" id="medium" /> - <Label htmlFor="medium">Medium - Balanced</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="high" id="high" /> - <Label htmlFor="high">High - Recommended</Label> - </div> + <Section collapsible defaultOpen={false}> + <SectionHeader title="Quality" /> + <SectionContent> + <RadioGroup + value={quality} + onValueChange={(value) => { + if (isExportQuality(value)) { + setQuality(value); + } + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="low" id="low" /> + <Label htmlFor="low">Low - Smallest file size</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="medium" id="medium" /> + <Label htmlFor="medium">Medium - Balanced</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="high" id="high" /> + <Label htmlFor="high">High - Recommended</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="very_high" id="very_high" /> + <Label htmlFor="very_high"> + Very High - Largest file size + </Label> + </div> + </RadioGroup> + </SectionContent> + </Section> + + <Section collapsible defaultOpen={false}> + <SectionHeader title="Audio" /> + <SectionContent> <div className="flex items-center space-x-2"> - <RadioGroupItem value="very_high" id="very_high" /> - <Label htmlFor="very_high"> - Very High - Largest file size + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => + setIncludeAudio(!!checked) + } + /> + <Label htmlFor="include-audio"> + Include audio in export </Label> </div> - </RadioGroup> - </PropertyGroup> - - <PropertyGroup title="Audio" defaultExpanded={false}> - <div className="flex items-center space-x-2"> - <Checkbox - id="include-audio" - checked={includeAudio} - onCheckedChange={(checked) => - setIncludeAudio(!!checked) - } - /> - <Label htmlFor="include-audio"> - Include audio in export - </Label> - </div> - </PropertyGroup> + </SectionContent> + </Section> </div> <div className="p-3 pt-0"> diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 517fdc92..be4176ce 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -163,7 +163,9 @@ export function DraggableItem({ <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> {preview} </div> - <span className="w-full flex-1 truncate text-sm text-left">{name}</span> + <span className="w-full flex-1 truncate text-sm text-left"> + {name} + </span> </button> </div> )} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index e7bd3d4f..3600d00b 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; import { TabBar } from "./tabbar"; import { Captions } from "./views/captions"; -import { MediaView } from "./views/media"; +import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 31842306..61777a7c 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -61,7 +61,8 @@ export function TabBar() { aria-label={tab.label} className={cn( "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", - activeTab !== tabKey && "border border-transparent text-muted-foreground", + activeTab !== tabKey && + "border border-transparent text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} > diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 59395fd8..808961f1 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; @@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; import { useRevealItem } from "@/hooks/use-reveal-item"; import { processMediaAssets } from "@/lib/media/processing"; -import { - buildImageElement, - buildUploadAudioElement, - buildVideoElement, -} from "@/lib/timeline/element-utils"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; -import type { CreateTimelineElement } from "@/types/timeline"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -132,7 +128,15 @@ export function MediaView() { asset: MediaAsset; startTime: number; }): boolean => { - const element = createElementFromMedia({ asset, startTime }); + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); editor.timeline.insertElement({ element, placement: { mode: "auto" }, @@ -194,171 +198,166 @@ export function MediaView() { const renderCompactPreview = (item: MediaAsset) => previewComponents.get(`compact-${item.id}`); - return ( - <> - <input {...fileInputProps} /> - - <div - className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b"> - <span className="text-muted-foreground text-sm">Assets</span> - <div className="flex items-center gap-0"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> + const mediaActions = ( + <div> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={() => + setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") + } + disabled={isProcessing} + className="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> <Button size="icon" - variant="text" - onClick={() => - setMediaViewMode( - mediaViewMode === "grid" ? "list" : "grid", - ) - } + variant="ghost" disabled={isProcessing} className="items-center justify-center" > - {mediaViewMode === "grid" ? ( - <HugeiconsIcon icon={LeftToRightListDashIcon} /> - ) : ( - <HugeiconsIcon icon={GridViewIcon} /> - )} + <HugeiconsIcon icon={SortingOneNineIcon} /> </Button> - </TooltipTrigger> - <TooltipContent> - <p> - {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} - </p> - </TooltipContent> - <Tooltip> - <DropdownMenu> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="text" - disabled={isProcessing} - className="items-center justify-center" - > - <HugeiconsIcon icon={SortingOneNineIcon} /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <DropdownMenuContent align="end"> - <SortMenuItem - label="Name" - sortKey="name" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Type" - sortKey="type" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Duration" - sortKey="duration" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="File size" - sortKey="size" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - </DropdownMenuContent> - </DropdownMenu> - <TooltipContent> - <p> - Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) - </p> - </TooltipContent> - </Tooltip> - </Tooltip> - </TooltipProvider> - <Button - variant="outline" - onClick={openFilePicker} - disabled={isProcessing} - size="sm" - className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3" - > - <HugeiconsIcon icon={CloudUploadIcon} /> - Import - </Button> - </div> - </div> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={openFilePicker} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5 ml-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Import + </Button> + </div> + ); - <div className="scrollbar-thin size-full overflow-y-auto pt-1"> - <div className="w-full flex-1 p-3 pt-0"> - {isDragOver || filteredMediaItems.length === 0 ? ( - <MediaDragOverlay - isVisible={true} - isProcessing={isProcessing} - progress={progress} - onClick={openFilePicker} - /> - ) : mediaViewMode === "grid" ? ( - <GridView - items={filteredMediaItems} - renderPreview={renderPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> - ) : ( - <ListView - items={filteredMediaItems} - renderPreview={renderCompactPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> - )} - </div> - </div> - </div> + return ( + <> + <input {...fileInputProps} /> + + <PanelView + title="Assets" + actions={mediaActions} + className={isDragOver ? "bg-accent/30" : ""} + {...dragProps} + > + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={openFilePicker} + /> + ) : mediaViewMode === "grid" ? ( + <GridView + items={filteredMediaItems} + renderPreview={renderPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + ) : ( + <ListView + items={filteredMediaItems} + renderPreview={renderCompactPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + )} + </PanelView> </> ); } @@ -636,40 +635,3 @@ function SortMenuItem({ </DropdownMenuItem> ); } - -function createElementFromMedia({ - asset, - startTime, -}: { - asset: MediaAsset; - startTime: number; -}): CreateTimelineElement { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - - switch (asset.type) { - case "video": - return buildVideoElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "image": - return buildImageElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "audio": - return buildUploadAudioElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - default: - throw new Error(`Unsupported media type: ${asset.type}`); - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx new file mode 100644 index 00000000..c613bc2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/utils/ui"; + +interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { + title?: string; + actions?: React.ReactNode; + children: React.ReactNode; + contentClassName?: string; + hideHeader?: boolean; + ref?: React.Ref<HTMLDivElement>; + onScroll?: React.UIEventHandler<HTMLDivElement>; + scrollRef?: React.Ref<HTMLDivElement>; +} + +export function PanelView({ + title, + actions, + children, + className, + contentClassName, + hideHeader = false, + ref, + onScroll, + scrollRef, + ...rest +}: PanelViewProps) { + return ( + <div + className={cn("relative flex h-full flex-col", className)} + ref={ref} + {...rest} + > + {!hideHeader && ( + <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b"> + <span className="text-muted-foreground text-sm">{title}</span> + {actions} + </div> + )} + <div + className={cn( + "scrollbar-thin size-full overflow-y-auto", + hideHeader ? "pt-4" : "pt-2", + )} + ref={scrollRef} + onScroll={onScroll} + > + <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}> + {children} + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index daebf2c6..7a3b16bf 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -108,10 +108,7 @@ export function Captions() { }; return ( - <BaseView - ref={containerRef} - className="flex h-full flex-col justify-between" - > + <PanelView title="Captions" ref={containerRef}> <div className="flex flex-col gap-3"> <Label>Language</Label> <Select @@ -148,6 +145,6 @@ export function Captions() { {isProcessing ? processingStep : "Generate transcript"} </Button> </div> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx new file mode 100644 index 00000000..457c08a9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx @@ -0,0 +1,332 @@ +// @ts-nocheck + +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { useEditorStore } from "@/stores/editor-store"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/section"; + +export function SettingsView() { + return ( + <PanelView title="Project settings" contentClassName="px-0"> + <div className="flex flex-col gap-8"> + <ProjectInfoView /> + <BackgroundView /> + </div> + </PanelView> + ); +} + +function ProjectInfoView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { canvasPresets } = useEditorStore(); + + const findPresetIndexByAspectRatio = ({ + presets, + targetAspectRatio, + }: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; + }) => { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; + }; + + const currentCanvasSize = activeProject.settings.canvasSize; + const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); + const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; + const presetIndex = findPresetIndexByAspectRatio({ + presets: canvasPresets, + targetAspectRatio: currentAspectRatio, + }); + const originalPresetValue = "original"; + const selectedPresetValue = + presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + + const handleAspectRatioChange = ({ value }: { value: string }) => { + if (value === originalPresetValue) { + const canvasSize = originalCanvasSize ?? currentCanvasSize; + editor.project.updateSettings({ + settings: { canvasSize }, + }); + return; + } + const index = parseInt(value, 10); + const preset = canvasPresets[index]; + if (preset) { + editor.project.updateSettings({ settings: { canvasSize: preset } }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }; + + return ( + <div className="flex flex-col gap-4"> + <PropertyItem direction="column"> + <PropertyItemLabel>Name</PropertyItemLabel> + <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Aspect ratio</PropertyItemLabel> + <PropertyItemValue> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} + > + <SelectTrigger> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Frame rate</PropertyItemLabel> + <PropertyItemValue> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( + <button + className={cn( + "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", + isSelected && "border-primary border-2", + )} + onClick={onSelect} + type="button" + aria-label={`Select ${blur.label} blur`} + > + <Image + src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + loading="eager" + /> + <div className="absolute right-1 bottom-1 left-1 text-center"> + <span className="rounded bg-black/50 px-1 text-xs text-white"> + {blur.label} + </span> + </div> + </button> + ), +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: ({ bg }: { bg: string }) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg, index) => ( + <button + key={`${index}-${bg}`} + className={cn( + "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", + isColorBackground && + bg === currentBackgroundColor && + "border-primary border-2", + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => handleColorSelect({ bg })} + type="button" + aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); + + const handleBlurSelect = useCallback( + async ({ blurIntensity }: { blurIntensity: number }) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async ({ color }: { color: string }) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const currentBlurIntensity = + activeProject.settings.background.type === "blur" + ? activeProject.settings.background.blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = + activeProject.settings.background.type === "color" + ? activeProject.settings.background.color + : DEFAULT_COLOR; + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + const backgroundSections = [ + { title: "Colors", backgrounds: colors, useBackgroundColor: true }, + { title: "Pattern craft", backgrounds: patternCraftGradients }, + { title: "Syntax UI", backgrounds: syntaxUIGradients }, + ]; + + return ( + <div className="flex flex-col"> + <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> + </PropertyGroup> + + {backgroundSections.map((section) => ( + <PropertyGroup + key={section.title} + title={section.title} + defaultExpanded={false} + > + <div className="flex flex-wrap gap-2"> + <BackgroundPreviews + backgrounds={section.backgrounds} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + useBackgroundColor={section.useBackgroundColor} + /> + </div> + </PropertyGroup> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 26afe566..7e10ac9d 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -1,8 +1,6 @@ "use client"; -import Image from "next/image"; -import { memo, useCallback, useMemo } from "react"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -10,63 +8,54 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, - FPS_PRESETS, -} from "@/constants/project-constants"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { FPS_PRESETS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { useEditorStore } from "@/stores/editor-store"; import { dimensionToAspectRatio } from "@/utils/geometry"; -import { cn } from "@/utils/ui"; import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "@/components/editor/panels/properties/property-item"; -import { ColorPicker } from "@/components/ui/color-picker"; + Section, + SectionContent, + SectionHeader, +} from "@/components/editor/panels/properties/section"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useState } from "react"; export function SettingsView() { - return <ProjectSettingsTabs />; -} + const [open, setOpen] = useState(false); -function ProjectSettingsTabs() { return ( - <BaseView - defaultTab="project-info" - tabs={[ - { - value: "project-info", - label: "Project info", - content: ( - <div className="p-5"> - <ProjectInfoView /> - </div> - ), - }, - { - value: "background", - label: "Background", - content: ( - <div className="flex h-full flex-col justify-between"> - <div className="flex-1"> - <BackgroundView /> + <PanelView contentClassName="px-0" hideHeader> + <div className="flex flex-col"> + <Section hasBorderTop={false}> + <SectionContent> + <ProjectInfoContent /> + </SectionContent> + </Section> + <Popover open={open} onOpenChange={setOpen}> + <Section className="cursor-pointer"> + <PopoverTrigger asChild> + <div> + <SectionHeader title="Background"> + <div className="size-4 rounded-sm bg-red-500" /> + </SectionHeader> </div> - </div> - ), - }, - ]} - className="flex h-full flex-col justify-between p-0" - /> + </PopoverTrigger> + </Section> + <PopoverContent> + <div className="size-4 rounded-sm bg-red-500" /> + </PopoverContent> + </Popover> + </div> + </PanelView> ); } -function ProjectInfoView() { +function ProjectInfoContent() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); @@ -124,232 +113,55 @@ function ProjectInfoView() { return ( <div className="flex flex-col gap-4"> - <PropertyItem direction="column"> - <PropertyItemLabel>Name</PropertyItemLabel> - <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Aspect ratio</PropertyItemLabel> - <PropertyItemValue> - <Select - value={selectedPresetValue} - onValueChange={(value) => handleAspectRatioChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - <SelectItem value={originalPresetValue}>Original</SelectItem> - {canvasPresets.map((preset, index) => { - const label = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - return ( - <SelectItem key={label} value={index.toString()}> - {label} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Frame rate</PropertyItemLabel> - <PropertyItemValue> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - </div> - ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - <button - className={cn( - "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", - isSelected && "border-primary border-2", - )} - onClick={onSelect} - type="button" - aria-label={`Select ${blur.label} blur`} - > - <Image - src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - loading="eager" - /> - <div className="absolute right-1 bottom-1 left-1 text-center"> - <span className="rounded bg-black/50 px-1 text-xs text-white"> - {blur.label} + <div className="flex flex-col gap-2"> + <Label>Name</Label> + <span className="leading-none text-sm"> + {activeProject.metadata.name} </span> </div> - </button> - ), -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - handleColorSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - handleColorSelect: ({ bg }: { bg: string }) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg, index) => ( - <button - key={`${index}-${bg}`} - className={cn( - "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", - isColorBackground && - bg === currentBackgroundColor && - "border-primary border-2", - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => handleColorSelect({ bg })} - type="button" - aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - handleColorSelect, - useBackgroundColor, - ], - ); - }, -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -function BackgroundView() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); - - const handleBlurSelect = useCallback( - async ({ blurIntensity }: { blurIntensity: number }) => { - await editor.project.updateSettings({ - settings: { background: { type: "blur", blurIntensity } }, - }); - }, - [editor.project], - ); - - const handleColorSelect = useCallback( - async ({ color }: { color: string }) => { - await editor.project.updateSettings({ - settings: { background: { type: "color", color } }, - }); - }, - [editor.project], - ); - - const currentBlurIntensity = - activeProject.settings.background.type === "blur" - ? activeProject.settings.background.blurIntensity - : DEFAULT_BLUR_INTENSITY; - - const currentBackgroundColor = - activeProject.settings.background.type === "color" - ? activeProject.settings.background.color - : DEFAULT_COLOR; - - const isBlurBackground = activeProject.settings.background.type === "blur"; - const isColorBackground = activeProject.settings.background.type === "color"; - - const blurPreviews = useMemo( - () => - blurLevels.map((blur) => ( - <BlurPreview - key={blur.value} - blur={blur} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} - /> - )), - [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], - ); - - const backgroundSections = [ - { title: "Colors", backgrounds: colors, useBackgroundColor: true }, - { title: "Pattern craft", backgrounds: patternCraftGradients }, - { title: "Syntax UI", backgrounds: syntaxUIGradients }, - ]; - - return ( - <div className="flex h-full flex-col"> - <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> - <div className="flex flex-wrap gap-2">{blurPreviews}</div> - </PropertyGroup> - - {backgroundSections.map((section) => ( - <PropertyGroup - key={section.title} - title={section.title} - defaultExpanded={false} + <div className="flex flex-col gap-2"> + <Label>Aspect ratio</Label> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} > - <div className="flex flex-wrap gap-2"> - <BackgroundPreviews - backgrounds={section.backgrounds} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - useBackgroundColor={section.useBackgroundColor} - /> - </div> - </PropertyGroup> - ))} + <SelectTrigger className="w-fit"> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div className="flex flex-col gap-2"> + <Label>Frame rate</Label> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-fit"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> </div> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 30c70a83..b1c57c3d 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -235,7 +235,7 @@ function SoundEffectsView() { <div className="flex items-center gap-3"> <Input placeholder="Search sound effects" - className="bg-accent w-full" + className="w-full" containerClassName="w-full" value={searchQuery} onChange={({ currentTarget }) => diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 68ba91d2..671effec 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -4,144 +4,104 @@ import Image from "next/image"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Button } from "@/components/ui/button"; -import { InputWithBack } from "@/components/ui/input-with-back"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { - buildIconSvgUrl, - getIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; + resolveStickerId, + type StickerItem as StickerData, +} from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; -import type { StickerCategory } from "@/types/stickers"; import { cn } from "@/utils/ui"; import { - ArrowRightIcon, HappyIcon, ClockIcon, - LayoutGridIcon, MultiplicationSignIcon, - SparklesIcon, - HashtagIcon, + Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Spinner } from "@/components/ui/spinner"; - -function isStickerCategory(value: string): value is StickerCategory { - return STICKER_CATEGORIES.includes(value as StickerCategory); -} +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OcSlidersVerticalIcon } from "@opencut/ui/icons"; +import type { StickerCategory } from "@/types/stickers"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { parseStickerId } from "@/lib/stickers/sticker-id"; export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( - <BaseView - value={selectedCategory} - onValueChange={(v) => { - if (isStickerCategory(v)) { - setSelectedCategory({ category: v }); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, - content: <StickersContentView category="all" />, - }, - { - value: "general", - label: "Icons", - icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, - content: <StickersContentView category="general" />, - }, - { - value: "brands", - label: "Brands", - icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, - content: <StickersContentView category="brands" />, - }, - { - value: "emoji", - label: "Emoji", - icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, - content: <StickersContentView category="emoji" />, - }, - ]} - className="flex h-full flex-col overflow-hidden p-0" - /> + <PanelView + title="Stickers" + actions={ + <div className="flex items-center"> + <Select + value={selectedCategory} + onValueChange={(value: StickerCategory) => + setSelectedCategory({ category: value }) + } + > + <SelectTrigger variant="outline" size="sm" className="mr-1.5"> + <SelectValue placeholder="All" /> + </SelectTrigger> + <SelectContent> + {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( + <SelectItem key={category} value={category}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button variant="ghost" size="icon"> + <HugeiconsIcon icon={Search01Icon} className="!size-3.5" /> + </Button> + + <Button variant="ghost" size="icon"> + <OcSlidersVerticalIcon className="!size-3.5" /> + </Button> + </div> + } + > + <StickersContentView /> + </PanelView> ); } function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, + items, + shouldCapSize = false, }: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; + items: StickerData[]; + shouldCapSize?: boolean; }) { const gridStyle: CSSProperties & { "--sticker-min": string; "--sticker-max"?: string; } = { - gridTemplateColumns: capSize + gridTemplateColumns: shouldCapSize ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", "--sticker-min": "96px", - ...(capSize ? { "--sticker-max": "160px" } : {}), + ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), }; return ( <div className="grid gap-2" style={gridStyle}> - {icons.map((iconName) => ( - <StickerItem - key={iconName} - iconName={iconName} - onAdd={onAdd} - isAdding={addingSticker === iconName} - capSize={capSize} - /> - ))} - </div> - ); -} - -function CollectionGrid({ - collections, - onSelectCollection, -}: { - collections: Array<{ - prefix: string; - name: string; - total: number; - category?: string; - }>; - onSelectCollection: ({ prefix }: { prefix: string }) => void; -}) { - return ( - <div className="grid grid-cols-1 gap-2"> - {collections.map((collection) => ( - <CollectionItem - key={collection.prefix} - title={collection.name} - subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} - onClick={() => onSelectCollection({ prefix: collection.prefix })} - /> + {items.map((item) => ( + <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> ))} </div> ); @@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) { ); } -function StickersContentView({ category }: { category: StickerCategory }) { +function StickersContentView() { const { searchQuery, - selectedCollection, viewMode, - collections, - currentCollection, searchResults, recentStickers, - isLoadingCollections, - isLoadingCollection, isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, clearRecentStickers, - setSelectedCategory, - addingSticker, } = useStickersStore(); - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - const filteredCollections = useMemo(() => { - if (category === "all") { - return Object.entries(collections).map(([prefix, collection]) => ({ - prefix, - name: collection.name, - total: collection.total, - category: collection.category, - })); - } - - const collectionList = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - name: c.name, - total: collection.total, - } - : null; - }) - .filter(Boolean) as Array<{ - prefix: string; - name: string; - total: number; - }>; - }, [collections, category]); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: () => setCollectionsToShow((prev) => prev + 20), - hasMore: filteredCollections.length > collectionsToShow, - isLoading: isLoadingCollections, - enabled: viewMode === "browse" && !selectedCollection && category === "all", - }); - - useEffect(() => { - if (Object.keys(collections).length === 0) { - loadCollections(); - } - }, [collections, loadCollections]); - - useEffect(() => { - const timer = setTimeout(() => { - if (localSearchQuery !== searchQuery) { - setSearchQuery({ query: localSearchQuery }); - if (localSearchQuery.trim()) { - searchStickers({ query: localSearchQuery }); - } - } - }, 500); - - return () => clearTimeout(timer); - }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline({ iconName }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { + const itemsToDisplay = useMemo(() => { if (viewMode === "search" && searchResults) { - return searchResults.icons; - } - - if (viewMode === "collection" && currentCollection) { - const icons: string[] = []; - - if (currentCollection.uncategorized) { - icons.push( - ...currentCollection.uncategorized.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - } - - if (currentCollection.categories) { - Object.values(currentCollection.categories).forEach((categoryIcons) => { - icons.push( - ...categoryIcons.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - }); - } - - return icons.slice(0, 100); + return searchResults.items; } return []; - }, [viewMode, searchResults, currentCollection]); + }, [viewMode, searchResults]); - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); + const recentStickerItems = useMemo(() => { + const items: StickerData[] = []; + for (const stickerId of recentStickers) { + const recentStickerItem = toRecentStickerItem({ stickerId }); + if (recentStickerItem) { + items.push(recentStickerItem); + } } - }, [isInCollection]); + return items; + }, [recentStickers]); return ( - <div className="mt-1 flex h-full flex-col gap-5 p-4"> - <div className="space-y-3"> - <InputWithBack - isExpanded={isInCollection} - setIsExpanded={(expanded) => { - if (!expanded && isInCollection) { - setSelectedCollection({ collection: null }); - } - }} - placeholder={ - category === "all" - ? "Search all stickers" - : category === "general" - ? "Search icons" - : category === "brands" - ? "Search brands" - : "Search Emojis" - } - value={localSearchQuery} - onChange={setLocalSearchQuery} - disableAnimation={true} - /> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="h-full flex-1" - ref={scrollAreaRef} - onScrollCapture={handleScroll} - > - <div className="flex h-full flex-col gap-4"> - {recentStickers.length > 0 && viewMode === "browse" && ( - <div className="h-full"> - <div className="mb-2 flex items-center gap-2"> - <HugeiconsIcon - icon={ClockIcon} - className="text-muted-foreground size-4" - /> - <span className="text-sm font-medium">Recent</span> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <button - type="button" - onClick={clearRecentStickers} - className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" - > - <HugeiconsIcon - icon={MultiplicationSignIcon} - className="text-muted-foreground size-3" - /> - </button> - </TooltipTrigger> - <TooltipContent> - <p>Clear recent stickers</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <StickerGrid - icons={recentStickers.slice(0, 12)} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize - /> - </div> - )} - - {viewMode === "collection" && selectedCollection && ( - <div className="h-full"> - {isLoadingCollection ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : showCollectionItems ? ( - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - /> - ) : ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - )} - </div> - )} - - {viewMode === "search" && ( - <div className="h-full"> - {isSearching ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : searchResults?.icons.length ? ( - <> - <div className="mb-3 flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize + <div className="flex h-full flex-col gap-4"> + {recentStickerItems.length > 0 && viewMode === "browse" && ( + <div className="flex h-full flex-col gap-2"> + <div className="flex items-center gap-2"> + <HugeiconsIcon + icon={ClockIcon} + className="text-muted-foreground size-4" + /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={clearRecentStickers} + className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" + > + <HugeiconsIcon + icon={MultiplicationSignIcon} + className="text-muted-foreground size-3" /> - </> - ) : searchQuery ? ( - <div className="flex flex-col items-center justify-center gap-3 py-8"> - <EmptyView - message={`No stickers found for "${searchQuery}"`} - /> - {category !== "all" && ( - <Button - variant="outline" - onClick={() => { - const q = localSearchQuery || searchQuery; - if (q) { - setSearchQuery({ query: q }); - } - setSelectedCategory({ category: "all" }); - if (q) { - searchStickers({ query: q }); - } - }} - > - Search in all icons - </Button> - )} - </div> - ) : null} - </div> - )} - - {viewMode === "browse" && !selectedCollection && ( - <div className="h-full space-y-4"> - {isLoadingCollections ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : ( - <> - {category !== "all" && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections} - onSelectCollection={({ prefix }) => - setSelectedCollection({ collection: prefix }) - } - /> - </div> - )} + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize /> + </div> + )} - {category === "all" && filteredCollections.length > 0 && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections.slice( - 0, - collectionsToShow, - )} - onSelectCollection={({ prefix }) => - setSelectedCollection({ collection: prefix }) - } - /> - </div> - )} - </> - )} + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : searchResults?.items.length ? ( + <div className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> </div> - )} - </div> - </ScrollArea> - </div> + <StickerGrid items={itemsToDisplay} shouldCapSize /> + </div> + ) : searchQuery ? ( + <EmptyView message={`No stickers found for "${searchQuery}"`} /> + ) : null} + </div> + )} </div> ); } -interface CollectionItemProps { - title: string; - subtitle: string; - onClick: () => void; -} - -function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { - return ( - <Button - variant="outline" - className="h-auto justify-between rounded-md py-2" - onClick={onClick} - > - <div className="text-left"> - <p className="font-medium">{title}</p> - <p className="text-muted-foreground text-xs">{subtitle}</p> - </div> - <HugeiconsIcon icon={ArrowRightIcon} className="size-4" /> - </Button> - ); -} - interface StickerItemProps { - iconName: string; - onAdd: (iconName: string) => void; - isAdding?: boolean; - capSize?: boolean; + item: StickerData; + shouldCapSize?: boolean; } -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); +function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { + const { addingSticker, addStickerToTimeline } = useStickersStore(); + const isAdding = addingSticker === item.id; + const [hasImageError, setHasImageError] = useState(false); useEffect(() => { - if (!iconName) { + if (!item.id) { return; } - setImageError(false); - setHostIndex(0); - }, [iconName]); + setHasImageError(false); + }, [item.id]); - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; + const displayName = item.name; + + const handleAdd = async () => { + try { + await addStickerToTimeline({ + stickerId: item.id, + name: item.name, + }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; - const preview = imageError ? ( + const preview = hasImageError ? ( <div className="flex size-full items-center justify-center p-2"> <span className="text-muted-foreground text-center text-xs break-all"> {displayName} @@ -536,21 +249,13 @@ function StickerItem({ ) : ( <div className="flex size-full items-center justify-center p-4"> <Image - src={ - hostIndex === 0 - ? getIconSvgUrl(iconName, { width: 64, height: 64 }) - : buildIconSvgUrl( - ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], - iconName, - { width: 64, height: 64 }, - ) - } + src={item.previewUrl} alt={displayName} width={64} height={64} className="size-full object-contain" style={ - capSize + shouldCapSize ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)", @@ -558,12 +263,7 @@ function StickerItem({ : undefined } onError={() => { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } + setHasImageError(true); }} loading="lazy" unoptimized @@ -572,44 +272,63 @@ function StickerItem({ ); return ( - <Tooltip> - <TooltipTrigger asChild> - <div - className={cn( - "relative", - isAdding && "pointer-events-none opacity-50", - )} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={{ - id: iconName, - type: "sticker", - name: displayName, - iconName, - }} - onAddToTimeline={() => onAdd(iconName)} - aspectRatio={1} - shouldShowLabel={false} - isRounded={true} - variant="card" - className="" - containerClassName="w-full" - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - </TooltipTrigger> - <TooltipContent> - <div className="space-y-1"> - <p className="font-medium">{displayName}</p> - <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + <div + className={cn("relative", isAdding && "pointer-events-none opacity-50")} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }} + onAddToTimeline={handleAdd} + aspectRatio={1} + shouldShowLabel={false} + isRounded + variant="card" + containerClassName="w-full" + /> + {isAdding && ( + <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> + <Spinner className="size-6 text-white" /> </div> - </TooltipContent> - </Tooltip> + )} + </div> + ); +} + +function getStickerNameFromId({ stickerId }: { stickerId: string }): string { + const stickerIdParts = stickerId.split(":"); + if (stickerIdParts.length <= 1) { + return stickerId; + } + return ( + stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? + stickerId ); } + +function toRecentStickerItem({ + stickerId, +}: { + stickerId: string; +}): StickerData | null { + try { + const { providerId } = parseStickerId({ stickerId }); + return { + id: stickerId, + provider: providerId, + name: getStickerNameFromId({ stickerId }), + previewUrl: resolveStickerId({ + stickerId, + options: { width: 64, height: 64 }, + }), + metadata: {}, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 6360c363..3673acc3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,5 +1,5 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { buildTextElement } from "@/lib/timeline/element-utils"; @@ -23,7 +23,7 @@ export function TextView() { }; return ( - <BaseView> + <PanelView title="Text"> <DraggableItem name="Default text" preview={ @@ -41,6 +41,6 @@ export function TextView() { onAddToTimeline={handleAddToTimeline} shouldShowLabel={false} /> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx deleted file mode 100644 index dd6151d3..00000000 --- a/apps/web/src/components/editor/panels/panel-base-view.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/utils/ui"; - -interface PanelBaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - value?: string; - onValueChange?: (value: string) => void; - tabs?: { - value: string; - label: string; - icon?: React.ReactNode; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.Ref<HTMLDivElement>; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <ScrollArea className="flex-1 scrollbar-hidden"> - <div className={cn("p-5", className)}>{children}</div> - </ScrollArea> - ); -} - -export function PanelBaseView({ - children, - defaultTab, - value, - onValueChange, - tabs, - className = "", - ref, -}: PanelBaseViewProps) { - return ( - <div className={cn("flex h-full flex-col", className)} ref={ref}> - {!tabs || tabs.length === 0 ? ( - <ViewContent className={className}>{children}</ViewContent> - ) : ( - <Tabs - defaultValue={defaultTab} - value={value} - onValueChange={onValueChange} - className="flex h-full flex-col" - > - <div className="bg-background sticky top-0 z-10"> - <div className="px-3 pt-3 pb-0"> - <TabsList> - {tabs.map((tab) => ( - <TabsTrigger key={tab.value} value={tab.value}> - {tab.icon ? ( - <span className="mr-1 inline-flex items-center"> - {tab.icon} - </span> - ) : null} - {tab.label} - </TabsTrigger> - ))} - </TabsList> - </div> - <Separator className="mt-3" /> - </div> - {tabs.map((tab) => ( - <TabsContent - key={tab.value} - value={tab.value} - className="mt-0 flex min-h-0 flex-1 flex-col" - > - <ViewContent className={className}>{tab.content}</ViewContent> - </TabsContent> - ))} - </Tabs> - )} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx new file mode 100644 index 00000000..4ace25ac --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { findCurrentScene } from "@/lib/scenes"; +import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; + +export function BookmarkNoteOverlay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const activeProject = editor.project.getActive(); + + if (!activeProject) { + return null; + } + + const activeScene = findCurrentScene({ + scenes: activeProject.scenes, + currentSceneId: activeProject.currentSceneId, + }); + + if (!activeScene) { + return null; + } + + const bookmarks = activeScene.bookmarks; + const activeBookmarks = getBookmarksActiveAtTime({ + bookmarks, + time: currentTime, + }); + const bookmarksWithNotes = activeBookmarks.filter( + (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", + ); + + if (bookmarksWithNotes.length === 0) { + return null; + } + + return ( + <div + className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5" + aria-live="polite" + > + {bookmarksWithNotes.map((bookmark) => ( + <div + key={bookmark.time} + className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm" + style={{ + backgroundColor: "rgb(0 0 0 / 0.5)", + borderLeft: bookmark.color + ? `3px solid ${bookmark.color}` + : "3px solid var(--primary)", + }} + > + {bookmark.note} + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx new file mode 100644 index 00000000..d5cf535a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, +} from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; + +export function PreviewContextMenu({ + onToggleFullscreen, + containerRef, +}: { + onToggleFullscreen: () => void; + containerRef: React.RefObject<HTMLElement | null>; +}) { + const { overlays, setOverlayVisibility } = usePreviewStore(); + + return ( + <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuItem onClick={onToggleFullscreen} inset> + Full screen + </ContextMenuItem> + <ContextMenuCheckboxItem + checked={overlays.bookmarks} + onCheckedChange={(checked) => + setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) + } + > + Show bookmarks + </ContextMenuCheckboxItem> + <ContextMenuItem inset>Show grid</ContextMenuItem> + </ContextMenuContent> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 902547bd..90cb11a3 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { formatTimeCode, getLastFrameTime } from "@/lib/time"; +import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { invokeAction } from "@/lib/actions"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { cn } from "@/utils/ui"; +import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; +import { PreviewContextMenu } from "./context-menu"; +import { PreviewToolbar } from "./toolbar"; function usePreviewSize() { const editor = useEditor(); @@ -32,6 +27,30 @@ function usePreviewSize() { }; } +export function PreviewPanel() { + const containerRef = useRef<HTMLDivElement>(null); + const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + + return ( + <div + ref={containerRef} + className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" + > + <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> + <PreviewCanvas + onToggleFullscreen={toggleFullscreen} + containerRef={containerRef} + /> + <RenderTreeController /> + </div> + <PreviewToolbar + isFullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} + /> + </div> + ); +} + function RenderTreeController() { const editor = useEditor(); const tracks = editor.timeline.getTracks(); @@ -58,98 +77,24 @@ function RenderTreeController() { return null; } -export function PreviewPanel() { - const containerRef = useRef<HTMLDivElement>(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); - - return ( - <div - ref={containerRef} - className={cn( - "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border", - isFullscreen && "bg-background", - )} - > - <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> - <PreviewCanvas /> - <RenderTreeController /> - </div> - <PreviewToolbar - isFullscreen={isFullscreen} - onToggleFullscreen={toggleFullscreen} - /> - </div> - ); -} - -function PreviewToolbar({ - isFullscreen, +function PreviewCanvas({ onToggleFullscreen, + containerRef, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; + containerRef: React.RefObject<HTMLElement | null>; }) { - const editor = useEditor(); - const isPlaying = editor.playback.getIsPlaying(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; - - return ( - <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> - <div className="flex items-center mt-1"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <span className="text-muted-foreground px-2 font-mono text-xs">/</span> - <span className="text-muted-foreground font-mono text-xs"> - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </span> - </div> - - <Button - variant="text" - size="icon" - type="button" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - - <div className="justify-self-end"> - <Button - variant="text" - size="icon" - type="button" - onClick={onToggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} - > - <HugeiconsIcon icon={FullScreenIcon} /> - </Button> - </div> - </div> - ); -} - -function PreviewCanvas() { const canvasRef = useRef<HTMLCanvasElement>(null); - const containerRef = useRef<HTMLDivElement>(null); + const outerContainerRef = useRef<HTMLDivElement>(null); + const canvasBoundsRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef }); + const containerSize = useContainerSize({ containerRef: outerContainerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); + const { overlays } = usePreviewStore(); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -224,24 +169,42 @@ function PreviewCanvas() { return ( <div - ref={containerRef} - className="relative flex h-full w-full items-center justify-center" + ref={outerContainerRef} + className="relative flex size-full items-center justify-center" > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="block border" - style={{ - width: displaySize.width, - height: displaySize.height, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> - <PreviewInteractionOverlay canvasRef={canvasRef} /> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={canvasBoundsRef} + className="relative" + style={{ width: displaySize.width, height: displaySize.height }} + > + <canvas + ref={canvasRef} + width={nativeWidth} + height={nativeHeight} + className="block border" + style={{ + width: displaySize.width, + height: displaySize.height, + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + <PreviewInteractionOverlay + canvasRef={canvasRef} + containerRef={canvasBoundsRef} + /> + {overlays.bookmarks && <BookmarkNoteOverlay />} + </div> + </ContextMenuTrigger> + <PreviewContextMenu + onToggleFullscreen={onToggleFullscreen} + containerRef={containerRef} + /> + </ContextMenu> </div> ); } diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx index 0f02da1c..44efb4c8 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx @@ -1,27 +1,27 @@ -"use client";
-
-import { useEditorStore } from "@/stores/editor-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
- <div className="pointer-events-none absolute inset-0">
- <Image
- src="/platform-guides/tiktok-blueprint.png"
- alt="TikTok layout guide"
- className="absolute inset-0 size-full object-contain"
- draggable={false}
- fill
- />
- </div>
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = useEditorStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
+"use client"; + +import { usePreviewStore } from "@/stores/preview-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( + <div className="pointer-events-none absolute inset-0"> + <Image + src="/platform-guides/tiktok-blueprint.png" + alt="TikTok layout guide" + className="absolute inset-0 size-full object-contain" + draggable={false} + fill + /> + </div> + ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = usePreviewStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return <TikTokGuide />; + + return null; +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index c13379f0..4c33a1a1 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,20 +1,54 @@ import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import { cn } from "@/utils/ui"; +import { TransformHandles } from "./transform-handles"; +import { SnapGuides } from "./snap-guides"; +import { TextEditOverlay } from "./text-edit-overlay"; export function PreviewInteractionOverlay({ canvasRef, + containerRef, }: { canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; }) { - const { onPointerDown, onPointerMove, onPointerUp } = - usePreviewInteraction({ canvasRef }); + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, + } = usePreviewInteraction({ canvasRef }); return ( - <div - className={cn("absolute inset-0 pointer-events-auto")} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> + <div className="absolute inset-0"> + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} + <div + className="absolute inset-0 pointer-events-auto" + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onDoubleClick={onDoubleClick} + /> + {editingText ? ( + <TextEditOverlay + canvasRef={canvasRef} + containerRef={containerRef} + trackId={editingText.trackId} + elementId={editingText.elementId} + element={editingText.element} + onCommit={commitTextEdit} + onCancel={cancelTextEdit} + /> + ) : ( + <TransformHandles canvasRef={canvasRef} containerRef={containerRef} /> + )} + <SnapGuides + lines={snapLines} + canvasRef={canvasRef} + containerRef={containerRef} + /> + </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx new file mode 100644 index 00000000..4aa714a1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { positionToOverlay } from "@/lib/preview/preview-coords"; + +export function SnapGuides({ + lines, + canvasRef, + containerRef, +}: { + lines: SnapLine[]; + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}) { + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (!canvasRect || !containerRect || lines.length === 0) { + return null; + } + + const toOverlayX = (logicalX: number) => + positionToOverlay({ + positionX: logicalX, + positionY: 0, + canvasRect, + containerRect, + canvasSize, + }).x; + + const toOverlayY = (logicalY: number) => + positionToOverlay({ + positionX: 0, + positionY: logicalY, + canvasRect, + containerRect, + canvasSize, + }).y; + + return ( + <div className="pointer-events-none absolute inset-0" aria-hidden> + {lines.map((line) => { + if (line.type === "vertical") { + return ( + <div + key={`vertical-${line.position}`} + className="absolute top-0 bottom-0 w-px bg-white/70" + style={{ left: toOverlayX(line.position) }} + /> + ); + } + return ( + <div + key={`horizontal-${line.position}`} + className="absolute left-0 right-0 h-px bg-white/70" + style={{ top: toOverlayY(line.position) }} + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx new file mode 100644 index 00000000..265e972d --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { TextElement } from "@/types/timeline"; +import { + positionToOverlay, + getDisplayScale, +} from "@/lib/preview/preview-coords"; +import { + DEFAULT_LINE_HEIGHT, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; + +const TEXT_BACKGROUND_PADDING = "4px 8px"; +const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + +export function TextEditOverlay({ + canvasRef, + containerRef, + trackId, + elementId, + element, + onCommit, + onCancel, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; + onCancel: () => void; +}) { + const editor = useEditor(); + const divRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCancel(); + return; + } + }, + [onCancel], + ); + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasRect || !containerRect || !canvasSize) return null; + + const { x: posX, y: posY } = positionToOverlay({ + positionX: element.transform.position.x, + positionY: element.transform.position.y, + canvasRect, + containerRect, + canvasSize, + }); + + const { x: displayScaleX } = getDisplayScale({ + canvasRect, + canvasSize, + }); + + const displayFontSize = + element.fontSize * + (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * + displayScaleX; + + const verticalAlignmentOffset = + displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; + + const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const letterSpacing = element.letterSpacing ?? 0; + const backgroundColor = + element.backgroundColor === "transparent" + ? "transparent" + : element.backgroundColor; + + return ( + <div + className="absolute" + style={{ + left: posX, + top: posY - verticalAlignmentOffset, + transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`, + transformOrigin: "center center", + }} + > + {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} + <div + ref={divRef} + contentEditable + suppressContentEditableWarning + tabIndex={0} + role="textbox" + aria-label="Edit text" + className="cursor-text select-text outline-none whitespace-pre" + style={{ + fontSize: displayFontSize, + fontFamily: element.fontFamily, + fontWeight, + fontStyle, + textAlign: element.textAlign, + letterSpacing: `${letterSpacing}px`, + lineHeight, + color: element.color, + backgroundColor, + minHeight: displayFontSize * lineHeight, + textDecoration: element.textDecoration ?? "none", + padding: + backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING, + minWidth: 1, + }} + onInput={handleInput} + onBlur={onCommit} + onKeyDown={handleKeyDown} + > + {element.content || ""} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx new file mode 100644 index 00000000..7f5cd9bc --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { formatTimeCode } from "@/lib/time"; +import { invokeAction } from "@/lib/actions"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { OcSocialIcon } from "@opencut/ui/icons"; +import { Separator } from "@/components/ui/separator"; + +export function PreviewToolbar({ + isFullscreen, + onToggleFullscreen, +}: { + isFullscreen: boolean; + onToggleFullscreen: () => void; +}) { + const editor = useEditor(); + const isPlaying = editor.playback.getIsPlaying(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( + <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> + <div className="flex items-center"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <span className="text-muted-foreground px-2 font-mono text-xs">/</span> + <span className="text-muted-foreground font-mono text-xs"> + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </span> + </div> + + <Button + variant="text" + size="icon" + onClick={() => invokeAction("toggle-play")} + > + <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> + </Button> + + <div className="justify-self-end flex items-center gap-2.5"> + <Button + variant="secondary" + size="sm" + className="[&_svg]:size-auto px-1 h-7" + onClick={onToggleFullscreen} + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + <OcSocialIcon size={20} /> + </Button> + <Separator orientation="vertical" className="h-4" /> + <Button + variant="text" + onClick={onToggleFullscreen} + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + <HugeiconsIcon icon={FullScreenIcon} /> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx new file mode 100644 index 00000000..976c3019 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useTransformHandles } from "@/hooks/use-transform-handles"; +import { useEditor } from "@/hooks/use-editor"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { SnapGuides } from "./snap-guides"; +import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Rotate01Icon } from "@hugeicons/core-free-icons"; + +const HANDLE_SIZE = 10; +const ROTATION_HANDLE_OFFSET = 24; +const ROTATION_HANDLE_RADIUS = 10; +const CORNER_HIT_AREA_SIZE = 18; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +const CORNERS: Corner[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; + +function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { + x: number; + y: number; +} { + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; + return { + x: bounds.cx - localY * sin, + y: bounds.cy + localY * cos, + }; +} + +function getOverlayContext({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}): { + canvasRect: DOMRect; + containerRect: DOMRect; +} | null { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!canvasRect || !containerRect) return null; + return { canvasRect, containerRect }; +} + +export function TransformHandles({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}) { + const { + selectedWithBounds, + hasVisualSelection, + snapLines, + handleCornerPointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + } = useTransformHandles({ canvasRef }); + + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!hasVisualSelection || !selectedWithBounds) return null; + + const overlayContext = getOverlayContext({ canvasRef, containerRef }); + if (!overlayContext) return null; + + const { bounds, element } = selectedWithBounds; + if (!isVisualElement(element)) return null; + + const { canvasRect, containerRect } = overlayContext; + const displayScale = getDisplayScale({ canvasRect, canvasSize }); + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + canvasToOverlay({ + canvasX, + canvasY, + canvasRect, + containerRect, + canvasSize, + }); + + const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); + const outlineWidth = bounds.width * displayScale.x; + const outlineHeight = bounds.height * displayScale.y; + + const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleScreen = toOverlay({ + canvasX: rotationHandle.x, + canvasY: rotationHandle.y, + }); + + return ( + <div className="pointer-events-none absolute inset-0" aria-hidden> + <SnapGuides + lines={snapLines} + canvasRef={canvasRef} + containerRef={containerRef} + /> + <BoundingBoxOutline + center={center} + outlineWidth={outlineWidth} + outlineHeight={outlineHeight} + rotation={bounds.rotation} + /> + {CORNERS.map((corner) => { + const cornerPosition = getCornerPosition({ bounds, corner }); + const screen = toOverlay({ + canvasX: cornerPosition.x, + canvasY: cornerPosition.y, + }); + return ( + <CornerHandle + key={corner} + corner={corner} + screen={screen} + onPointerDown={(event) => + handleCornerPointerDown({ event, corner }) + } + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> + ); + })} + <RotationHandle + screen={rotationHandleScreen} + onPointerDown={(event) => handleRotationPointerDown({ event })} + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> + </div> + ); +} + +function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; +}) { + return ( + <div + className="pointer-events-none absolute" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + border: "1px solid white", + boxSizing: "border-box", + opacity: 0.75, + }} + /> + ); +} + +function CornerHandle({ + corner, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + corner: Corner; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isNesw = corner === "top-right" || corner === "bottom-left"; + + return ( + <button + type="button" + className={cn( + "absolute flex items-center justify-center outline-none", + isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize", + )} + style={{ + left: screen.x - CORNER_HIT_AREA_SIZE / 2, + top: screen.y - CORNER_HIT_AREA_SIZE / 2, + width: CORNER_HIT_AREA_SIZE, + height: CORNER_HIT_AREA_SIZE, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + <div + className="rounded-sm bg-white" + style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} + /> + </button> + ); +} + +function RotationHandle({ + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <button + type="button" + className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none" + style={{ + left: screen.x - ROTATION_HANDLE_RADIUS, + top: screen.y - ROTATION_HANDLE_RADIUS, + width: ROTATION_HANDLE_RADIUS * 2, + height: ROTATION_HANDLE_RADIUS * 2, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} /> + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index 8ccf07ee..c5576dda 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -17,4 +17,4 @@ export function EmptyView() { </div> </div> ); -}
\ No newline at end of file +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts new file mode 100644 index 00000000..395666cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -0,0 +1,72 @@ +import { useReducer, useRef } from "react"; +import { evaluateMathExpression } from "@/utils/math"; + +function looksLikeExpression({ input }: { input: string }): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + if (/[+*/]/.test(input)) return true; + const minusIndex = trimmed.indexOf("-"); + return minusIndex > 0; +} + +export function usePropertyDraft<T>({ + displayValue: sourceDisplay, + parse, + onPreview, + onCommit, + supportsExpressions = true, +}: { + displayValue: string; + parse: (input: string) => T | null; + onPreview: (value: T) => void; + onCommit: () => void; + supportsExpressions?: boolean; +}) { + const [, forceRender] = useReducer( + (renderVersion: number) => renderVersion + 1, + 0, + ); + const isEditing = useRef(false); + const draft = useRef(""); + + return { + displayValue: isEditing.current ? draft.current : sourceDisplay, + scrubTo: (value: number) => { + const parsed = parse(String(value)); + if (parsed !== null) onPreview(parsed); + }, + commitScrub: onCommit, + onFocus: () => { + isEditing.current = true; + draft.current = sourceDisplay; + forceRender(); + }, + onChange: ( + event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ) => { + draft.current = event.target.value; + forceRender(); + + const parsed = parse(event.target.value); + if (parsed !== null) { + onPreview(parsed); + } + }, + onBlur: () => { + if ( + supportsExpressions && + looksLikeExpression({ input: draft.current }) + ) { + const evaluated = evaluateMathExpression({ input: draft.current }); + if (evaluated !== null) { + const parsed = parse(String(evaluated)); + if (parsed !== null) onPreview(parsed); + } + } + onCommit(); + isEditing.current = false; + draft.current = ""; + forceRender(); + }, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx deleted file mode 100644 index a682097a..00000000 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from "react"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( - <div - className={cn( - "flex gap-2", - direction === "row" - ? "items-center justify-between gap-6" - : "flex-col gap-1.5", - className, - )} - > - {children} - </div> - ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <span className={cn("text-muted-foreground text-xs", className)}> - {children} - </span> - ); -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={cn("flex-1 text-sm", className)}>{children}</div>; -} - -interface PropertyGroupProps { - title: string; - children: React.ReactNode; - defaultExpanded?: boolean; - collapsible?: boolean; - className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; -} - -export function PropertyGroup({ - title, - children, - defaultExpanded = true, - collapsible = true, - className, - hasBorderTop = true, - hasBorderBottom = true, -}: PropertyGroupProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - return ( - <div - className={cn( - "flex flex-col", - hasBorderTop && "border-t", - hasBorderBottom && "last:border-b", - className, - )} - > - {collapsible ? ( - <button - type="button" - className="flex items-center justify-between p-3.5 cursor-pointer" - onClick={() => setIsExpanded(!isExpanded)} - > - <PropertyGroupTitle isExpanded={isExpanded}> - {title} - </PropertyGroupTitle> - <HugeiconsIcon - icon={isExpanded ? MinusSignIcon : PlusSignIcon} - className={cn( - "size-3", - isExpanded ? "text-foreground" : "text-muted-foreground", - )} - /> - </button> - ) : ( - <div className="flex items-center justify-between p-4"> - <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle> - </div> - )} - {(collapsible ? isExpanded : true) && ( - <div className="p-3 pt-0">{children}</div> - )} - </div> - ); -} - -function PropertyGroupTitle({ - children, - isExpanded = false, -}: { - children: React.ReactNode; - isExpanded?: boolean; -}) { - return ( - <span - className={cn( - "text-xs font-medium", - isExpanded ? "text-foreground" : "text-muted-foreground", - )} - > - {children} - </span> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx new file mode 100644 index 00000000..0b45065f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -0,0 +1,161 @@ +import { createContext, useContext, useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +const sectionExpandedCache = new Map<string, boolean>(); + +interface SectionContext { + isOpen: boolean; + toggle: () => void; + collapsible: boolean; +} + +const SectionCtx = createContext<SectionContext | null>(null); + +function useSectionContext() { + return useContext(SectionCtx); +} + +interface SectionProps { + children: React.ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; + sectionKey?: string; + className?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; +} + +export function Section({ + children, + collapsible = false, + defaultOpen = true, + sectionKey, + className, + hasBorderTop = true, + hasBorderBottom = true, +}: SectionProps) { + const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; + const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + + const toggle = () => { + const next = !isOpen; + setIsOpen(next); + if (sectionKey) sectionExpandedCache.set(sectionKey, next); + }; + + return ( + <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}> + <div + className={cn( + "flex flex-col", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {children} + </div> + </SectionCtx.Provider> + ); +} + +interface SectionHeaderProps { + title: string; + children?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function SectionHeader({ + title, + children, + onClick, + className, +}: SectionHeaderProps) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + const isInteractive = isCollapsible || !!onClick; + + const handleClick = isCollapsible ? ctx?.toggle : onClick; + + const content = ( + <> + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + )} + > + {title} + </span> + <div className="flex items-center gap-1"> + {children} + {isCollapsible && ( + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn( + "size-3 shrink-0 transition-transform duration-200 ease-out", + isOpen + ? "rotate-0 text-foreground" + : "-rotate-90 text-muted-foreground", + )} + /> + )} + </div> + </> + ); + + const baseClassName = cn( + "flex w-full items-center justify-between h-11 px-3.5", + className, + ); + + if (isInteractive) { + return ( + <button + type="button" + className={cn(baseClassName, "cursor-pointer text-left")} + onClick={(event) => { + handleClick?.(); + event.currentTarget.blur(); + }} + > + {content} + </button> + ); + } + + return <div className={baseClassName}>{content}</div>; +} + +export function SectionContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + + if (isCollapsible) { + return ( + <div + className={cn( + "grid transition-[grid-template-rows] duration-100 ease-out", + isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", + )} + > + <div className="overflow-hidden"> + <div className={cn("p-4 pt-0", className)}>{children}</div> + </div> + </div> + ); + } + + return <div className={cn("p-4 pt-0", className)}>{children}</div>; +} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx new file mode 100644 index 00000000..430efa25 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -0,0 +1,196 @@ +import { useEditor } from "@/hooks/use-editor"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { + DEFAULT_BLEND_MODE, + DEFAULT_OPACITY, +} from "@/constants/timeline-constants"; +import { OcCheckerboardIcon } from "@opencut/ui/icons"; +import { Fragment, useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "../section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/types/rendering"; +import type { ElementType } from "@/types/timeline"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; +}; + +const BLEND_MODE_GROUPS = [ + [{ value: "normal", label: "Normal" }], + [ + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + ], + [ + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + ], + [ + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + ], + [ + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + ], + [ + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, + ], +]; + +export function BlendingSection({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE; + const didSelectRef = useRef(false); + const committedBlendModeRef = useRef(blendMode); + if (!editor.timeline.isPreviewActive()) { + committedBlendModeRef.current = blendMode; + } + + const previewBlendMode = (value: BlendMode) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + didSelectRef.current = true; + }; + + const handleBlendModeOpenChange = (isOpen: boolean) => { + if (!isOpen) { + if (!didSelectRef.current) editor.timeline.discardPreview(); + didSelectRef.current = false; + } + }; + + const opacity = usePropertyDraft({ + displayValue: Math.round(element.opacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { opacity: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.type}:blending`}> + <SectionHeader title="Blending" /> + <SectionContent> + <div className="flex items-start gap-2"> + <div className="w-1/2 space-y-1.5"> + <NumberField + className="w-full" + icon={ + <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: DEFAULT_OPACITY }, + }, + ], + }) + } + isDefault={element.opacity === DEFAULT_OPACITY} + dragSensitivity="slow" + /> + </div> + <div className="w-1/2 space-y-1.5"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} + > + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} + className="w-full" + > + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36"> + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> + {group.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + onPointerEnter={() => + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </div> + </div> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx new file mode 100644 index 00000000..8be58f39 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx @@ -0,0 +1,2 @@ +export * from "./transform"; +export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx new file mode 100644 index 00000000..17a54b4c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -0,0 +1,248 @@ +import { NumberField } from "@/components/ui/number-field"; +import { useEditor } from "@/hooks/use-editor"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { clamp } from "@/utils/math"; +import type { ElementType, Transform } from "@/types/timeline"; +import { Section, SectionContent, SectionHeader } from "../section"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Link05Icon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { useState } from "react"; +import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; + +type TransformElement = { + id: string; + transform: Transform; + type: ElementType; +}; + +function parseFloat_({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : parsed; +} + +export function TransformSection({ + element, + trackId, +}: { + element: TransformElement; + trackId: string; +}) { + const editor = useEditor(); + const [isScaleLocked, setIsScaleLocked] = useState(false); + + const previewTransform = (transform: Partial<Transform>) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { transform: { ...element.transform, ...transform } }, + }, + ], + }); + }; + + const commit = () => editor.timeline.commitPreview(); + + const positionX = usePropertyDraft({ + displayValue: Math.round(element.transform.position.x).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, x: value }, + }), + onCommit: commit, + }); + + const positionY = usePropertyDraft({ + displayValue: Math.round(element.transform.position.y).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, y: value }, + }), + onCommit: commit, + }); + + const scale = usePropertyDraft({ + displayValue: Math.round(element.transform.scale * 100).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return Math.max(parsed, 1) / 100; + }, + onPreview: (value) => previewTransform({ scale: value }), + onCommit: commit, + }); + const scaleFieldProps = { + className: "flex-1", + value: scale.displayValue, + onFocus: scale.onFocus, + onChange: scale.onChange, + onBlur: scale.onBlur, + dragSensitivity: "slow" as const, + onScrub: scale.scrubTo, + onScrubEnd: scale.commitScrub, + onReset: () => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + scale: DEFAULT_TRANSFORM.scale, + }, + }, + }, + ], + }), + isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale, + }; + + const rotation = usePropertyDraft({ + displayValue: Math.round(element.transform.rotate).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return clamp({ value: parsed, min: -360, max: 360 }); + }, + onPreview: (value) => previewTransform({ rotate: value }), + onCommit: commit, + }); + + return ( + <Section collapsible sectionKey={`${element.type}:transform`}> + <SectionHeader title="Transform" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + <div className="flex items-center gap-2"> + <NumberField + icon="X" + className="flex-1" + value={positionX.displayValue} + onFocus={positionX.onFocus} + onChange={positionX.onChange} + onBlur={positionX.onBlur} + onScrub={positionX.scrubTo} + onScrubEnd={positionX.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + position: { + ...element.transform.position, + x: DEFAULT_TRANSFORM.position.x, + }, + }, + }, + }, + ], + }) + } + isDefault={ + element.transform.position.x === DEFAULT_TRANSFORM.position.x + } + /> + <NumberField + icon="Y" + className="flex-1" + value={positionY.displayValue} + onFocus={positionY.onFocus} + onChange={positionY.onChange} + onBlur={positionY.onBlur} + onScrub={positionY.scrubTo} + onScrubEnd={positionY.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + position: { + ...element.transform.position, + y: DEFAULT_TRANSFORM.position.y, + }, + }, + }, + }, + ], + }) + } + isDefault={ + element.transform.position.y === DEFAULT_TRANSFORM.position.y + } + /> + </div> + + <div className="flex items-center gap-2"> + <NumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + className="flex-1" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + rotate: DEFAULT_TRANSFORM.rotate, + }, + }, + }, + ], + }) + } + isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 22e50e4e..cfca52dc 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,24 +1,31 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; -import type { FontFamily } from "@/constants/font-constants"; import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useReducer, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; +import { + DEFAULT_TEXT_ELEMENT, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "./hooks/use-property-draft"; +import { TransformSection, BlendingSection } from "./sections"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { OcFontWeightIcon } from "@opencut/ui/icons"; +import { Label } from "@/components/ui/label"; export function TextProperties({ element, @@ -27,624 +34,229 @@ export function TextProperties({ element: TextElement; trackId: string; }) { - const editor = useEditor(); - const containerRef = useRef<HTMLDivElement>(null); - const [, forceRender] = useReducer((x: number) => x + 1, 0); - const isEditingFontSize = useRef(false); - const isEditingOpacity = useRef(false); - const isEditingContent = useRef(false); - const fontSizeDraft = useRef(""); - const opacityDraft = useRef(""); - const contentDraft = useRef(""); - - const fontSizeDisplay = isEditingFontSize.current - ? fontSizeDraft.current - : element.fontSize.toString(); - const opacityDisplay = isEditingOpacity.current - ? opacityDraft.current - : Math.round(element.opacity * 100).toString(); - const contentDisplay = isEditingContent.current - ? contentDraft.current - : element.content; - - const lastSelectedColor = useRef(DEFAULT_COLOR); - const initialFontSizeRef = useRef<number | null>(null); - const initialOpacityRef = useRef<number | null>(null); - const initialContentRef = useRef<string | null>(null); - const initialColorRef = useRef<string | null>(null); - const initialBgColorRef = useRef<string | null>(null); - - const handleFontSizeChange = ({ value }: { value: string }) => { - fontSizeDraft.current = value; - forceRender(); + return ( + <div className="flex h-full flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TransformSection element={element} trackId={trackId} /> + <BlendingSection element={element} trackId={trackId} /> + <FontSection element={element} trackId={trackId} /> + <ColorSection element={element} trackId={trackId} /> + </div> + ); +} - if (value.trim() !== "") { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - const parsed = parseInt(value, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: false, - }); - } - }; +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleFontSizeBlur = () => { - if (initialFontSizeRef.current !== null) { - const parsed = parseInt(fontSizeDraft.current, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: initialFontSizeRef.current }, - }, + { trackId, elementId: element.id, updates: { content: value } }, ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - isEditingFontSize.current = false; - fontSizeDraft.current = ""; - forceRender(); - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); - const handleOpacityChange = ({ value }: { value: string }) => { - opacityDraft.current = value; - forceRender(); - - if (value.trim() !== "") { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - const parsed = parseInt(value, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: false, - }); - } - }; + return ( + <Section collapsible sectionKey="text:content" hasBorderTop={false}> + <SectionHeader title="Content" /> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} - const handleOpacityBlur = () => { - if (initialOpacityRef.current !== null) { - const parsed = parseInt(opacityDraft.current, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - isEditingOpacity.current = false; - opacityDraft.current = ""; - forceRender(); - }; +function FontSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleColorChange = ({ color }: { color: string }) => { - if (color !== "transparent") { - lastSelectedColor.current = color; - } - if (initialBgColorRef.current === null) { - initialBgColorRef.current = element.backgroundColor; - } - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + }, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, + { trackId, elementId: element.id, updates: { fontSize: value } }, ], - }); - } - }; - - const handleColorChangeEnd = ({ color }: { color: string }) => { - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: initialBgColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialBgColorRef.current = null; - } - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); return ( - <div className="flex h-full flex-col" ref={containerRef}> - <PanelBaseView className="p-0"> - <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> - <Textarea - placeholder="Name" - value={contentDisplay} - className="bg-accent min-h-20" - onFocus={() => { - isEditingContent.current = true; - contentDraft.current = element.content; - initialContentRef.current = element.content; - forceRender(); - }} - onChange={(event) => { - contentDraft.current = event.target.value; - forceRender(); - if (initialContentRef.current === null) { - initialContentRef.current = element.content; - } + <Section collapsible sectionKey="text:font"> + <SectionHeader title="Font" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: event.target.value }, + updates: { fontFamily: value }, }, ], - pushHistory: false, - }); - }} - onBlur={() => { - if (initialContentRef.current !== null) { - const finalContent = contentDraft.current; - editor.timeline.updateElements({ + }) + } + /> + <Select value={element.fontWeight}> + <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> + <SelectValue placeholder="Select weight" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="100">Thin</SelectItem> + <SelectItem value="200">Extra Light</SelectItem> + <SelectItem value="300">Light</SelectItem> + <SelectItem value="400">Normal</SelectItem> + <SelectItem value="500">Medium</SelectItem> + <SelectItem value="600">Semi Bold</SelectItem> + <SelectItem value="700">Bold</SelectItem> + <SelectItem value="800">Extra Bold</SelectItem> + <SelectItem value="900">Black</SelectItem> + </SelectContent> + </Select> + + <Label>Font size</Label> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => + editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { content: initialContentRef.current }, + updates: { fontSize: value }, }, ], - pushHistory: false, - }); + }) + } + onValueCommit={() => editor.timeline.commitPreview()} + className="w-full" + /> + <NumberField + className="w-18 shrink-0" + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onReset={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: finalContent }, + updates: { + fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + }, }, ], - pushHistory: true, - }); - initialContentRef.current = null; + }) } - isEditingContent.current = false; - contentDraft.current = ""; - forceRender(); + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} + +function ColorSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + return ( + <Section collapsible sectionKey="text:color" hasBorderBottom={false}> + <SectionHeader title="Color" /> + <SectionContent> + <div className="flex flex-col gap-6"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: hexColor }, + }, + ], + }); }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } /> - </PropertyGroup> - <PropertyGroup title="Typography" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" - ? "normal" - : "bold", - }, - }, - ], - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, - }, - ], - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, - }, - ], - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }, - }, - ], - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialFontSizeRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: initialFontSizeRef.current, - }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeDisplay} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={() => { - isEditingFontSize.current = true; - fontSizeDraft.current = element.fontSize.toString(); - forceRender(); - }} - onChange={(e) => - handleFontSizeChange({ value: e.target.value }) - } - onBlur={handleFontSizeBlur} - className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - </PropertyGroup> - <PropertyGroup title="Appearance" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { - if (initialColorRef.current === null) { - initialColorRef.current = element.color || "#FFFFFF"; - } - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }); - } - }} - onChangeEnd={(color) => { - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: initialColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialColorRef.current = null; - } - }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialOpacityRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={opacityDisplay} - min={0} - max={100} - onFocus={() => { - isEditingOpacity.current = true; - opacityDraft.current = Math.round( - element.opacity * 100, - ).toString(); - forceRender(); - }} - onChange={(e) => - handleOpacityChange({ value: e.target.value }) - } - onBlur={handleOpacityBlur} - className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Background</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", "") - } - onChange={(color) => - handleColorChange({ color: `#${color}` }) - } - onChangeEnd={(color) => handleColorChangeEnd({ color })} - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" - } - /> - </PropertyItemValue> - </PropertyItem> - </div> - </PropertyGroup> - </PanelBaseView> - </div> + </div> + </SectionContent> + </Section> ); } diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index cfcb1e23..64824f9a 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -1,21 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; +import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; +import { + DEFAULT_BOOKMARK_COLOR, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; -import { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { + ArrowTurnBackwardIcon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import type { Bookmark } from "@/types/timeline"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; + +const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND; +const MIN_BOOKMARK_WIDTH_PX = 2; +const BOOKMARK_MARKER_WIDTH_PX = 12; +const BOOKMARK_MARKER_HEIGHT_PX = 15; +const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; +const BOOKMARK_MARKER_CLIP_PATH = + "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; + +function seekToBookmarkTime({ + editor, + time, +}: { + editor: EditorCore; + time: number; +}) { + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + const fps = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps }); + editor.playback.seek({ time: snappedTime }); +} interface TimelineBookmarksRowProps { zoomLevel: number; dynamicTimelineWidth: number; - handleWheel: (e: React.WheelEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; - handleRulerMouseDown: (e: React.MouseEvent) => void; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; + handleWheel: (event: React.WheelEvent) => void; + handleTimelineContentClick: (event: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; + handleRulerMouseDown: (event: React.MouseEvent) => void; } export function TimelineBookmarksRow({ zoomLevel, dynamicTimelineWidth, + dragState, + onBookmarkMouseDown, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -34,17 +88,23 @@ export function TimelineBookmarksRow({ aria-label="Timeline ruler" type="button" onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleTimelineContentClick(event); + }} onMouseDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; handleRulerMouseDown(event); handleRulerTrackingMouseDown(event); }} > - {activeScene.bookmarks.map((time: number) => ( + {activeScene.bookmarks.map((bookmark) => ( <TimelineBookmark - key={`bookmark-row-${time}`} - time={time} + key={`bookmark-${bookmark.time}`} + bookmark={bookmark} zoomLevel={zoomLevel} + dragState={dragState} + onBookmarkMouseDown={onBookmarkMouseDown} /> ))} </button> @@ -52,60 +112,305 @@ export function TimelineBookmarksRow({ ); } -export function TimelineBookmark({ - time, +function TimelineBookmark({ + bookmark, zoomLevel, + dragState, + onBookmarkMouseDown, }: { - time: number; + bookmark: Bookmark; zoomLevel: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; }) { const editor = useEditor(); - const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isDragging = + dragState.isDragging && + dragState.bookmarkTime !== null && + Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON; + + const displayTime = isDragging ? dragState.currentTime : bookmark.time; + const time = bookmark.time; + const bookmarkDuration = bookmark.duration ?? 0; + const durationWidth = + bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0; + const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; + const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); + const left = displayTime * PIXELS_PER_SECOND * zoomLevel; + const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; + const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); + const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; + + const handleSeek = () => seekToBookmarkTime({ editor, time }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.detail === 2) { + setIsPopoverOpen(true); + } else { + handleSeek(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleSeek(); + }; - const handleBookmarkActivate = ({ - event, - }: { - event: - | React.MouseEvent<HTMLButtonElement> - | React.KeyboardEvent<HTMLButtonElement>; - }) => { + const handleMouseDown = (event: React.MouseEvent) => { + onBookmarkMouseDown({ event, bookmark }); + event.preventDefault(); event.stopPropagation(); - const framesPerSecond = activeProject?.settings.fps ?? 30; - const snappedTime = getSnappedSeekTime({ - rawTime: time, - duration, - fps: framesPerSecond, - }); - editor.playback.seek({ time: snappedTime }); }; return ( - <button - className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" - style={{ - left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - aria-label={`Seek to bookmark at ${time}s`} - type="button" - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => handleBookmarkActivate({ event })} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - handleBookmarkActivate({ event }); - }} - > - <div className="text-primary absolute top-[-1px] left-[-5px]"> - <HugeiconsIcon - icon={Bookmark02Icon} - aria-hidden="true" - className="fill-primary size-3" + <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> + <PopoverAnchor asChild> + <button + className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} + style={{ + left: `${bookmarkLeft}px`, + width: `${bookmarkWidth}px`, + }} + aria-label={`Bookmark at ${time.toFixed(1)}s`} + type="button" + onMouseDown={handleMouseDown} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {hasDurationRange ? ( + <div + className="absolute opacity-30" + style={{ + top: 1.5, + height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, + left: BOOKMARK_HALF_WIDTH_PX, + width: durationWidth, + backgroundColor: iconColor, + }} + /> + ) : null} + <div + className="absolute top-0 overflow-hidden" + style={{ + left: 0, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute inset-0" + style={{ + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + <div + className="absolute top-0 overflow-hidden" + style={{ + left: rightHalfLeft, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute top-0" + style={{ + left: -BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1 - BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + </button> + </PopoverAnchor> + <PopoverContent + className="w-64 flex flex-col gap-3 p-3" + align="start" + side="bottom" + sideOffset={8} + onOpenAutoFocus={(event) => event.preventDefault()} + > + <BookmarkPopoverContent + bookmark={bookmark} + time={time} + timelineDuration={duration} + onPopoverClose={() => setIsPopoverOpen(false)} /> + </PopoverContent> + </Popover> + ); +} + +function BookmarkPopoverContent({ + bookmark, + time, + timelineDuration, + onPopoverClose, +}: { + bookmark: Bookmark; + time: number; + timelineDuration: number; + onPopoverClose: () => void; +}) { + const editor = useEditor(); + const [draftColorHex, setDraftColorHex] = useState( + (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + ); + + useEffect(() => { + setDraftColorHex( + (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + ); + }, [bookmark.color]); + + const handleRemove = () => { + editor.scenes.removeBookmark({ time }); + onPopoverClose(); + }; + + const handleUpdate = ({ + note, + color, + duration, + }: Partial<{ note: string; color: string; duration: number }>) => { + const updates: Partial<{ note: string; color: string; duration: number }> = + {}; + if (note !== undefined && note !== bookmark.note) updates.note = note; + if ( + color !== undefined && + color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() + ) { + updates.color = color; + } + if (duration !== undefined && duration !== bookmark.duration) { + updates.duration = duration; + } + if (Object.keys(updates).length === 0) return; + editor.scenes.updateBookmark({ time, updates }); + }; + + return ( + <> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Note</Label> + <Input + placeholder="Add a note..." + value={bookmark.note ?? ""} + onChange={(event) => handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Color</Label> + <div className="relative"> + <ColorPicker + value={uppercase({ string: draftColorHex })} + onChange={(color) => setDraftColorHex(uppercase({ string: color }))} + onChangeEnd={(color) => + handleUpdate({ color: `#${uppercase({ string: color })}` }) + } + className="bg-background border" + /> + {bookmark.color && + bookmark.color.replace(/^#/, "").toUpperCase() !== + DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( + <Button + type="button" + variant="text" + size="text" + aria-label="Reset to default color" + className="absolute top-1/2 right-1 -translate-y-1/2 mr-1" + onClick={() => + editor.scenes.updateBookmark({ + time, + updates: { color: undefined }, + }) + } + > + <HugeiconsIcon + icon={ArrowTurnBackwardIcon} + className="!size-3.5" + /> + </Button> + )} + </div> </div> - </button> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Duration</Label> + <div className="flex items-center gap-1.5"> + <Input + type="number" + min={0} + step={0.1} + value={bookmark.duration ?? 0} + onChange={(event) => { + const parsed = parseFloat(event.target.value); + const value = Number.isNaN(parsed) + ? 0 + : clamp({ + value: parsed, + min: 0, + max: Math.max(0, timelineDuration - time), + }); + handleUpdate({ duration: value }); + }} + className="h-8 text-sm" + containerClassName="w-full" + /> + </div> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-destructive hover:bg-destructive/10" + onClick={handleRemove} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + handleRemove(); + } + }} + aria-label="delete bookmark" + > + <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" /> + Delete + </Button> + </> ); } diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 94edc2d0..7e6ff326 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; import { TimelineRuler } from "./timeline-ruler"; import { TimelineBookmarksRow } from "./bookmarks"; +import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; @@ -126,6 +128,17 @@ export function Timeline() { onSnapPointChange: handleSnapPointChange, }); + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = useTimelinePlayhead({ zoomLevel, @@ -168,10 +181,18 @@ export function Timeline() { containerWidth, ); + useEdgeAutoScroll({ + isActive: bookmarkDragState.isDragging, + getMouseClientX: () => bookmarkLastMouseXRef.current, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || isResizing); + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -351,7 +372,7 @@ export function Timeline() { > <div ref={timelineHeaderRef} - className="bg-background sticky top-0 z-30 flex flex-col" + className="bg-background sticky top-0 flex flex-col" > <TimelineRuler zoomLevel={zoomLevel} @@ -366,6 +387,8 @@ export function Timeline() { <TimelineBookmarksRow zoomLevel={zoomLevel} dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} handleWheel={handleWheel} handleTimelineContentClick={handleRulerClick} handleRulerTrackingMouseDown={handleRulerMouseDown} @@ -433,7 +456,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-40"> + <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} onClick={(e) => { diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 914edccb..e70f0cd7 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -38,7 +38,7 @@ export function SnapIndicator({ return ( <div - className="pointer-events-none absolute z-90" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: topPosition, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 59ce88da..628855f6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { resolveStickerId } from "@/lib/stickers"; import Image from "next/image"; import { ScissorIcon, @@ -139,9 +140,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none ${ - isBeingDragged ? "z-30" : "z-10" - }`} + className={`absolute top-0 h-full select-none`} style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -155,7 +154,6 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - isBeingDragged={isBeingDragged} hasAudio={hasAudio} isMuted={isMuted} mediaAssets={mediaAssets} @@ -165,7 +163,7 @@ export function TimelineElement({ /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-64"> + <ContextMenuContent className="w-64"> <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />} @@ -227,7 +225,6 @@ function ElementInner({ element, track, isSelected, - isBeingDragged, hasAudio, isMuted, mediaAssets, @@ -238,7 +235,6 @@ function ElementInner({ element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - isBeingDragged: boolean; hasAudio: boolean; isMuted: boolean; mediaAssets: MediaAsset[]; @@ -248,7 +244,7 @@ function ElementInner({ element: TimelineElementType, ) => void; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -259,7 +255,7 @@ function ElementInner({ { type: track.type, }, - )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} > <button type="button" @@ -321,7 +317,7 @@ function ResizeHandle({ side: "left" | "right"; elementId: string; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -330,8 +326,8 @@ function ResizeHandle({ return ( <button type="button" - className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} - onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + onMouseDown={(event) => handleResizeStart({ event, elementId, side })} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} > <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> @@ -362,7 +358,10 @@ function ElementContent({ return ( <div className="flex size-full items-center gap-2 pl-2"> <Image - src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} alt={element.name} className="size-5 shrink-0" width={20} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 8af35dc9..ebb2a37e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -72,20 +72,22 @@ export function TimelinePlayhead({ aria-valuemax={duration} aria-valuenow={playheadPosition} tabIndex={0} - className="pointer-events-auto absolute z-60" + className="pointer-events-none absolute z-5" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: "2px", }} - onMouseDown={handlePlayheadMouseDown} onKeyDown={handlePlayheadKeyDown} > - <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> + <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" /> - <div - className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + <button + type="button" + aria-label="Drag playhead" + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + onMouseDown={handlePlayheadMouseDown} /> </div> ); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 0b02c63f..03ce88eb 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -171,7 +171,7 @@ function SceneSelector() { <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> <SplitButtonSeparator /> <ScenesView> - <SplitButtonRight onClick={() => {}} type="button"> + <SplitButtonRight onClick={() => {}}> <HugeiconsIcon icon={Layers01Icon} className="size-4" /> </SplitButtonRight> </ScenesView> @@ -222,7 +222,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "out" })} > <HugeiconsIcon icon={SearchMinusIcon} /> @@ -240,7 +239,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "in" })} > <HugeiconsIcon icon={SearchAddIcon} /> @@ -269,7 +267,6 @@ function ToolbarButton({ <Button variant={isActive ? "secondary" : "text"} size="icon" - type="button" onClick={(event) => onClick({ event })} className={cn( "rounded-sm", diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx index f96def4a..153a662a 100644 --- a/apps/web/src/components/gitHub-contribute-section.tsx +++ b/apps/web/src/components/gitHub-contribute-section.tsx @@ -1,10 +1,7 @@ import { Button } from "./ui/button"; import Link from "next/link"; import { SOCIAL_LINKS } from "@/constants/site-constants"; -import { - GithubIcon, - Link04Icon, -} from "@hugeicons/core-free-icons"; +import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; export function GitHubContributeSection({ diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index b1b5ab3a..746cfd16 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -9,6 +9,7 @@ import { useKeybindingDisabler, } from "@/hooks/use-keybindings"; import { useEditorActions } from "@/hooks/actions/use-editor-actions"; +import { prefetchFontAtlas } from "@/lib/fonts/google-fonts"; interface EditorProviderProps { projectId: string; @@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { if (cancelled) return; setIsLoading(false); + prefetchFontAtlas(); } catch (err) { if (cancelled) return; diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index b6e8d274..6fd84fa3 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -1,36 +1,39 @@ -"use client";
-
-import { Button } from "./ui/button";
-import { useTheme } from "next-themes";
-import { cn } from "@/utils/ui";
-import { Sun03Icon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-interface ThemeToggleProps {
- className?: string;
- iconClassName?: string;
- onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
-}
-
-export function ThemeToggle({
- className,
- iconClassName,
- onToggle,
-}: ThemeToggleProps) {
- const { theme, setTheme } = useTheme();
-
- return (
- <Button
- size="icon"
- variant="ghost"
- className={cn("size-8", className)}
- onClick={(e) => {
- setTheme(theme === "dark" ? "light" : "dark");
- onToggle?.(e);
- }}
- >
- <HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
- <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
- </Button>
- );
-}
+"use client"; + +import { Button } from "./ui/button"; +import { useTheme } from "next-themes"; +import { cn } from "@/utils/ui"; +import { Sun03Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +interface ThemeToggleProps { + className?: string; + iconClassName?: string; + onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void; +} + +export function ThemeToggle({ + className, + iconClassName, + onToggle, +}: ThemeToggleProps) { + const { theme, setTheme } = useTheme(); + + return ( + <Button + size="icon" + variant="ghost" + className={cn("size-8", className)} + onClick={(e) => { + setTheme(theme === "dark" ? "light" : "dark"); + onToggle?.(e); + }} + > + <HugeiconsIcon + icon={Sun03Icon} + className={cn("!size-[1.1rem]", iconClassName)} + /> + <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> + </Button> + ); +} diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index e941ca43..dcc190d3 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { AlertTriangle } from "lucide-react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { Alert02Icon } from "@hugeicons/core-free-icons"; @@ -32,7 +31,10 @@ const Alert = React.forwardRef< {...props} > {variant === "destructive" && ( - <HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" /> + <HugeiconsIcon + icon={Alert02Icon} + className="size-5 text-destructive mt-0.5" + /> )} {children} </div> @@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef< >(({ className, ...props }, ref) => ( <h5 ref={ref} - className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)} + className={cn( + "mb-2 text-base leading-none font-semibold tracking-tight", + className, + )} {...props} /> )); diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 0c2fb4c4..a3b59233 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef< >(({ className, ...props }, ref) => ( <AvatarPrimitive.Fallback ref={ref} - className={cn( - "bg-muted flex size-full items-center justify-center rounded-full", - className, - )} + className={cn( + "bg-muted flex size-full items-center justify-center rounded-full", + className, + )} {...props} /> )); diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index e95f2965..927cca89 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -1,108 +1,99 @@ -import * as React from "react"; -import { Slot as SlotPrimitive } from "radix-ui"; import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Slot } from "radix-ui"; import { cn } from "@/utils/ui"; -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); -Breadcrumb.displayName = "Breadcrumb"; +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; +} -const BreadcrumbList = React.forwardRef< - HTMLOListElement, - React.ComponentPropsWithoutRef<"ol"> ->(({ className, ...props }, ref) => ( - <ol - ref={ref} - className={cn( - "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", - className, - )} - {...props} - /> -)); -BreadcrumbList.displayName = "BreadcrumbList"; +function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { + return ( + <ol + data-slot="breadcrumb-list" + className={cn( + "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", + className, + )} + {...props} + /> + ); +} -const BreadcrumbItem = React.forwardRef< - HTMLLIElement, - React.ComponentPropsWithoutRef<"li"> ->(({ className, ...props }, ref) => ( - <li - ref={ref} - className={cn("inline-flex items-center gap-1.5", className)} - {...props} - /> -)); -BreadcrumbItem.displayName = "BreadcrumbItem"; +function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-item" + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> + ); +} -const BreadcrumbLink = React.forwardRef< - HTMLAnchorElement, - React.ComponentPropsWithoutRef<"a"> & { - asChild?: boolean; - } ->(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; +function BreadcrumbLink({ + asChild, + className, + ...props +}: React.ComponentProps<"a"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot.Root : "a"; return ( <Comp - ref={ref} - className={cn("hover:text-foreground", className)} + data-slot="breadcrumb-link" + className={cn("hover:text-foreground transition-colors", className)} {...props} /> ); -}); -BreadcrumbLink.displayName = "BreadcrumbLink"; +} -const BreadcrumbPage = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> ->(({ className, ...props }, ref) => ( - <span - ref={ref} - role="link" - aria-disabled="true" - aria-current="page" - className={cn("text-foreground font-normal", className)} - {...props} - /> -)); -BreadcrumbPage.displayName = "BreadcrumbPage"; +function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-page" + aria-current="page" + className={cn("text-foreground font-normal", className)} + {...props} + /> + ); +} -const BreadcrumbSeparator = ({ +function BreadcrumbSeparator({ children, className, ...props -}: React.ComponentProps<"li">) => ( - <li - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:size-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> -); -BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; +}: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-separator" + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:size-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> + ); +} -const BreadcrumbEllipsis = ({ +function BreadcrumbEllipsis({ className, ...props -}: React.ComponentProps<"span">) => ( - <span - role="presentation" - aria-hidden="true" - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More</span> - </span> -); -BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-ellipsis" + role="presentation" + aria-hidden="true" + className={cn("flex size-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="size-4" /> + <span className="sr-only">More</span> + </span> + ); +} export { Breadcrumb, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 2fc2c2e6..fba3109f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { Slot as SlotPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; const buttonVariants = cva( @@ -9,18 +8,14 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground hover:bg-primary/90", - background: - "bg-background text-foreground hover:bg-background/90", - foreground: - "bg-foreground text-background hover:bg-foreground/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", + background: "bg-background text-foreground hover:bg-background/90", + foreground: "bg-foreground text-background hover:bg-foreground/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/80", "destructive-foreground": "border bg-background hover:bg-destructive/15 text-destructive", - outline: - "border border-border bg-transparent hover:bg-accent/50", + outline: "border border-border bg-transparent hover:bg-accent/50", secondary: "bg-secondary text-secondary-foreground border border-secondary-border", text: "bg-transparent rounded-none opacity-100 hover:opacity-75", @@ -28,10 +23,10 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", }, size: { - default: "h-9.5 px-4 py-2", - sm: "h-8 p-1 px-2 text-xs rounded-sm", + default: "h-9 px-4 py-2", + sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", - icon: "size-7", + icon: "size-7 rounded-sm", text: "p-0", }, }, @@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const effectiveSize = size ?? (variant === "text" ? "text" : "default"); return ( <Comp - className={cn(buttonVariants({ variant, size: effectiveSize, className }))} + className={cn( + buttonVariants({ variant, size: effectiveSize, className }), + )} ref={ref} + type="button" {...props} /> ); diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index a0664cc3..3d088b27 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -60,8 +60,8 @@ function Calendar({ ...classNames, }} components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />, - IconRight: ({ ...props }) => <ChevronRight className="size-4" />, + IconLeft: () => <ChevronLeft className="size-4" />, + IconRight: () => <ChevronRight className="size-4" />, }} {...props} /> diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx deleted file mode 100644 index 9018b9ea..00000000 --- a/apps/web/src/components/ui/carousel.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import * as React from "react"; - -import { cn } from "@/utils/ui"; -import { Button } from "./button"; - -type CarouselApi = UseEmblaCarouselType[1]; -type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; -type CarouselOptions = UseCarouselParameters[0]; -type CarouselPlugin = UseCarouselParameters[1]; - -type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; -}; - -type CarouselContextProps = { - carouselRef: ReturnType<typeof useEmblaCarousel>[0]; - api: ReturnType<typeof useEmblaCarousel>[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; -} & CarouselProps; - -const CarouselContext = React.createContext<CarouselContextProps | null>(null); - -function useCarousel() { - const context = React.useContext(CarouselContext); - - if (!context) { - throw new Error("useCarousel must be used within a <Carousel />"); - } - - return context; -} - -const Carousel = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & CarouselProps ->( - ( - { - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props - }, - ref, - ) => { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins, - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); - - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } - - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); - - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); - - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext], - ); - - React.useEffect(() => { - if (!api || !setApi) { - return; - } - - setApi(api); - }, [api, setApi]); - - React.useEffect(() => { - if (!api) { - return; - } - - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); - - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); - - return ( - <CarouselContext.Provider - value={{ - carouselRef, - api, - opts, - orientation: - orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), - scrollPrev, - scrollNext, - canScrollPrev, - canScrollNext, - }} - > - <div - ref={ref} - onKeyDownCapture={handleKeyDown} - className={cn("relative", className)} - role="region" - aria-roledescription="carousel" - {...props} - > - {children} - </div> - </CarouselContext.Provider> - ); - }, -); -Carousel.displayName = "Carousel"; - -const CarouselContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => { - const { carouselRef, orientation } = useCarousel(); - - return ( - <div ref={carouselRef} className="overflow-hidden"> - <div - ref={ref} - className={cn( - "flex", - orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", - className, - )} - {...props} - /> - </div> - ); -}); -CarouselContent.displayName = "CarouselContent"; - -const CarouselItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => { - const { orientation } = useCarousel(); - - return ( - <div - ref={ref} - role="group" - aria-roledescription="slide" - className={cn( - "min-w-0 shrink-0 grow-0 basis-full", - orientation === "horizontal" ? "pl-4" : "pt-4", - className, - )} - {...props} - /> - ); -}); -CarouselItem.displayName = "CarouselItem"; - -const CarouselPrevious = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> ->(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute size-8 rounded-full", - orientation === "horizontal" - ? "top-1/2 -left-12 -translate-y-1/2" - : "-top-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollPrev} - onClick={scrollPrev} - {...props} - > - <ArrowLeft className="size-4" /> - <span className="sr-only">Previous slide</span> - </Button> - ); -}); -CarouselPrevious.displayName = "CarouselPrevious"; - -const CarouselNext = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> ->(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollNext, canScrollNext } = useCarousel(); - - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute size-8 rounded-full", - orientation === "horizontal" - ? "top-1/2 -right-12 -translate-y-1/2" - : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollNext} - onClick={scrollNext} - {...props} - > - <ArrowRight className="size-4" /> - <span className="sr-only">Next slide</span> - </Button> - ); -}); -CarouselNext.displayName = "CarouselNext"; - -export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, -}; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx deleted file mode 100644 index 2bd04b20..00000000 --- a/apps/web/src/components/ui/chart.tsx +++ /dev/null @@ -1,370 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as RechartsPrimitive from "recharts"; -import { - NameType, - Payload, - ValueType, -} from "recharts/types/component/DefaultTooltipContent"; - -import { cn } from "@/utils/ui"; - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const; - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record<keyof typeof THEMES, string> } - ); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = React.createContext<ChartContextProps | null>(null); - -function useChart() { - const context = React.useContext(ChartContext); - - if (!context) { - throw new Error("useChart must be used within a <ChartContainer />"); - } - - return context; -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"]; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - - return ( - <ChartContext.Provider value={{ config }}> - <div - data-chart={chartId} - ref={ref} - className={cn( - "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", - className, - )} - {...props} - > - <ChartStyle id={chartId} config={config} /> - <RechartsPrimitive.ResponsiveContainer> - {children} - </RechartsPrimitive.ResponsiveContainer> - </div> - </ChartContext.Provider> - ); -}); -ChartContainer.displayName = "Chart"; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color, - ); - - if (!colorConfig.length) { - return null; - } - - return ( - <style - dangerouslySetInnerHTML={{ - __html: Object.entries(THEMES) - .map( - ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join("\n")} -} -`, - ) - .join("\n"), - }} - /> - ); -}; - -const ChartTooltip = RechartsPrimitive.Tooltip; - -const ChartTooltipContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<typeof RechartsPrimitive.Tooltip> & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - } ->( - ( - { - active, - payload, - className, - indicator = "dot", - hideLabel = false, - hideIndicator = false, - label, - labelFormatter, - labelClassName, - formatter, - color, - nameKey, - labelKey, - }, - ref, - ) => { - const { config } = useChart(); - - const tooltipLabel = React.useMemo(() => { - if (hideLabel || !payload?.length) { - return null; - } - - const [item] = payload; - const key = `${labelKey || item.dataKey || item.name || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label; - - if (labelFormatter) { - return ( - <div className={cn("font-medium", labelClassName)}> - {labelFormatter(value, payload)} - </div> - ); - } - - if (!value) { - return null; - } - - return <div className={cn("font-medium", labelClassName)}>{value}</div>; - }, [ - label, - labelFormatter, - payload, - hideLabel, - labelClassName, - config, - labelKey, - ]); - - if (!active || !payload?.length) { - return null; - } - - const nestLabel = payload.length === 1 && indicator !== "dot"; - - return ( - <div - ref={ref} - className={cn( - "border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl", - className, - )} - > - {nestLabel ? null : tooltipLabel} - <div className="grid gap-1.5"> - {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; - - return ( - <div - key={item.dataKey} - className={cn( - "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5", - indicator === "dot" && "items-center", - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) - ) : ( - <> - {itemConfig?.icon ? ( - <itemConfig.icon /> - ) : ( - !hideIndicator && ( - <div - className={cn( - "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", - { - "size-2.5": indicator === "dot", - "w-1": indicator === "line", - "w-0 border-[1.5px] border-dashed bg-transparent": - indicator === "dashed", - "my-0.5": nestLabel && indicator === "dashed", - }, - )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } - /> - ) - )} - <div - className={cn( - "flex flex-1 justify-between leading-none", - nestLabel ? "items-end" : "items-center", - )} - > - <div className="grid gap-1.5"> - {nestLabel ? tooltipLabel : null} - <span className="text-muted-foreground"> - {itemConfig?.label || item.name} - </span> - </div> - {item.value && ( - <span className="text-foreground font-mono font-medium tabular-nums"> - {item.value.toLocaleString()} - </span> - )} - </div> - </> - )} - </div> - ); - })} - </div> - </div> - ); - }, -); -ChartTooltipContent.displayName = "ChartTooltip"; - -const ChartLegend = RechartsPrimitive.Legend; - -const ChartLegendContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & - Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean; - nameKey?: string; - } ->( - ( - { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, - ref, - ) => { - const { config } = useChart(); - - if (!payload?.length) { - return null; - } - - return ( - <div - ref={ref} - className={cn( - "flex items-center justify-center gap-4", - verticalAlign === "top" ? "pb-3" : "pt-3", - className, - )} - > - {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - - return ( - <div - key={item.value} - className={cn( - "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3", - )} - > - {itemConfig?.icon && !hideIcon ? ( - <itemConfig.icon /> - ) : ( - <div - className="size-2 shrink-0 rounded-[2px]" - style={{ - backgroundColor: item.color, - }} - /> - )} - {itemConfig?.label} - </div> - ); - })} - </div> - ); - }, -); -ChartLegendContent.displayName = "ChartLegend"; - -// Helper to extract item config from a payload. -function getPayloadConfigFromPayload( - config: ChartConfig, - payload: unknown, - key: string, -) { - if (typeof payload !== "object" || payload === null) { - return; - } - - const payloadPayload = - "payload" in payload && - typeof payload.payload === "object" && - payload.payload !== null - ? payload.payload - : undefined; - - let configLabelKey: string = key; - - if ( - key in payload && - typeof payload[key as keyof typeof payload] === "string" - ) { - configLabelKey = payload[key as keyof typeof payload] as string; - } else if ( - payloadPayload && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string; - } - - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; -} - -export { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - ChartStyle, -}; diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 0ea1a14d..d58b6144 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -1,133 +1,80 @@ import { forwardRef, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { cn } from "@/utils/ui"; import { Input } from "./input"; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "./popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; +import { Button } from "./button"; +import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + type ColorFormat, + appendAlpha, + extractColorFromText, + formatColorValue, + hexToHsv, + hsvToHex, + parseColorInput, + parseHexAlpha, +} from "@/utils/color"; interface ColorPickerProps { value?: string; onChange?: (value: string) => void; onChangeEnd?: (value: string) => void; className?: string; - containerRef?: React.RefObject<HTMLDivElement | null>; } -const hexToHsv = (hex: string) => { - const r = parseInt(hex.slice(0, 2), 16) / 255; - const g = parseInt(hex.slice(2, 4), 16) / 255; - const b = parseInt(hex.slice(4, 6), 16) / 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const diff = max - min; - - let h = 0; - const s = max === 0 ? 0 : diff / max; - const v = max; - - if (diff !== 0) { - switch (max) { - case r: - h = ((g - b) / diff) % 6; - break; - case g: - h = (b - r) / diff + 2; - break; - case b: - h = (r - g) / diff + 4; - break; - } - } - - h = (h * 60 + 360) % 360; - if (Number.isNaN(h)) h = 0; - - return [h, s, v]; -}; - -const hsvToHex = (h: number, s: number, v: number) => { - const c = v * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = v - c; - - let r = 0, - g = 0, - b = 0; - - if (h >= 0 && h < 60) { - r = c; - g = x; - b = 0; - } else if (h >= 60 && h < 120) { - r = x; - g = c; - b = 0; - } else if (h >= 120 && h < 180) { - r = 0; - g = c; - b = x; - } else if (h >= 180 && h < 240) { - r = 0; - g = x; - b = c; - } else if (h >= 240 && h < 300) { - r = x; - g = 0; - b = c; - } else if (h >= 300 && h < 360) { - r = c; - g = 0; - b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); -}; - const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( - ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => { - const [isOpen, setIsOpen] = useState(false); - const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( - null, - ); - const [pickerPosition, setPickerPosition] = useState({ - right: 0, - bottom: 0, - }); + ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => { + const [isDragging, setIsDragging] = useState< + "saturation" | "hue" | "opacity" | null + >(null); const [internalHue, setInternalHue] = useState(0); const [inputValue, setInputValue] = useState(value); + const [colorFormat, setColorFormat] = useState<ColorFormat>("hex"); - const pickerRef = useRef<HTMLDivElement>(null); const saturationRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null); - const triggerRef = useRef<HTMLButtonElement>(null); + const opacityRef = useRef<HTMLButtonElement>(null); const latestDragColorRef = useRef<string | null>(null); - const [h, s, v] = hexToHsv(value); - const displayHue = s > 0 ? h : internalHue; + const isEyeDropperSupported = + typeof window !== "undefined" && "EyeDropper" in window; - useEffect(() => { - setInputValue(value); - }, [value]); + const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); + const [h, s, v] = hexToHsv({ hex: rgbValue }); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - pickerRef.current && - !pickerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); + const handleEyeDropper = async () => { + if (!isEyeDropperSupported || !EyeDropper) return; + try { + const dropper = new EyeDropper(); + const result = await dropper.open(); + const hex = result.sRGBHex.replace("#", "").toLowerCase(); + const finalHex = appendAlpha({ rgbHex: hex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } catch { + // user cancelled the picker } - }, [isOpen]); + }; + const hueDiff = Math.abs(h - internalHue); + const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; + const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; + + useEffect(() => { + setInputValue(formatColorValue({ hex: value, format: colorFormat })); + }, [value, colorFormat]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { @@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( 0, Math.min(1, (e.clientY - rect.top) / rect.height), ); - const newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } @@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } } + + if (isDragging === "opacity" && opacityRef.current) { + const rect = opacityRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } }; const handleMouseUp = () => { @@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( document.removeEventListener("mouseup", handleMouseUp); }; } - }, [isDragging, displayHue, s, v, onChange]); + }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); const handleSaturationMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const rect = saturationElement.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - const newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); }; @@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } }; + const handleOpacityMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const opacityElement = opacityRef.current; + if (!opacityElement) return; + setIsDragging("opacity"); + const rect = opacityElement.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + }; + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const hex = e.target.value.replace("#", ""); - setInputValue(hex); + setInputValue( + colorFormat === "hex" + ? e.target.value.replace("#", "") + : e.target.value, + ); + }; + + const commitInputValue = () => { + const parsed = parseColorInput({ + input: inputValue, + format: colorFormat, + }); + if (parsed) { + const nextHex = appendAlpha({ rgbHex: parsed, alpha }); + onChange?.(nextHex); + onChangeEnd?.(nextHex); + return; + } + + const extracted = extractColorFromText({ text: inputValue }); + if (extracted) { + const hasExplicitAlpha = extracted.length > 6; + const finalHex = hasExplicitAlpha + ? extracted + : appendAlpha({ rgbHex: extracted, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } }; const handleInputBlur = () => { - onChange?.(inputValue); + commitInputValue(); }; const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") { - onChange?.(inputValue); + commitInputValue(); e.currentTarget.blur(); } }; + const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => { + const pastedText = event.clipboardData.getData("text"); + const extractedHex = extractColorFromText({ text: pastedText }); + if (!extractedHex) return; + + event.preventDefault(); + const hasExplicitAlpha = extractedHex.length > 6; + const finalHex = hasExplicitAlpha + ? extractedHex + : appendAlpha({ rgbHex: extractedHex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + }; + const saturationStyle = { background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, }; @@ -240,90 +258,178 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", }; + const checkerboardStyle = { + backgroundImage: ` + linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), + linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) + `, + backgroundSize: "8px 8px", + backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", + backgroundColor: "#fff", + }; + return ( - <div className="relative flex-1"> + <Popover> <div ref={ref} className={cn( - "bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]", + "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]", className, )} {...props} > - <button - ref={triggerRef} - className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20" - style={{ backgroundColor: `#${value}` }} - type="button" - onClick={() => { - if (!isOpen && triggerRef.current && containerRef?.current) { - const containerRect = - containerRef.current.getBoundingClientRect(); - setPickerPosition({ - right: window.innerWidth - containerRect.left - 8, - bottom: window.innerHeight - containerRect.bottom, - }); - } - setIsOpen(!isOpen); - }} - /> + <PopoverTrigger asChild> + <button + className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20" + style={{ backgroundColor: `#${value}` }} + type="button" + /> + </PopoverTrigger> <div className="flex flex-1 items-center"> <Input - className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase" + className={cn( + "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0", + colorFormat === "hex" && "uppercase", + )} size="sm" containerClassName="w-full" value={inputValue} onChange={handleInputChange} onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} + onPaste={handlePaste} /> </div> </div> + <PopoverContent + className="w-64 px-0 select-none flex flex-col gap-3 py-2" + side="left" + sideOffset={8} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isDragging) event.preventDefault(); + }} + > + <header className="border-b flex justify-between items-center pb-2 px-2"> + <Select defaultValue="custom"> + <SelectTrigger variant="outline"> + <SelectValue placeholder="Select a mode" /> + </SelectTrigger> + <SelectContent position="popper"> + <SelectItem value="custom">Custom</SelectItem> + <SelectItem value="saved">Saved</SelectItem> + </SelectContent> + </Select> + <div> + {isEyeDropperSupported && ( + <Button + variant="ghost" + size="icon" + type="button" + onClick={handleEyeDropper} + > + <HugeiconsIcon icon={ColorPickerIcon} /> + </Button> + )} + <PopoverClose asChild> + <Button variant="ghost" size="icon" type="button"> + <HugeiconsIcon icon={Cancel01Icon} /> + </Button> + </PopoverClose> + </div> + </header> + <div className="px-2 flex flex-col gap-3"> + <button + ref={saturationRef} + className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0" + style={saturationStyle} + type="button" + onMouseDown={handleSaturationMouseDown} + > + <ColorCircle + size="sm" + position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} + color={`#${value}`} + /> + </button> - {isOpen && - createPortal( - <div - ref={pickerRef} - className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none" - style={{ - right: pickerPosition.right, - bottom: pickerPosition.bottom, - }} + <button + ref={hueRef} + className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0" + style={hueStyle} + type="button" + onMouseDown={handleHueMouseDown} > - <button - ref={saturationRef} - className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0" - style={saturationStyle} - type="button" - onMouseDown={handleSaturationMouseDown} - > - <ColorCircle - size="sm" - position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} - color={`#${value}`} - /> - </button> + <ColorCircle + size="md" + position={{ + left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`, + top: "50%", + }} + /> + </button> + + <button + ref={opacityRef} + className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0" + style={checkerboardStyle} + type="button" + onMouseDown={handleOpacityMouseDown} + > + <div + className="absolute inset-0 rounded-lg" + style={{ + background: `linear-gradient(to right, transparent, #${rgbValue})`, + }} + /> + <ColorCircle + size="md" + position={{ + left: `calc(0.5rem + (100% - 1rem) * ${alpha})`, + top: "50%", + }} + /> + </button> - <button - ref={hueRef} - className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0" - style={hueStyle} - type="button" - onMouseDown={handleHueMouseDown} + <div className="flex items-center gap-2"> + <Select + value={colorFormat} + onValueChange={(value) => setColorFormat(value as ColorFormat)} > - <ColorCircle - size="md" - position={{ - left: `${(displayHue / 360) * 100}%`, - top: "50%", - }} - color={`#${value}`} - /> - </button> - </div>, - document.body, - )} - </div> + <SelectTrigger variant="outline" className="min-w-18 max-w-18"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="hex">HEX</SelectItem> + <SelectItem value="rgb">RGB</SelectItem> + <SelectItem value="hsl">HSL</SelectItem> + <SelectItem value="hsv">HSV</SelectItem> + </SelectContent> + </Select> + + <Input + className={cn( + "h-7 rounded-sm p-2.5", + colorFormat === "hex" && "uppercase", + )} + type="text" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} + onPaste={handlePaste} + /> + </div> + </div> + </PopoverContent> + </Popover> ); }, ); @@ -336,7 +442,7 @@ const ColorCircle = ({ }: { size: "sm" | "md"; position: { left: string; top: string }; - color: string; + color?: string; }) => ( <div className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${ diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx deleted file mode 100644 index f5d96125..00000000 --- a/apps/web/src/components/ui/command.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import * as React from "react"; -import type { DialogProps } from "@radix-ui/react-dialog"; -import { Command as CommandPrimitive } from "cmdk"; -import { Search } from "lucide-react"; -import { cn } from "@/utils/ui"; -import { Dialog, DialogContent } from "./dialog"; - -const Command = React.forwardRef< - React.ElementRef<typeof CommandPrimitive>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive> ->(({ className, ...props }, ref) => ( - <CommandPrimitive - ref={ref} - className={cn( - "bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md", - className, - )} - {...props} - /> -)); -Command.displayName = CommandPrimitive.displayName; - -const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - <Dialog {...props}> - <DialogContent className="overflow-hidden p-0"> - <Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"> - {children} - </Command> - </DialogContent> - </Dialog> - ); -}; - -const CommandInput = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Input>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> ->(({ className, ...props }, ref) => ( - <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <Search className="mr-2 size-4 shrink-0 opacity-50" /> - <CommandPrimitive.Input - ref={ref} - className={cn( - "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", - className, - )} - {...props} - /> - </div> -)); - -CommandInput.displayName = CommandPrimitive.Input.displayName; - -const CommandList = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.List>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.List - ref={ref} - className={cn("max-h-[300px] overflow-x-hidden overflow-y-auto", className)} - {...props} - /> -)); - -CommandList.displayName = CommandPrimitive.List.displayName; - -const CommandEmpty = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Empty>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> ->((props, ref) => ( - <CommandPrimitive.Empty - ref={ref} - className="py-6 text-center text-sm" - {...props} - /> -)); - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Group>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Group - ref={ref} - className={cn( - "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", - className, - )} - {...props} - /> -)); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Separator - ref={ref} - className={cn("bg-border -mx-1 h-px", className)} - {...props} - /> -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Item - ref={ref} - className={cn( - "data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - className, - )} - {...props} - /> -)); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "text-muted-foreground ml-auto text-xs tracking-widest", - className, - )} - {...props} - /> - ); -}; -CommandShortcut.displayName = "CommandShortcut"; - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -}; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 4af92764..a49d8a88 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons" +import { + Tick02Icon, + ArrowRightIcon, + CircleIcon, +} from "@hugeicons/core-free-icons"; const ContextMenu = ContextMenuPrimitive.Root; @@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground", - destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", + default: + "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground", + destructive: + "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", }, }, defaultVariants: { @@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, inset, children, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} - {children} - <HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" /> - </ContextMenuPrimitive.SubTrigger> -)); +>( + ( + { className, inset, children, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className, + )} + {...props} + > + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} + {children} + <HugeiconsIcon + icon={ArrowRightIcon} + className="ml-auto text-muted-foreground/80" + /> + </ContextMenuPrimitive.SubTrigger> + ), +); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< @@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> ->(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Portal> + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { + container?: HTMLElement | null; + } +>(({ className, container, ...props }, ref) => ( + <ContextMenuPrimitive.Portal container={container ?? undefined}> <ContextMenuPrimitive.Content ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5", + "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5", className, )} {...props} @@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef< icon?: React.ReactNode; textRight?: string; } ->(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - > - {icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>} - {children} - {textRight && ( - <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> - {textRight} - </span> - )} - </ContextMenuPrimitive.Item> -)); + inset, + variant = "default", + icon, + children, + textRight, + ...props + }, + ref, + ) => { + const shouldInsetContent = inset || Boolean(icon); + + return ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + shouldInsetContent && "pl-8", + className, + )} + {...props} + > + {icon && ( + <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"> + {icon} + </span> + )} + {children} + {textRight && ( + <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> + {textRight} + </span> + )} + </ContextMenuPrimitive.Item> + ); + }, +); ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< @@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, children, checked, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} - checked={checked} - {...props} - > - <span className="absolute left-2 flex size-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <HugeiconsIcon icon={Tick02Icon} className="size-4" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} - {children} - </ContextMenuPrimitive.CheckboxItem> -)); +>( + ( + { className, children, checked, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "pr-2 pl-8", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-3 flex size-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <HugeiconsIcon icon={Tick02Icon} className="size-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} + {children} + </ContextMenuPrimitive.CheckboxItem> + ), +); ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; @@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef< <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" /> </ContextMenuPrimitive.ItemIndicator> </span> - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} {children} </ContextMenuPrimitive.RadioItem> )); @@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef< )} {...props} > - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} {children} </ContextMenuPrimitive.Label> )); @@ -208,7 +262,10 @@ const ContextMenuShortcut = ({ }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <span - className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)} + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground opacity-60", + className, + )} {...props} /> ); diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx deleted file mode 100644 index d5752d62..00000000 --- a/apps/web/src/components/ui/drawer.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Drawer as DrawerPrimitive } from "vaul"; - -import { cn } from "@/utils/ui"; - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( - <DrawerPrimitive.Root - shouldScaleBackground={shouldScaleBackground} - {...props} - /> -); -Drawer.displayName = "Drawer"; - -const DrawerTrigger = DrawerPrimitive.Trigger; - -const DrawerPortal = DrawerPrimitive.Portal; - -const DrawerClose = DrawerPrimitive.Close; - -const DrawerOverlay = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Overlay - ref={ref} - className={cn("fixed inset-0 z-50 bg-black/80", className)} - {...props} - /> -)); -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; - -const DrawerContent = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> ->(({ className, children, ...props }, ref) => ( - <DrawerPortal> - <DrawerOverlay /> - <DrawerPrimitive.Content - ref={ref} - className={cn( - "bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border", - className, - )} - {...props} - > - <div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" /> - {children} - </DrawerPrimitive.Content> - </DrawerPortal> -)); -DrawerContent.displayName = "DrawerContent"; - -const DrawerHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} - {...props} - /> -); -DrawerHeader.displayName = "DrawerHeader"; - -const DrawerFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("mt-auto flex flex-col gap-2 p-4", className)} - {...props} - /> -); -DrawerFooter.displayName = "DrawerFooter"; - -const DrawerTitle = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Title - ref={ref} - className={cn( - "text-lg leading-none font-semibold tracking-tight", - className, - )} - {...props} - /> -)); -DrawerTitle.displayName = DrawerPrimitive.Title.displayName; - -const DrawerDescription = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Description - ref={ref} - className={cn("text-muted-foreground text-sm", className)} - {...props} - /> -)); -DrawerDescription.displayName = DrawerPrimitive.Description.displayName; - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index d8a9f594..2d6541b8 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef< <DropdownMenuPrimitive.SubContent ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", + "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", className, )} {...props} @@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef< e.preventDefault(); }} className={cn( - "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg", + "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg", className, )} {...props} @@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; + icon?: React.ReactNode; variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; } ->(({ className, inset, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - /> -)); + inset, + icon, + variant = "default", + children, + asChild, + ...props + }, + ref, + ) => { + const iconSlot = ( + <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex"> + {icon} + </span> + ); + + const renderedChildren = + asChild && React.isValidElement(children) ? ( + React.cloneElement( + children as React.ReactElement<{ children?: React.ReactNode }>, + {}, + iconSlot, + (children as React.ReactElement<{ children?: React.ReactNode }>).props + .children, + ) + ) : ( + <> + {iconSlot} + {children} + </> + ); + + return ( + <DropdownMenuPrimitive.Item + ref={ref} + asChild={asChild} + data-has-icon={icon ? "" : undefined} + className={cn( + dropdownMenuItemVariants({ variant }), + inset && "pl-8", + className, + )} + {...props} + > + {renderedChildren} + </DropdownMenuPrimitive.Item> + ); + }, +); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 4b8e364a..d5d56255 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,16 +1,51 @@ +"use client"; + +import { + useState, + useMemo, + useRef, + useEffect, + useCallback, + type CSSProperties, +} from "react"; +import { List, type RowComponentProps } from "react-window"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + getCachedFontAtlas, + loadFullFont, + prefetchFontAtlas, + clearFontAtlasCache, +} from "@/lib/fonts/google-fonts"; +import type { FontAtlas, FontAtlasEntry } from "@/types/fonts"; import { cn } from "@/utils/ui"; +import { ChevronDown, Search, Upload } from "lucide-react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { TextFontIcon } from "@hugeicons/core-free-icons"; + +const FONT_TABS = [ + { key: "all", label: "All fonts" }, + { key: "favorites", label: "Favorites" }, + { key: "my-fonts", label: "My fonts" }, +] as const; + +type FontTab = (typeof FONT_TABS)[number]["key"]; + +const ROW_HEIGHT = 40; +const SPRITE_ROW_HEIGHT = 40; +const PREVIEW_SCALE = 0.8; +const LIST_WIDTH = 288; +const MAX_LIST_HEIGHT = 288; +const OVERSCAN = 15; interface FontPickerProps { - defaultValue?: FontFamily; - onValueChange?: (value: FontFamily) => void; + defaultValue?: string; + onValueChange?: (value: string) => void; className?: string; } @@ -19,24 +54,256 @@ export function FontPicker({ onValueChange, className, }: FontPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState<FontTab>("all"); + const [atlas, setAtlas] = useState<FontAtlas | null>(() => + getCachedFontAtlas(), + ); + const [status, setStatus] = useState<"idle" | "loading" | "error">(() => + getCachedFontAtlas() ? "idle" : "loading", + ); + const searchInputRef = useRef<HTMLInputElement>(null); + + const fontNames = useMemo(() => { + if (!atlas) return []; + return Object.keys(atlas.fonts).sort(); + }, [atlas]); + + const filteredFonts = useMemo(() => { + if (!search) return fontNames; + const query = search.toLowerCase(); + return fontNames.filter((name) => name.toLowerCase().includes(query)); + }, [fontNames, search]); + + const listHeight = Math.min( + MAX_LIST_HEIGHT, + filteredFonts.length * ROW_HEIGHT, + ); + + const handleSelect = useCallback( + async ({ family }: { family: string }) => { + try { + await loadFullFont({ family }); + onValueChange?.(family); + } catch { + onValueChange?.(family); + } + setOpen(false); + }, + [onValueChange], + ); + + // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed) + useEffect(() => { + if (!open || atlas) return; + + setStatus("loading"); + prefetchFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, [open, atlas]); + + useEffect(() => { + if (!open) { + setSearch(""); + setActiveTab("all"); + } + }, [open]); + + const handleRetry = useCallback(() => { + clearFontAtlasCache(); + setStatus("loading"); + prefetchFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, []); + return ( - <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger - className={cn("w-full", className)} + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger + className={cn( + "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden", + className, + )} > - <SelectValue placeholder="Select a font" /> - </SelectTrigger> - <SelectContent> - {FONT_OPTIONS.map((font) => ( - <SelectItem - key={font.value} - value={font.value} - style={{ fontFamily: font.value }} + <div className="flex min-w-0 items-center gap-1.5"> + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + <HugeiconsIcon icon={TextFontIcon} /> + </span> + <span className="truncate" style={{ fontFamily: defaultValue }}> + {defaultValue ?? "Select a font"} + </span> + </div> + <ChevronDown className="size-3 shrink-0 opacity-50" /> + </PopoverTrigger> + <PopoverContent + className="w-72 p-0 overflow-hidden" + align="start" + side="left" + onOpenAutoFocus={(event) => { + event.preventDefault(); + searchInputRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + <div className="relative px-3 py-1.5"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" /> + <Input + ref={searchInputRef} + placeholder="Search fonts..." + value={search} + onChange={(event) => setSearch(event.target.value)} + size="xs" + className="w-full pl-5 bg-transparent !border-none !shadow-none" + /> + </div> + <div className="flex border-b px-3"> + {FONT_TABS.map((tab) => ( + <button + key={tab.key} + type="button" + className={cn( + "px-3 py-1.5 text-xs border-b-2 -mb-px", + activeTab === tab.key + ? "border-foreground text-foreground" + : "border-transparent text-muted-foreground hover:text-foreground", + )} + onClick={() => setActiveTab(tab.key)} + > + {tab.label} + </button> + ))} + </div> + {status === "loading" && ( + <div className="py-8 text-center text-sm text-muted-foreground"> + Loading fonts... + </div> + )} + {status === "error" && ( + <div className="flex flex-col items-center gap-3 py-8 px-4"> + <p className="text-sm text-muted-foreground text-center"> + Failed to load font previews. + </p> + <Button variant="outline" size="sm" onClick={handleRetry}> + Retry + </Button> + </div> + )} + {status === "idle" && + fontNames.length > 0 && + filteredFonts.length === 0 && ( + <div className="py-6 text-center text-sm text-muted-foreground"> + No fonts found. + </div> + )} + {status === "idle" && atlas && filteredFonts.length > 0 && ( + <List + rowCount={filteredFonts.length} + rowHeight={ROW_HEIGHT} + overscanCount={OVERSCAN} + rowComponent={FontRow} + rowProps={{ + atlas, + filteredFonts, + selectedFont: defaultValue, + onFontSelect: handleSelect, + }} + style={{ height: listHeight, width: LIST_WIDTH }} + /> + )} + <div className="border-t p-1"> + <Button + variant="ghost" + size="sm" + className="w-full justify-start text-muted-foreground h-8 font-normal" + onClick={() => { + // TODO: Implement local font loading + console.log("Load local fonts clicked"); + }} > - {font.label} - </SelectItem> - ))} - </SelectContent> - </Select> + <Upload className="!size-3.5" /> + Load local fonts + </Button> + </div> + </PopoverContent> + </Popover> + ); +} + +function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { + return ( + <div + className="shrink-0" + style={{ + width: entry.w, + height: SPRITE_ROW_HEIGHT, + backgroundColor: "currentColor", + WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, + WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, + WebkitMaskRepeat: "no-repeat", + maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, + maskPosition: `-${entry.x}px -${entry.y}px`, + maskRepeat: "no-repeat", + transform: `scale(${PREVIEW_SCALE})`, + transformOrigin: "left center", + }} + /> + ); +} + +type FontRowProps = { + atlas: FontAtlas; + filteredFonts: string[]; + selectedFont: string | undefined; + onFontSelect: (params: { family: string }) => void; +}; + +function FontRow({ + index, + style, + atlas, + filteredFonts, + selectedFont, + onFontSelect, +}: RowComponentProps<FontRowProps>) { + const fontName = filteredFonts[index]; + const entry = atlas.fonts[fontName]; + const isSelected = fontName === selectedFont; + + return ( + <button + type="button" + style={style as CSSProperties} + className={cn( + "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover", + isSelected && "bg-popover-hover", + )} + onClick={() => onFontSelect({ family: fontName })} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onFontSelect({ family: fontName }); + } + }} + aria-label={fontName} + > + <div className="min-w-0 overflow-hidden"> + <FontSpritePreview entry={entry} /> + </div> + </button> ); } diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx deleted file mode 100644 index ec71f96a..00000000 --- a/apps/web/src/components/ui/input-otp.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import * as React from "react"; -import { OTPInput, OTPInputContext } from "input-otp"; -import { Minus } from "lucide-react"; - -import { cn } from "@/utils/ui"; - -const InputOTP = React.forwardRef< - React.ElementRef<typeof OTPInput>, - React.ComponentPropsWithoutRef<typeof OTPInput> ->(({ className, containerClassName, ...props }, ref) => ( - <OTPInput - ref={ref} - containerClassName={cn( - "flex items-center justify-between gap-2 w-full has-disabled:opacity-50", - containerClassName, - )} - className={cn("disabled:cursor-not-allowed", className)} - {...props} - /> -)); -InputOTP.displayName = "InputOTP"; - -const InputOTPGroup = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex flex-1 items-center gap-2", className)} - {...props} - /> -)); -InputOTPGroup.displayName = "InputOTPGroup"; - -const InputOTPSlot = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> & { index: number } ->(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; - - return ( - <div - ref={ref} - className={cn( - "border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md", - isActive && "ring-ring z-10 ring-1", - className, - )} - {...props} - > - {char} - {hasFakeCaret && ( - <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> - <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" /> - </div> - )} - </div> - ); -}); -InputOTPSlot.displayName = "InputOTPSlot"; - -const InputOTPSeparator = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> ->(({ ...props }, ref) => ( - <div ref={ref} role="separator" {...props}> - <Minus /> - </div> -)); -InputOTPSeparator.displayName = "InputOTPSeparator"; - -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index f8265779..1c22fd2e 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -1,88 +1,85 @@ -"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button
- variant="outline"
- className="bg-accent !size-9 rounded-full"
- >
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
+"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disableAnimation?: boolean; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, + disableAnimation = false, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: disableAnimation ? 0 : 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( + <div ref={setContainerRef} className="relative w-full"> + <motion.div + className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75" + initial={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + animate={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + transition={smoothTransition} + onClick={() => setIsExpanded(!isExpanded)} + > + <Button variant="outline" className="bg-accent !size-9 rounded-full"> + <ArrowLeft /> + </Button> + </motion.div> + <div + className="relative flex-1" + style={{ marginLeft: "0px", paddingLeft: "0px" }} + > + <motion.div + className="relative" + initial={{ + marginLeft: isExpanded ? 50 : 0, + }} + animate={{ + marginLeft: isExpanded ? 50 : 0, + }} + transition={smoothTransition} + > + <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> + <Input + placeholder={placeholder} + className="bg-accent w-full pl-9" + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> + </motion.div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 9be4bca2..762cc675 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; const inputVariants = cva( - "file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10", - destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", + default: "selection:bg-primary selection:text-primary-foreground", + destructive: + "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", }, size: { - default: - "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", - sm: "h-8 px-3 text-xs file:h-6 file:text-xs", + default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", + xs: "h-7 px-3 text-xs file:h-6 file:text-xs", + sm: "h-8 px-3 text-sm file:h-6 file:text-xs", lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", }, }, @@ -102,7 +103,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>( /> {showClear && ( <Button - type="button" variant="text" size="icon" onMouseDown={(e) => { @@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>( )} {showPasswordToggle && ( <Button - type="button" variant="text" size="icon" onClick={() => onShowPasswordChange?.(!showPassword)} diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx new file mode 100644 index 00000000..35cce81b --- /dev/null +++ b/apps/web/src/components/ui/number-field.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { cn } from "@/utils/ui"; +import { useRef, useState, type ComponentProps } from "react"; +import { useFocusLock } from "@/hooks/use-focus-lock"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; + +const DRAG_SENSITIVITIES = { + default: 1, + slow: 0.5, +} as const; + +type DragSensitivity = "default" | "slow"; + +interface NumberFieldProps + extends Omit<ComponentProps<"input">, "size" | "type"> { + icon?: React.ReactNode; + dragSensitivity?: DragSensitivity; + onScrub?: (value: number) => void; + onScrubEnd?: () => void; + allowExpressions?: boolean; + onReset?: () => void; + isDefault?: boolean; +} + +function NumberField({ + className, + icon, + disabled, + dragSensitivity = "default", + onScrub, + onScrubEnd, + value, + allowExpressions = true, + onKeyDown, + onFocus, + onBlur, + onMouseDown, + onReset, + isDefault = false, + ref, + ...props +}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) { + const iconRef = useRef<HTMLSpanElement>(null); + const inputRef = useRef<HTMLInputElement>(null); + const startValueRef = useRef(0); + const cumulativeDeltaRef = useRef(0); + const [isInputFocused, setIsInputFocused] = useState(false); + + const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({ + isActive: isInputFocused, + onDismiss: () => inputRef.current?.blur(), + cursor: "text", + allowSelector: "input, textarea, [contenteditable]", + }); + + const handleIconPointerDown = (event: React.PointerEvent) => { + if (!onScrub || disabled || event.button !== 0) return; + const parsed = parseFloat(String(value ?? "0")); + startValueRef.current = Number.isNaN(parsed) ? 0 : parsed; + cumulativeDeltaRef.current = 0; + let hasReceivedFirstMove = false; + iconRef.current?.requestPointerLock(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + // first movementX after pointer lock often contains a bogus warp delta + if (!hasReceivedFirstMove) { + hasReceivedFirstMove = true; + return; + } + cumulativeDeltaRef.current += moveEvent.movementX; + const newValue = + startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; + onScrub(newValue); + }; + + const handlePointerUp = () => { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.exitPointerLock(); + onScrubEnd?.(); + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + }; + + const canScrub = Boolean(icon && onScrub); + + return ( + <div + ref={wrapperRef} + className={cn( + "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", + disabled && "pointer-events-none cursor-not-allowed opacity-50", + className, + )} + > + {icon && ( + <span + ref={iconRef} + className={cn( + "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none", + canScrub && "cursor-ew-resize", + )} + onPointerDown={canScrub ? handleIconPointerDown : undefined} + > + {icon} + </span> + )} + <input + type={allowExpressions ? "text" : "number"} + inputMode={allowExpressions ? "decimal" : undefined} + ref={inputRef} + disabled={disabled} + value={value} + className={cn( + "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + icon ? "px-1.5" : "pl-2.5", + onReset ? "pr-0" : "pr-2.5", + )} + onMouseDown={(event) => { + const inputElement = event.currentTarget; + const shouldPreventNativeCaretPlacement = + event.button === 0 && document.activeElement !== inputElement; + if (shouldPreventNativeCaretPlacement) { + event.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + onMouseDown?.(event); + }} + onFocus={(event) => { + setIsInputFocused(true); + event.currentTarget.select(); + onFocus?.(event); + }} + onKeyDown={(event) => { + const shouldBlurInput = + event.key === "Enter" || event.key === "Escape"; + if (shouldBlurInput) event.currentTarget.blur(); + onKeyDown?.(event); + }} + onBlur={(event) => { + setIsInputFocused(false); + onBlur?.(event); + }} + {...props} + /> + {onReset && !isDefault && ( + <div className="shrink-0 pr-2 flex items-center"> + <Button + variant="text" + size="text" + aria-label="Reset to default" + onClick={onReset} + > + <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" /> + </Button> + </div> + )} + </div> + ); +} + +export { NumberField }; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx deleted file mode 100644 index 96eec969..00000000 --- a/apps/web/src/components/ui/pagination.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; - -import { cn } from "@/utils/ui"; -import { type ButtonProps, buttonVariants } from "./button"; - -const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( - <nav - role="navigation" - aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} - {...props} - /> -); -Pagination.displayName = "Pagination"; - -const PaginationContent = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> ->(({ className, ...props }, ref) => ( - <ul - ref={ref} - className={cn("flex flex-row items-center gap-1", className)} - {...props} - /> -)); -PaginationContent.displayName = "PaginationContent"; - -const PaginationItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> ->(({ className, ...props }, ref) => ( - <li ref={ref} className={cn("", className)} {...props} /> -)); -PaginationItem.displayName = "PaginationItem"; - -type PaginationLinkProps = { - isActive?: boolean; -} & Pick<ButtonProps, "size"> & - React.ComponentProps<"a">; - -const PaginationLink = ({ - className, - isActive, - size = "icon", - ...props -}: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "text", - size, - }), - className, - )} - {...props} - /> -); -PaginationLink.displayName = "PaginationLink"; - -const PaginationPrevious = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to previous page" - size="default" - className={cn("gap-1 pl-2.5", className)} - {...props} - > - <ChevronLeft className="size-4" /> - <span>Previous</span> - </PaginationLink> -); -PaginationPrevious.displayName = "PaginationPrevious"; - -const PaginationNext = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to next page" - size="default" - className={cn("gap-1 pr-2.5", className)} - {...props} - > - <span>Next</span> - <ChevronRight className="size-4" /> - </PaginationLink> -); -PaginationNext.displayName = "PaginationNext"; - -const PaginationEllipsis = ({ - className, - ...props -}: React.ComponentProps<"span">) => ( - <span - aria-hidden - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More pages</span> - </span> -); -PaginationEllipsis.displayName = "PaginationEllipsis"; - -export { - Pagination, - PaginationContent, - PaginationLink, - PaginationItem, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -}; diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx deleted file mode 100644 index 48b002ca..00000000 --- a/apps/web/src/components/ui/phone-input.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import * as RPNInput from "react-phone-number-input"; -import flags from "react-phone-number-input/flags"; - -import { Button } from "./button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./command"; -import { Input } from "./input"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { ScrollArea } from "./scroll-area"; -import { cn } from "@/utils/ui"; - -type PhoneInputProps = Omit< - React.ComponentProps<"input">, - "onChange" | "value" | "ref" -> & - Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { - onChange?: (value: RPNInput.Value) => void; - }; - -const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = - React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( - ({ className, onChange, defaultCountry = "US", ...props }, ref) => { - return ( - <RPNInput.default - ref={ref} - className={cn("flex", className)} - flagComponent={FlagComponent} - countrySelectComponent={CountrySelect} - inputComponent={InputComponent} - defaultCountry={defaultCountry} - international - addInternationalOption - smartCaret={false} - /** - * Handles the onChange event. - * - * react-phone-number-input might trigger the onChange event as undefined - * when a valid phone number is not entered. To prevent this, - * the value is coerced to an empty string. - * - * @param {E164Number | undefined} value - The entered value - */ - onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} - {...props} - /> - ); - }, - ); -PhoneInput.displayName = "PhoneInput"; - -const InputComponent = React.forwardRef< - HTMLInputElement, - React.ComponentProps<"input"> ->(({ className, size: _size, ...props }, ref) => ( - <Input - className={cn("rounded-s-none rounded-e-lg", className)} - {...props} - ref={ref} - /> -)); -InputComponent.displayName = "InputComponent"; - -type CountryEntry = { label: string; value: RPNInput.Country | undefined }; - -type CountrySelectProps = { - disabled?: boolean; - value: RPNInput.Country; - options: CountryEntry[]; - onChange: (country: RPNInput.Country) => void; -}; - -const CountrySelect = ({ - disabled, - value: selectedCountry, - options: countryList, - onChange, -}: CountrySelectProps) => { - return ( - <Popover> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - className="flex gap-1 rounded-s-lg rounded-e-none border-r-0 px-3 focus:z-10" - disabled={disabled} - > - <FlagComponent - country={selectedCountry} - countryName={selectedCountry} - /> - <ChevronsUpDown - className={cn( - "-mr-2 opacity-50", - disabled ? "hidden" : "opacity-100", - )} - /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[300px] p-0"> - <Command> - <CommandInput placeholder="Search country..." /> - <CommandList> - <ScrollArea className="h-72"> - <CommandEmpty>No country found.</CommandEmpty> - <CommandGroup> - {countryList.map(({ value, label }) => - value ? ( - <CountrySelectOption - key={value} - country={value} - countryName={label} - selectedCountry={selectedCountry} - onChange={onChange} - /> - ) : null, - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}; - -interface CountrySelectOptionProps extends RPNInput.FlagProps { - selectedCountry: RPNInput.Country; - onChange: (country: RPNInput.Country) => void; -} - -const CountrySelectOption = ({ - country, - countryName, - selectedCountry, - onChange, -}: CountrySelectOptionProps) => { - return ( - <CommandItem className="gap-2" onSelect={() => onChange(country)}> - <FlagComponent country={country} countryName={countryName} /> - <span className="flex-1 text-sm">{countryName}</span> - <span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span> - <CheckIcon - className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} - /> - </CommandItem> - ); -}; - -const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { - const Flag = flags[country]; - - return ( - <span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full"> - {Flag && <Flag title={countryName} />} - </span> - ); -}; - -export { PhoneInput }; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 36ae5a69..8fd511a3 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; +const PopoverClose = PopoverPrimitive.Close; + const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> @@ -20,7 +22,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", + "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", className, )} {...props} @@ -29,4 +31,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }; diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx index 4e526b7e..88d017fe 100644 --- a/apps/web/src/components/ui/prose.tsx +++ b/apps/web/src/components/ui/prose.tsx @@ -1,4 +1,7 @@ import { cn } from "@/utils/ui"; +import rehypeParse from "rehype-parse"; +import { unified } from "unified"; +import { createElement } from "react"; import type React from "react"; type ProseProps = React.HTMLAttributes<HTMLElement> & { @@ -6,6 +9,92 @@ type ProseProps = React.HTMLAttributes<HTMLElement> & { html: string; }; +type HastTextNode = { + type: "text"; + value: string; +}; + +type HastElementNode = { + type: "element"; + tagName: string; + properties?: Record<string, unknown>; + children?: HastNode[]; +}; + +type HastRootNode = { + type: "root"; + children?: HastNode[]; +}; + +type HastNode = HastTextNode | HastElementNode | HastRootNode; + +function toReactProps({ + properties, +}: { + properties: HastElementNode["properties"]; +}): Record<string, unknown> { + if (!properties) { + return {}; + } + + const props: Record<string, unknown> = {}; + + for (const [propertyName, propertyValue] of Object.entries(properties)) { + if (propertyValue === null || propertyValue === undefined) { + continue; + } + + if (propertyName.startsWith("on")) { + continue; + } + + if (propertyName === "class") { + props.className = Array.isArray(propertyValue) + ? propertyValue.join(" ") + : propertyValue; + continue; + } + + if (propertyName === "for") { + props.htmlFor = propertyValue; + continue; + } + + props[propertyName] = propertyValue; + } + + return props; +} + +function renderHastNode({ + node, + key, +}: { + node: HastNode; + key: string; +}): React.ReactNode { + if (node.type === "text") { + return node.value; + } + + if (node.type !== "element") { + return null; + } + + const children = (node.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `${key}-${index}` }), + ); + + return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children); +} + +function renderHtmlNodes({ html }: { html: string }): React.ReactNode { + const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode; + return (rootNode.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `prose-node-${index}` }), + ); +} + function Prose({ children, html, className }: ProseProps) { return ( <article @@ -14,7 +103,7 @@ function Prose({ children, html, className }: ProseProps) { className, )} > - {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children} + {html ? renderHtmlNodes({ html }) : children} </article> ); } diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index fd9c7ac5..311f6edb 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -2,10 +2,11 @@ import * as React from "react"; import { Select as SelectPrimitive } from "radix-ui"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { Check } from "lucide-react"; +import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; const Select = SelectPrimitive.Root; @@ -14,36 +15,63 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "", + destructive: + "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const selectTriggerVariants = cva( + "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", { variants: { variant: { + default: "bg-accent", + outline: "bg-background hover:bg-accent/50", + }, + size: { default: "", - destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + sm: "", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & + VariantProps<typeof selectTriggerVariants> & { + icon?: React.ReactNode; + } +>(({ className, children, icon, variant, size, ...props }, ref) => ( <SelectPrimitive.Trigger ref={ref} - className={cn( - "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none", - className, - )} + className={cn(selectTriggerVariants({ variant, size }), className)} {...props} > - {children} + <div className="flex items-center gap-1.5"> + {icon && ( + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + {icon} + </span> + )} + {children} + </div> <SelectPrimitive.Icon asChild> - <ChevronDown className="size-3 opacity-50" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )); @@ -61,7 +89,7 @@ const SelectScrollUpButton = React.forwardRef< )} {...props} > - <ChevronUp className="size-4" /> + <HugeiconsIcon icon={ArrowUpIcon} className="size-4" /> </SelectPrimitive.ScrollUpButton> )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; @@ -78,7 +106,7 @@ const SelectScrollDownButton = React.forwardRef< )} {...props} > - <ChevronDown className="size-4" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.ScrollDownButton> )); SelectScrollDownButton.displayName = @@ -92,7 +120,7 @@ const SelectContent = React.forwardRef< <SelectPrimitive.Content ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", + "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", className, )} position={position} @@ -124,7 +152,7 @@ const SelectLabel = React.forwardRef< <SelectPrimitive.Label ref={ref} className={cn( - "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", + "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", className, )} {...props} @@ -140,12 +168,12 @@ const SelectItem = React.forwardRef< >(({ className, children, variant = "default", ...props }, ref) => ( <SelectPrimitive.Item ref={ref} - className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)} + className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)} {...props} > - <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <span className="absolute left-1.5 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> - <Check className="size-4" /> + <Check className="size-3.5" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> @@ -159,7 +187,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} - className={cn("bg-border mx-1 my-2 h-px", className)} + className={cn("bg-border mx-1 my-1 h-px", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx deleted file mode 100644 index a41e25c7..00000000 --- a/apps/web/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,725 +0,0 @@ -"use client"; - -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; -import * as React from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/utils/ui"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const SIDEBAR_KEYBOARD_SHORTCUT = "b"; - -type SidebarContextProps = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext<SidebarContextProps | null>(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; - - const contextValue = React.useMemo<SidebarContextProps>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], - ); - - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - data-slot="sidebar-wrapper" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", - className, - )} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === "none") { - return ( - <div - data-slot="sidebar" - className={cn( - "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", - className, - )} - {...props} - > - {children} - </div> - ); - } - - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-slot="sidebar" - data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetHeader className="sr-only"> - <SheetTitle>Sidebar</SheetTitle> - <SheetDescription>Displays the mobile sidebar.</SheetDescription> - </SheetHeader> - <div className="flex size-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } - - return ( - <div - className="group peer text-sidebar-foreground hidden md:block" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - data-slot="sidebar" - > - {/* This is what handles the sidebar gap on desktop */} - <div - data-slot="sidebar-gap" - className={cn( - "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", - )} - /> - <div - data-slot="sidebar-container" - className={cn( - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", - side === "left" - ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - // Adjust the padding for floating and inset variants. - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", - className, - )} - {...props} - > - <div - data-sidebar="sidebar" - data-slot="sidebar-inner" - className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" - > - {children} - </div> - </div> - </div> - ); -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps<typeof Button>) { - const { toggleSidebar } = useSidebar(); - - return ( - <Button - data-sidebar="trigger" - data-slot="sidebar-trigger" - variant="text" - size="icon" - className={cn("size-7", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeftIcon /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); - - return ( - <button - data-sidebar="rail" - data-slot="sidebar-rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", - "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { - return ( - <main - data-slot="sidebar-inset" - className={cn( - "bg-background relative flex w-full flex-1 flex-col", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInput({ - className, - ...props -}: React.ComponentProps<typeof Input>) { - return ( - <Input - data-slot="sidebar-input" - data-sidebar="input" - className={cn("bg-background h-8 w-full shadow-none", className)} - {...props} - /> - ); -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-header" - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-footer" - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps<typeof Separator>) { - return ( - <Separator - data-slot="sidebar-separator" - data-sidebar="separator" - className={cn("bg-sidebar-border mx-2 w-auto", className)} - {...props} - /> - ); -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-content" - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group" - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); -} - -function SidebarGroupLabel({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; - - return ( - <Comp - data-slot="sidebar-group-label" - data-sidebar="group-label" - className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupAction({ - className, - asChild = false, - ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-group-action" - data-sidebar="group-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group-content" - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> - ); -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu" - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> - ); -} - -function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-item" - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> - ); -} - -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -function SidebarMenuButton({ - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; -} & VariantProps<typeof sidebarMenuButtonVariants>) { - const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); - - const button = ( - <Comp - data-slot="sidebar-menu-button" - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); - - if (!tooltip) { - return button; - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } - - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); -} - -function SidebarMenuAction({ - className, - asChild = false, - showOnHover = false, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-menu-action" - data-sidebar="menu-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuBadge({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-menu-badge" - data-sidebar="menu-badge" - className={cn( - "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean; -}) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); - - return ( - <div - data-slot="sidebar-menu-skeleton" - data-sidebar="menu-skeleton" - className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 max-w-(--skeleton-width) flex-1" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu-sub" - data-sidebar="menu-sub" - className={cn( - "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-sub-item" - data-sidebar="menu-sub-item" - className={cn("group/menu-sub-item relative", className)} - {...props} - /> - ); -} - -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - <Comp - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -}; diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index a46792f7..5546d539 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -3,15 +3,15 @@ import { Loading03Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) { - return ( - <HugeiconsIcon - icon={Loading03Icon} - role="status" - aria-label="Loading" - className={cn("size-4 animate-spin", className)} - {...props} - /> - ) + return ( + <HugeiconsIcon + icon={Loading03Icon} + role="status" + aria-label="Loading" + className={cn("size-4 animate-spin", className)} + {...props} + /> + ); } -export { Spinner } +export { Spinner }; diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index aa8b331a..dfc4cdfe 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -1,90 +1,90 @@ -import { Button, type ButtonProps } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { type ReactNode, forwardRef } from "react";
-import { cn } from "@/utils/ui";
-
-interface SplitButtonProps {
- children: ReactNode;
- className?: string;
-}
-
-interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
- children: ReactNode;
-}
-
-const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
- ({ children, className, ...props }, ref) => {
- return (
- <div
- ref={ref}
- className={cn(
- "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- },
-);
-SplitButton.displayName = "SplitButton";
-
-const SplitButtonSide = forwardRef<
- HTMLButtonElement,
- SplitButtonSideProps & { paddingClass: string }
->(({ children, className, paddingClass, onClick, ...props }, ref) => {
- return (
- <Button
- ref={ref}
- variant="text"
- className={cn(
- "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
- onClick
- ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
- : "cursor-default select-text",
- paddingClass,
- className,
- )}
- onClick={onClick}
- {...props}
- >
- {typeof children === "string" ? (
- <span className="cursor-text font-normal">{children}</span>
- ) : (
- children
- )}
- </Button>
- );
-});
-SplitButtonSide.displayName = "SplitButtonSide";
-
-const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
- },
-);
-SplitButtonLeft.displayName = "SplitButtonLeft";
-
-const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
- },
-);
-SplitButtonRight.displayName = "SplitButtonRight";
-
-const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
- ({ className, ...props }, ref) => {
- return (
- <Separator
- ref={ref}
- orientation="vertical"
- className={cn("bg-foreground/15 h-full", className)}
- {...props}
- />
- );
- },
-);
-SplitButtonSeparator.displayName = "SplitButtonSeparator";
-
-export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+import { Button, type ButtonProps } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/utils/ui"; + +interface SplitButtonProps { + children: ReactNode; + className?: string; +} + +interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> { + children: ReactNode; +} + +const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( + ({ children, className, ...props }, ref) => { + return ( + <div + ref={ref} + className={cn( + "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border", + className, + )} + {...props} + > + {children} + </div> + ); + }, +); +SplitButton.displayName = "SplitButton"; + +const SplitButtonSide = forwardRef< + HTMLButtonElement, + SplitButtonSideProps & { paddingClass: string } +>(({ children, className, paddingClass, onClick, ...props }, ref) => { + return ( + <Button + ref={ref} + variant="text" + className={cn( + "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100", + onClick + ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" + : "cursor-default select-text", + paddingClass, + className, + )} + onClick={onClick} + {...props} + > + {typeof children === "string" ? ( + <span className="cursor-text font-normal">{children}</span> + ) : ( + children + )} + </Button> + ); +}); +SplitButtonSide.displayName = "SplitButtonSide"; + +const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />; + }, +); +SplitButtonLeft.displayName = "SplitButtonLeft"; + +const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />; + }, +); +SplitButtonRight.displayName = "SplitButtonRight"; + +const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>( + ({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + orientation="vertical" + className={cn("bg-foreground/15 h-full", className)} + {...props} + /> + ); + }, +); +SplitButtonSeparator.displayName = "SplitButtonSeparator"; + +export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index ca60a809..2ae59697 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,8 +9,8 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 769ac360..067042e6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -60,6 +60,7 @@ const TooltipContent = React.forwardRef< fill="none" xmlns="http://www.w3.org/2000/svg" className="absolute top-1/2 left-[-6px] -translate-y-1/2" + aria-hidden="true" > <path d="M6 0L0 5L6 10V0Z" |
