mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-10-04 11:37:44 +00:00
Restore timeline palette and refine compact wallpaper controls
This commit is contained in:
@@ -20,7 +20,7 @@ export function ChoiceGroup({
|
||||
}) {
|
||||
return (
|
||||
<TagGroup
|
||||
size="lg"
|
||||
size="md"
|
||||
aria-label={props["aria-label"]}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
@@ -58,7 +58,7 @@ export function ChoiceItem({
|
||||
title ??
|
||||
(typeof children === "string" ? children : props["aria-label"])
|
||||
}
|
||||
className={`min-h-9 justify-center ${className ?? ""}`}
|
||||
className={`min-h-8 justify-center ${className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ColorControl({
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={label}
|
||||
className="h-10 min-w-0 max-w-full gap-2 px-3"
|
||||
className="h-9 min-w-0 max-w-full gap-2 px-3 text-[13px]"
|
||||
>
|
||||
<ColorSwatch size="sm" />
|
||||
<span className="truncate">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ToggleButton } from "@heroui/react";
|
||||
import { Card, RadioGroup, Radio, Label, Description } from "@heroui/react";
|
||||
import { ProgressBar } from "@heroui/react";
|
||||
import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
|
||||
import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
|
||||
import { Palette, Trash as Trash2, UploadSimple as Upload } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
|
||||
import { getDefaultBorderRadiusPercent } from "./projectPersistence";
|
||||
import { SliderControl } from "./SliderControl";
|
||||
import { WallpaperGrid } from "./WallpaperGrid";
|
||||
import { KeyboardShortcutsDialog } from "./TutorialHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -185,49 +185,7 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab {
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <Label>{children}</Label>;
|
||||
}
|
||||
|
||||
function WallpaperVideoPreview({ src }: { src: string }) {
|
||||
const [resolvedSrc, setResolvedSrc] = useState(src);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setResolvedSrc(src);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const nextSrc = await getRenderableVideoUrl(src);
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(nextSrc);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(src);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<video
|
||||
src={resolvedSrc}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
|
||||
draggable={false}
|
||||
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.pause();
|
||||
e.currentTarget.currentTime = 0;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <Label className="text-[13px]">{children}</Label>;
|
||||
}
|
||||
|
||||
const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"];
|
||||
@@ -1386,44 +1344,6 @@ export function SettingsPanel({
|
||||
isSelected && "ring-2 ring-accent",
|
||||
);
|
||||
|
||||
const renderWallpaperImageTile = (
|
||||
wallpaperUrl: string,
|
||||
isSelected: boolean,
|
||||
props?: {
|
||||
key?: string;
|
||||
ariaLabel?: string;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
},
|
||||
) => (
|
||||
<ToggleButton
|
||||
isSelected={isSelected}
|
||||
key={props?.key}
|
||||
className={wallpaperTileClass(isSelected)}
|
||||
aria-label={props?.ariaLabel}
|
||||
onClick={props?.onClick}
|
||||
>
|
||||
<div className="absolute inset-px overflow-hidden rounded-[5px]">
|
||||
{isVideoWallpaperSource(wallpaperUrl) ? (
|
||||
<WallpaperVideoPreview src={wallpaperUrl} />
|
||||
) : (
|
||||
<img
|
||||
src={wallpaperUrl}
|
||||
alt={
|
||||
props?.title ??
|
||||
props?.ariaLabel ??
|
||||
tSettings("background.wallpaperPreview", "Wallpaper preview")
|
||||
}
|
||||
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{props?.children}
|
||||
</ToggleButton>
|
||||
);
|
||||
|
||||
const crop = cropRegion ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1654,8 +1574,7 @@ export function SettingsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const handleRemoveCustomImage = (imageUrl: string) => {
|
||||
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
|
||||
// If the removed image was selected, clear selection
|
||||
if (selected === imageUrl) {
|
||||
@@ -1733,8 +1652,8 @@ export function SettingsPanel({
|
||||
exit={{ opacity: 0, y: -8, filter: "blur(6px)" }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{backgroundTab === "image" ? (
|
||||
<div className="mt-0 space-y-4">
|
||||
{backgroundTab === "image" || backgroundTab === "video" ? (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -1742,126 +1661,49 @@ export function SettingsPanel({
|
||||
accept=".jpg,.jpeg,image/jpeg"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="w-full gap-2 h-9"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
{tSettings("background.uploadCustom")}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{customImages.map((imageUrl, idx) => {
|
||||
const isSelected = getWallpaperTileState(imageUrl);
|
||||
return renderWallpaperImageTile(imageUrl, isSelected, {
|
||||
key: `custom-${idx}`,
|
||||
ariaLabel: isVideoWallpaperSource(imageUrl)
|
||||
? (imageUrl.split(/[\\/]/).pop() ??
|
||||
tSettings(
|
||||
"background.video",
|
||||
"Video background",
|
||||
))
|
||||
: undefined,
|
||||
title: isVideoWallpaperSource(imageUrl)
|
||||
? imageUrl.split(/[\\/]/).pop()
|
||||
: undefined,
|
||||
onClick: () => onWallpaperChange(imageUrl),
|
||||
children: (
|
||||
<Button
|
||||
variant="destructive-soft"
|
||||
onClick={(e) =>
|
||||
handleRemoveCustomImage(imageUrl, e)
|
||||
}
|
||||
className="absolute top-0.5 right-0.5 w-3 h-3 flex items-center justify-center z-10"
|
||||
>
|
||||
<X className="w-2 h-2 text-white" />
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
})}
|
||||
|
||||
{imageWallpaperTiles.map((tile) => {
|
||||
const isSelected = getWallpaperTileState(
|
||||
tile.value,
|
||||
tile.previewUrl,
|
||||
);
|
||||
return renderWallpaperImageTile(
|
||||
tile.previewUrl,
|
||||
isSelected,
|
||||
{
|
||||
key: tile.key,
|
||||
ariaLabel: tile.label,
|
||||
title: tile.label,
|
||||
onClick: () => onWallpaperChange(tile.value),
|
||||
},
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : backgroundTab === "video" ? (
|
||||
<div className="mt-0 space-y-4">
|
||||
<Button
|
||||
onClick={handleVideoUpload}
|
||||
variant="outline"
|
||||
className="w-full gap-2 h-9"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
{tSettings("background.uploadCustomVideo", "Upload Video")}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{customImages
|
||||
.filter(isVideoWallpaperSource)
|
||||
.map((videoUrl, idx) => {
|
||||
const isSelected = getWallpaperTileState(videoUrl);
|
||||
return renderWallpaperImageTile(
|
||||
videoUrl,
|
||||
isSelected,
|
||||
{
|
||||
key: `custom-video-${idx}`,
|
||||
ariaLabel:
|
||||
videoUrl.split(/[\\/]/).pop() ??
|
||||
"Video background",
|
||||
title: videoUrl.split(/[\\/]/).pop(),
|
||||
onClick: () => onWallpaperChange(videoUrl),
|
||||
children: (
|
||||
<Button
|
||||
variant="destructive-soft"
|
||||
onClick={(e) =>
|
||||
handleRemoveCustomImage(
|
||||
videoUrl,
|
||||
e,
|
||||
)
|
||||
}
|
||||
className="absolute top-0.5 right-0.5 w-3 h-3 flex items-center justify-center z-10"
|
||||
>
|
||||
<X className="w-2 h-2 text-white" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
);
|
||||
})}
|
||||
|
||||
{videoWallpaperTiles.map((wallpaper) => {
|
||||
const isSelected = getWallpaperTileState(
|
||||
wallpaper.value,
|
||||
wallpaper.previewUrl,
|
||||
);
|
||||
return renderWallpaperImageTile(
|
||||
wallpaper.previewUrl,
|
||||
isSelected,
|
||||
{
|
||||
key: wallpaper.key,
|
||||
ariaLabel: wallpaper.label,
|
||||
title: wallpaper.label,
|
||||
onClick: () =>
|
||||
onWallpaperChange(wallpaper.value),
|
||||
},
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<WallpaperGrid
|
||||
addLabel={
|
||||
backgroundTab === "image"
|
||||
? tSettings(
|
||||
"background.addWallpaper",
|
||||
"Add wallpaper",
|
||||
)
|
||||
: tSettings(
|
||||
"background.addVideoWallpaper",
|
||||
"Add video wallpaper",
|
||||
)
|
||||
}
|
||||
onAdd={
|
||||
backgroundTab === "image"
|
||||
? () => fileInputRef.current?.click()
|
||||
: handleVideoUpload
|
||||
}
|
||||
onSelect={onWallpaperChange}
|
||||
onRemove={handleRemoveCustomImage}
|
||||
isSelected={getWallpaperTileState}
|
||||
items={[
|
||||
...customImages
|
||||
.filter(
|
||||
(url) =>
|
||||
isVideoWallpaperSource(url) ===
|
||||
(backgroundTab === "video"),
|
||||
)
|
||||
.map((url, index) => ({
|
||||
key: `custom-${index}`,
|
||||
value: url,
|
||||
previewUrl: url,
|
||||
label: isVideoWallpaperSource(url)
|
||||
? (url.split(/[\\/]/).pop() ??
|
||||
"Custom video wallpaper")
|
||||
: `${tSettings("background.customWallpaper", "Custom wallpaper")} ${index + 1}`,
|
||||
removable: true,
|
||||
})),
|
||||
...(backgroundTab === "image"
|
||||
? imageWallpaperTiles
|
||||
: videoWallpaperTiles),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : backgroundTab === "color" ? (
|
||||
<div className="mt-0 space-y-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -2165,6 +2007,8 @@ export function SettingsPanel({
|
||||
<div className="flex items-center gap-3">
|
||||
<SectionLabel>{tSettings("captions.generation", "Generation")}</SectionLabel>
|
||||
<Button
|
||||
className="text-xs text-muted"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => onAutoCaptionSettingsChange?.(DEFAULT_AUTO_CAPTION_SETTINGS)}
|
||||
@@ -2188,7 +2032,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPickWhisperModel}
|
||||
className="h-10 w-full px-4 text-sm"
|
||||
className="h-9 w-full px-4 text-sm"
|
||||
>
|
||||
{tSettings("captions.selectModel", "Select Model")}
|
||||
</Button>
|
||||
@@ -2201,7 +2045,7 @@ export function SettingsPanel({
|
||||
value={autoCaptionSettings.language || "auto"}
|
||||
onValueChange={(value) => updateAutoCaptionSettings({ language: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[180px] text-sm">
|
||||
<SelectTrigger className="h-9 w-[180px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2216,7 +2060,7 @@ export function SettingsPanel({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="grid w-full grid-cols-2 gap-2">
|
||||
{whisperModelDownloadStatus === "downloading" ? (
|
||||
<Button type="button" disabled className="h-10 w-full px-4 text-sm">
|
||||
<Button type="button" disabled className="h-9 w-full px-4 text-sm">
|
||||
{tSettings("captions.downloading", "Downloading...")}{" "}
|
||||
{Math.round(whisperModelDownloadProgress)}%
|
||||
</Button>
|
||||
@@ -2225,7 +2069,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onDeleteWhisperSmallModel}
|
||||
className="h-10 w-full px-4 text-sm"
|
||||
className="h-9 w-full px-4 text-sm"
|
||||
>
|
||||
{tSettings("captions.deleteModel", "Delete Model")}
|
||||
</Button>
|
||||
@@ -2233,7 +2077,7 @@ export function SettingsPanel({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDownloadWhisperSmallModel}
|
||||
className="h-10 w-full px-4 text-sm"
|
||||
className="h-9 w-full px-4 text-sm"
|
||||
>
|
||||
{tSettings("captions.downloadModel", "Download Model")}
|
||||
</Button>
|
||||
@@ -2243,7 +2087,7 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
onClick={onClearAutoCaptions}
|
||||
disabled={captionCueCount === 0}
|
||||
className="h-10 w-full px-4 text-sm"
|
||||
className="h-9 w-full px-4 text-sm"
|
||||
>
|
||||
{tSettings("captions.clearFull", "Clear Captions")}
|
||||
</Button>
|
||||
@@ -2254,7 +2098,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
onClick={onGenerateAutoCaptions}
|
||||
disabled={isGeneratingCaptions || !whisperModelPath}
|
||||
className="h-10 w-full px-4 text-sm"
|
||||
className="h-9 w-full px-4 text-sm"
|
||||
>
|
||||
{isGeneratingCaptions
|
||||
? tSettings("captions.generating", "Generating...")
|
||||
@@ -2460,7 +2304,7 @@ export function SettingsPanel({
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{t("common.app.language", "Language")}</SectionLabel>
|
||||
<Select value={locale} onValueChange={(value) => setLocale(value as AppLocale)}>
|
||||
<SelectTrigger className="h-10 w-full text-sm">
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2564,7 +2408,7 @@ export function SettingsPanel({
|
||||
<SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel>
|
||||
<KeyboardShortcutsDialog
|
||||
triggerLabel={t("editor.keyboardShortcuts.customize")}
|
||||
triggerClassName="h-10 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
triggerClassName="h-9 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -2854,7 +2698,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
variant="destructive-soft"
|
||||
size="sm"
|
||||
className="mt-1 h-10 w-full gap-2 text-xs"
|
||||
className="mt-1 h-9 w-full gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{tSettings("zoom.deleteZoom")}
|
||||
@@ -2868,6 +2712,8 @@ export function SettingsPanel({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel>
|
||||
<Button
|
||||
className="text-xs text-muted"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -3304,6 +3150,8 @@ export function SettingsPanel({
|
||||
{tSettings("effects.webcamCrop", "Crop")}
|
||||
</div>
|
||||
<Button
|
||||
className="text-xs text-muted"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -3475,7 +3323,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onUploadWebcam}
|
||||
className="h-10 min-w-0 gap-2 px-3"
|
||||
className="h-9 min-w-0 gap-2 px-3"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
<span className="min-w-0 truncate">
|
||||
@@ -3489,7 +3337,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClearWebcam}
|
||||
className="h-10 min-w-0 gap-2 px-3"
|
||||
className="h-9 min-w-0 gap-2 px-3"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span className="min-w-0 truncate">
|
||||
@@ -3547,7 +3395,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
variant="destructive-soft"
|
||||
size="sm"
|
||||
className="h-10 w-full gap-2 text-xs"
|
||||
className="h-9 w-full gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{tSettings("clip.delete", "Delete Clip")}
|
||||
@@ -3560,7 +3408,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
variant="destructive-soft"
|
||||
size="sm"
|
||||
className="h-10 w-full gap-2 text-xs"
|
||||
className="h-9 w-full gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{tSettings("zoom.deleteZoom", "Delete Zoom")}
|
||||
@@ -3573,7 +3421,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
variant="destructive-soft"
|
||||
size="sm"
|
||||
className="h-10 w-full gap-2 text-xs"
|
||||
className="h-9 w-full gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{tSettings("audio.deleteRegion", "Delete Audio")}
|
||||
@@ -3587,7 +3435,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
variant="destructive-soft"
|
||||
size="sm"
|
||||
className="h-10 w-full gap-2 text-xs"
|
||||
className="h-9 w-full gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{tSettings("annotation.delete", "Delete Annotation")}
|
||||
|
||||
@@ -31,10 +31,12 @@ export const SliderControl = memo(function SliderControl({
|
||||
maxValue={max}
|
||||
step={step}
|
||||
onChange={(value) => onChange(Number(value))}
|
||||
className="my-1.5 w-full gap-y-2"
|
||||
className="my-1 w-full gap-y-2"
|
||||
>
|
||||
<Label>{label}</Label>
|
||||
<Slider.Output>{() => formatValue(value)}</Slider.Output>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Slider.Output className="text-xs font-normal">
|
||||
{() => formatValue(value)}
|
||||
</Slider.Output>
|
||||
<Slider.Track>
|
||||
<Slider.Fill />
|
||||
<Slider.Thumb />
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button, ToggleButton } from "@heroui/react";
|
||||
import { Plus, X } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRenderableVideoUrl } from "@/lib/assetPath";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WallpaperTile {
|
||||
key: string;
|
||||
value: string;
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
/** Shared image/video gallery. Removal stays separate from the selection button. */
|
||||
export function WallpaperGrid({
|
||||
items,
|
||||
addLabel,
|
||||
onAdd,
|
||||
onSelect,
|
||||
onRemove,
|
||||
isSelected,
|
||||
}: {
|
||||
items: WallpaperTile[];
|
||||
addLabel: string;
|
||||
onAdd: () => void;
|
||||
onSelect: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
isSelected: (value: string, previewUrl?: string) => boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
isIconOnly
|
||||
aria-label={addLabel}
|
||||
onPress={onAdd}
|
||||
className="aspect-[4/3] h-auto w-full min-w-0 rounded-md p-0 text-muted"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
{items.map((item) => {
|
||||
const selected = isSelected(item.value, item.previewUrl);
|
||||
return (
|
||||
<div key={item.key} className="group relative flex min-w-0">
|
||||
<ToggleButton
|
||||
isSelected={selected}
|
||||
aria-label={item.label}
|
||||
onChange={() => onSelect(item.value)}
|
||||
className={cn(
|
||||
"relative aspect-[4/3] h-auto w-full min-w-0 overflow-hidden rounded-md p-0",
|
||||
selected && "ring-2 ring-accent ring-offset-2 ring-offset-surface",
|
||||
)}
|
||||
>
|
||||
{isVideoWallpaperSource(item.previewUrl) ? (
|
||||
<WallpaperVideoPreview src={item.previewUrl} />
|
||||
) : (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full select-none object-cover"
|
||||
/>
|
||||
)}
|
||||
</ToggleButton>
|
||||
{item.removable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isIconOnly
|
||||
aria-label={`Remove ${item.label}`}
|
||||
onPress={() => onRemove(item.value)}
|
||||
className="absolute right-0.5 top-0.5 z-10 size-5 min-w-0 rounded-full p-0 bg-overlay text-foreground shadow-sm opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WallpaperVideoPreview({ src }: { src: string }) {
|
||||
const [resolvedSrc, setResolvedSrc] = useState(src);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setResolvedSrc(src);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const nextSrc = await getRenderableVideoUrl(src);
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(nextSrc);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(src);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<video
|
||||
src={resolvedSrc}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="absolute inset-0 h-full w-full select-none object-cover"
|
||||
draggable={false}
|
||||
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.pause();
|
||||
e.currentTarget.currentTime = 0;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function EditorHeader(props: Props) {
|
||||
|
||||
return (
|
||||
<header
|
||||
className="editor-header relative z-50 grid h-14 shrink-0 bg-surface grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
|
||||
className="editor-header [--text-sm:0.8125rem] relative z-50 grid h-14 shrink-0 border-b border-separator grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
|
||||
style={{ WebkitAppRegion: "drag" } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
@@ -116,7 +116,7 @@ export function EditorHeader(props: Props) {
|
||||
<DiscordLinkButton />
|
||||
<FeedbackDialog />
|
||||
</div>
|
||||
<div className="w-2 shrink-0" />
|
||||
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -187,7 +187,7 @@ export function EditorHeader(props: Props) {
|
||||
{hasUnsavedChanges ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
|
||||
) : null}
|
||||
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
|
||||
<span className="truncate text-[13px] font-medium tracking-tight text-foreground/90">
|
||||
{projectDisplayName}
|
||||
</span>
|
||||
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
|
||||
|
||||
@@ -103,10 +103,10 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
|
||||
<UserCircle />
|
||||
</Button>
|
||||
</nav>
|
||||
<aside className="flex w-[320px] min-h-0 flex-col">
|
||||
<aside className="editor-inspector [--text-sm:0.8125rem] [--text-base:0.8125rem] flex w-[320px] min-h-0 flex-col">
|
||||
<Card className="min-h-0 flex-1 gap-0 overflow-hidden p-0">
|
||||
<header className="flex min-h-14 shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||
<Card.Title>
|
||||
<Card.Title className="text-[14px]">
|
||||
{settingsPanelProps.selectedAnnotationId
|
||||
? t("timeline.annotation.label", "Annotation")
|
||||
: (sections.find((section) => section.id === activeSection)
|
||||
@@ -130,7 +130,7 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
|
||||
aria-label="Advanced settings"
|
||||
>
|
||||
<Switch.Content>
|
||||
<Label>Advanced</Label>
|
||||
<Label className="text-xs">Advanced</Label>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
|
||||
@@ -1,68 +1,179 @@
|
||||
/* Custom timeline geometry, using the same palette as the HeroUI controls. */
|
||||
.glassGreen,
|
||||
.glassRed,
|
||||
.glassYellow,
|
||||
.glassAmber,
|
||||
.glassPurple,
|
||||
.glassCyan,
|
||||
.glassDarkGreen,
|
||||
.glassCaption {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--region-accent) 14%, var(--surface));
|
||||
border: 1px solid color-mix(in oklab, var(--region-accent) 25%, var(--separator));
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
.glassGreen {
|
||||
--region-accent: var(--accent);
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #183065;
|
||||
border: 1px solid #1b3a7f;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.glassRed {
|
||||
--region-accent: var(--danger);
|
||||
|
||||
.glassGreen:hover {
|
||||
background: #1a387a;
|
||||
border-color: #1e469f;
|
||||
}
|
||||
.glassYellow {
|
||||
--region-accent: var(--warning);
|
||||
}
|
||||
.glassAmber {
|
||||
--region-accent: var(--warning);
|
||||
}
|
||||
.glassPurple {
|
||||
--region-accent: var(--color-purple-500);
|
||||
}
|
||||
.glassCyan {
|
||||
--region-accent: var(--accent);
|
||||
}
|
||||
.glassDarkGreen {
|
||||
--region-accent: var(--success);
|
||||
}
|
||||
.glassCaption {
|
||||
--region-accent: var(--color-pink-500);
|
||||
}
|
||||
.glassGreen:hover,
|
||||
.glassRed:hover,
|
||||
.glassYellow:hover,
|
||||
.glassAmber:hover,
|
||||
.glassPurple:hover,
|
||||
.glassCyan:hover,
|
||||
.glassDarkGreen:hover,
|
||||
.glassCaption:hover {
|
||||
background: color-mix(in oklab, var(--region-accent) 20%, var(--surface));
|
||||
border-color: var(--region-accent);
|
||||
}
|
||||
.glassGreen.selected,
|
||||
.glassRed.selected,
|
||||
.glassYellow.selected,
|
||||
.glassAmber.selected,
|
||||
.glassPurple.selected,
|
||||
.glassCyan.selected,
|
||||
.glassDarkGreen.selected,
|
||||
.glassCaption.selected {
|
||||
background: color-mix(in oklab, var(--region-accent) 22%, var(--surface));
|
||||
border-color: var(--region-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--region-accent);
|
||||
|
||||
.glassGreen.selected {
|
||||
background: #1c3e89;
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassRed {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #652425;
|
||||
border: 1px solid #802a2b;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassRed:hover {
|
||||
background: #7b292a;
|
||||
border-color: #a13232;
|
||||
}
|
||||
|
||||
.glassRed.selected {
|
||||
background: #8b2d2d;
|
||||
border-color: #ef4444;
|
||||
box-shadow: inset 0 0 0 1.5px #ef4444;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassYellow {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #4e4726;
|
||||
border: 1px solid #62582c;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassYellow:hover {
|
||||
background: #5f552b;
|
||||
border-color: #7a6d34;
|
||||
}
|
||||
|
||||
.glassYellow.selected {
|
||||
background: #6a5f2f;
|
||||
border-color: #b4a046;
|
||||
box-shadow: inset 0 0 0 1.5px #b4a046;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #67460f;
|
||||
border: 1px solid #83570f;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassAmber:hover {
|
||||
background: #7e540f;
|
||||
border-color: #a56c0d;
|
||||
}
|
||||
|
||||
.glassAmber.selected {
|
||||
background: #8e5e0e;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: inset 0 0 0 1.5px #f59e0b;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassPurple {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #4a2a69;
|
||||
border: 1px solid #5c3385;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassPurple:hover {
|
||||
background: #593180;
|
||||
border-color: #733da7;
|
||||
}
|
||||
|
||||
.glassPurple.selected {
|
||||
background: #643690;
|
||||
border-color: #a855f7;
|
||||
box-shadow: inset 0 0 0 1.5px #a855f7;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassCyan {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #183065;
|
||||
border: 1px solid #1b3a7f;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassCyan:hover {
|
||||
background: #1a387a;
|
||||
border-color: #1e469f;
|
||||
}
|
||||
|
||||
.glassCyan.selected {
|
||||
background: #1c3e89;
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassDarkGreen {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #164f2d;
|
||||
border: 1px solid #196b38;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassDarkGreen:hover {
|
||||
background: #186234;
|
||||
border-color: #1c8643;
|
||||
}
|
||||
|
||||
.glassDarkGreen.selected {
|
||||
background: #196e3a;
|
||||
border-color: #22c55e;
|
||||
box-shadow: inset 0 0 0 1.5px #22c55e;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassCaption {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: #5b2746;
|
||||
border: 1px solid #7a3157;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glassCaption:hover {
|
||||
background: #6d2f54;
|
||||
border-color: #933a68;
|
||||
}
|
||||
|
||||
.glassCaption.selected {
|
||||
background: #7a3157;
|
||||
border-color: #f472b6;
|
||||
box-shadow: inset 0 0 0 1.5px #f472b6;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Pill resize handle — visible on hover */
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
@@ -78,12 +189,136 @@
|
||||
justify-content: center;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 2px;
|
||||
background: color-mix(in oklab, var(--foreground) 70%, transparent);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
/* --- Light-mode overrides --- */
|
||||
:global(:root:not(.dark)) .glassGreen {
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassGreen:hover {
|
||||
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassGreen.selected {
|
||||
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassRed {
|
||||
background: linear-gradient(180deg, #fee2e2 0%, #fca5a5 100%);
|
||||
border-color: #f87171;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassRed:hover {
|
||||
background: linear-gradient(180deg, #fecaca 0%, #f87171 100%);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassRed.selected {
|
||||
background: linear-gradient(180deg, #fca5a5 0%, #ef4444 100%);
|
||||
border-color: #ef4444;
|
||||
box-shadow: inset 0 0 0 1.5px #ef4444;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassYellow {
|
||||
background: linear-gradient(180deg, #fef9c3 0%, #fde047 100%);
|
||||
border-color: #facc15;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassYellow:hover {
|
||||
background: linear-gradient(180deg, #fef08a 0%, #facc15 100%);
|
||||
border-color: #eab308;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassYellow.selected {
|
||||
background: linear-gradient(180deg, #fde047 0%, #eab308 100%);
|
||||
border-color: #b4a046;
|
||||
box-shadow: inset 0 0 0 1.5px #b4a046;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassAmber {
|
||||
background: linear-gradient(180deg, #fef3c7 0%, #fcd34d 100%);
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassAmber:hover {
|
||||
background: linear-gradient(180deg, #fde68a 0%, #fbbf24 100%);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassAmber.selected {
|
||||
background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: inset 0 0 0 1.5px #f59e0b;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassPurple {
|
||||
background: linear-gradient(180deg, #f3e8ff 0%, #d8b4fe 100%);
|
||||
border-color: #c084fc;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassPurple:hover {
|
||||
background: linear-gradient(180deg, #e9d5ff 0%, #c084fc 100%);
|
||||
border-color: #a855f7;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassPurple.selected {
|
||||
background: linear-gradient(180deg, #d8b4fe 0%, #a855f7 100%);
|
||||
border-color: #a855f7;
|
||||
box-shadow: inset 0 0 0 1.5px #a855f7;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassCyan {
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCyan:hover {
|
||||
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCyan.selected {
|
||||
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassDarkGreen {
|
||||
background: linear-gradient(180deg, #dcfce7 0%, #86efac 100%);
|
||||
border-color: #4ade80;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassDarkGreen:hover {
|
||||
background: linear-gradient(180deg, #bbf7d0 0%, #4ade80 100%);
|
||||
border-color: #22c55e;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassDarkGreen.selected {
|
||||
background: linear-gradient(180deg, #86efac 0%, #22c55e 100%);
|
||||
border-color: #22c55e;
|
||||
box-shadow: inset 0 0 0 1.5px #22c55e;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassCaption {
|
||||
background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%);
|
||||
border-color: #f472b6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCaption:hover {
|
||||
background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%);
|
||||
border-color: #ec4899;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCaption.selected {
|
||||
background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%);
|
||||
border-color: #ec4899;
|
||||
box-shadow: inset 0 0 0 1.5px #ec4899;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .zoomEndCap {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
:global(:root:not(.dark)) .zoomEndCap:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.zoomEndCap:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.glassGreen:hover .zoomEndCap,
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
|
||||
Reference in New Issue
Block a user