chore: formatting

This commit is contained in:
Maze Winther 2026-04-15 06:26:55 +02:00
parent eb28942dab
commit 8161de00e6
7 changed files with 357 additions and 342 deletions

View File

@ -254,9 +254,7 @@ export function Captions() {
<HugeiconsIcon icon={AlertCircleIcon} size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>
{diagnostic.message}
</TooltipContent>
<TooltipContent>{diagnostic.message}</TooltipContent>
</Tooltip>
))}
<Button

View File

@ -49,7 +49,9 @@ const BlurPreview = memo(
};
renderPreview();
return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
return effectPreviewService.onPreviewImageReady({
callback: renderPreview,
});
}, [blur.value]);
return (
@ -186,7 +188,12 @@ export function BackgroundContent() {
return (
<div className="flex flex-col">
<Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
<Section
collapsible
defaultOpen={true}
sectionKey="background-blur"
showTopBorder={false}
>
<SectionHeader>
<SectionTitle>Blur</SectionTitle>
</SectionHeader>

View File

@ -1,309 +1,309 @@
"use client";
import { useState } from "react";
import type { ParamValues } from "@/lib/params";
import type { Effect } from "@/lib/effects/types";
import type { EffectElement, VisualElement } from "@/lib/timeline";
import { effectsRegistry } from "@/lib/effects";
import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
SectionFields,
} from "@/components/section";
import { PropertyParamField } from "../components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { Separator } from "@/components/ui/separator";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function StandaloneEffectTab({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effect: Effect = {
id: element.id,
type: element.effectType,
params: element.params,
enabled: true,
};
const previewParam = (key: string) => (value: number | string | boolean) => {
previewUpdates({
params: { ...(renderElement as EffectElement).params, [key]: value },
});
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effect</SectionTitle>
</div>
<EffectSection
effect={effect}
renderParams={(renderElement as EffectElement).params}
previewParam={previewParam}
onCommit={commit}
/>
</div>
);
}
export function ClipEffectsTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editor = useEditor();
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effects: Effect[] = element.effects ?? [];
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
return (
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
?.params ??
effects.find((ef) => ef.id === effectId)?.params ??
{}
);
};
const buildPreviewParam =
(effectId: string) =>
(key: string) =>
(value: number | string | boolean) => {
const updatedEffects = (
(renderElement as VisualElement).effects ?? []
).map((existing) =>
existing.id !== effectId
? existing
: { ...existing, params: { ...existing.params, [key]: value } },
);
previewUpdates({ effects: updatedEffects });
};
const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
const handleDragOver = ({
event,
index,
}: {
event: React.DragEvent;
index: number;
}) => {
event.preventDefault();
if (index !== dropIndex) setDropIndex(index);
};
const handleDrop = ({ toIndex }: { toIndex: number }) => {
if (dragIndex !== null && dragIndex !== toIndex) {
editor.timeline.reorderClipEffects({
trackId,
elementId: element.id,
fromIndex: dragIndex,
toIndex,
});
}
setDragIndex(null);
setDropIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDropIndex(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effects</SectionTitle>
</div>
{effects.length === 0 ? (
<EmptyView />
) : (
<ul className="flex flex-col">
{effects.map((effect, index) => {
const resolvedDragIndex = dragIndex ?? -1;
const isDragging = dragIndex === index;
const isDropTarget =
dropIndex === index && dragIndex !== null && dragIndex !== index;
const showTopDropIndicator =
isDropTarget && index < resolvedDragIndex;
const showBottomDropIndicator =
isDropTarget && index > resolvedDragIndex;
return (
<li
key={effect.id}
draggable
onDragStart={() => handleDragStart({ index })}
onDragOver={(event) => handleDragOver({ event, index })}
onDrop={() => handleDrop({ toIndex: index })}
onDragEnd={handleDragEnd}
className={cn(
"group list-none",
isDragging && "opacity-40",
showTopDropIndicator && "border-t-2 border-primary",
showBottomDropIndicator && "border-b-2 border-primary",
)}
>
<EffectSection
effect={effect}
renderParams={getRenderParams({ effectId: effect.id })}
previewParam={buildPreviewParam(effect.id)}
onCommit={commit}
onToggle={() =>
editor.timeline.toggleClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
onRemove={() =>
editor.timeline.removeClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
/>
</li>
);
})}
</ul>
)}
</div>
);
}
function EmptyView() {
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
return (
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-10 text-muted-foreground"
strokeWidth={1}
/>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-foreground">No effects</h3>
<p className="text-muted-foreground text-sm text-balance max-w-44">
Add effects to this layer from the Assets panel.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("effects")}
>
Open effects
</Button>
</div>
);
}
function EffectSection({
effect,
renderParams,
previewParam,
onCommit,
onToggle,
onRemove,
}: {
effect: Effect;
renderParams: ParamValues;
previewParam: (key: string) => (value: number | string | boolean) => void;
onCommit: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
const definition = effectsRegistry.get(effect.type);
return (
<Section
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
showTopBorder={false}
>
<SectionHeader
className={cn(onToggle && "cursor-move")}
trailing={
onToggle && (
<div className="flex items-center gap-1">
<Button
variant={effect.enabled ? "secondary" : "ghost"}
size="icon"
aria-label={`Toggle ${definition.name}`}
onClick={onToggle}
>
<HugeiconsIcon
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${definition.name}`}
onClick={onRemove}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
</div>
)
}
>
<SectionTitle
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
>
{definition.name}
</SectionTitle>
</SectionHeader>
<SectionContent
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
>
<SectionFields>
{definition.params.map((param) => (
<div key={param.key} className="flex flex-col gap-3.5">
<div className="px-4">
<PropertyParamField
param={param}
value={renderParams[param.key] ?? param.default}
onPreview={previewParam(param.key)}
onCommit={onCommit}
/>
</div>
<Separator />
</div>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
"use client";
import { useState } from "react";
import type { ParamValues } from "@/lib/params";
import type { Effect } from "@/lib/effects/types";
import type { EffectElement, VisualElement } from "@/lib/timeline";
import { effectsRegistry } from "@/lib/effects";
import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
SectionFields,
} from "@/components/section";
import { PropertyParamField } from "../components/property-param-field";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Delete02Icon,
ViewIcon,
ViewOffSlashIcon,
MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { Separator } from "@/components/ui/separator";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function StandaloneEffectTab({
element,
trackId,
}: {
element: EffectElement;
trackId: string;
}) {
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effect: Effect = {
id: element.id,
type: element.effectType,
params: element.params,
enabled: true,
};
const previewParam = (key: string) => (value: number | string | boolean) => {
previewUpdates({
params: { ...(renderElement as EffectElement).params, [key]: value },
});
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effect</SectionTitle>
</div>
<EffectSection
effect={effect}
renderParams={(renderElement as EffectElement).params}
previewParam={previewParam}
onCommit={commit}
/>
</div>
);
}
export function ClipEffectsTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editor = useEditor();
const { renderElement, previewUpdates, commit } = useElementPreview({
trackId,
elementId: element.id,
fallback: element,
});
const effects: Effect[] = element.effects ?? [];
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
return (
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
?.params ??
effects.find((ef) => ef.id === effectId)?.params ??
{}
);
};
const buildPreviewParam =
(effectId: string) =>
(key: string) =>
(value: number | string | boolean) => {
const updatedEffects = (
(renderElement as VisualElement).effects ?? []
).map((existing) =>
existing.id !== effectId
? existing
: { ...existing, params: { ...existing.params, [key]: value } },
);
previewUpdates({ effects: updatedEffects });
};
const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
const handleDragOver = ({
event,
index,
}: {
event: React.DragEvent;
index: number;
}) => {
event.preventDefault();
if (index !== dropIndex) setDropIndex(index);
};
const handleDrop = ({ toIndex }: { toIndex: number }) => {
if (dragIndex !== null && dragIndex !== toIndex) {
editor.timeline.reorderClipEffects({
trackId,
elementId: element.id,
fromIndex: dragIndex,
toIndex,
});
}
setDragIndex(null);
setDropIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDropIndex(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
<SectionTitle>Effects</SectionTitle>
</div>
{effects.length === 0 ? (
<EmptyView />
) : (
<ul className="flex flex-col">
{effects.map((effect, index) => {
const resolvedDragIndex = dragIndex ?? -1;
const isDragging = dragIndex === index;
const isDropTarget =
dropIndex === index && dragIndex !== null && dragIndex !== index;
const showTopDropIndicator =
isDropTarget && index < resolvedDragIndex;
const showBottomDropIndicator =
isDropTarget && index > resolvedDragIndex;
return (
<li
key={effect.id}
draggable
onDragStart={() => handleDragStart({ index })}
onDragOver={(event) => handleDragOver({ event, index })}
onDrop={() => handleDrop({ toIndex: index })}
onDragEnd={handleDragEnd}
className={cn(
"group list-none",
isDragging && "opacity-40",
showTopDropIndicator && "border-t-2 border-primary",
showBottomDropIndicator && "border-b-2 border-primary",
)}
>
<EffectSection
effect={effect}
renderParams={getRenderParams({ effectId: effect.id })}
previewParam={buildPreviewParam(effect.id)}
onCommit={commit}
onToggle={() =>
editor.timeline.toggleClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
onRemove={() =>
editor.timeline.removeClipEffect({
trackId,
elementId: element.id,
effectId: effect.id,
})
}
/>
</li>
);
})}
</ul>
)}
</div>
);
}
function EmptyView() {
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
return (
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-10 text-muted-foreground"
strokeWidth={1}
/>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-foreground">No effects</h3>
<p className="text-muted-foreground text-sm text-balance max-w-44">
Add effects to this layer from the Assets panel.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("effects")}
>
Open effects
</Button>
</div>
);
}
function EffectSection({
effect,
renderParams,
previewParam,
onCommit,
onToggle,
onRemove,
}: {
effect: Effect;
renderParams: ParamValues;
previewParam: (key: string) => (value: number | string | boolean) => void;
onCommit: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
const definition = effectsRegistry.get(effect.type);
return (
<Section
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
showTopBorder={false}
>
<SectionHeader
className={cn(onToggle && "cursor-move")}
trailing={
onToggle && (
<div className="flex items-center gap-1">
<Button
variant={effect.enabled ? "secondary" : "ghost"}
size="icon"
aria-label={`Toggle ${definition.name}`}
onClick={onToggle}
>
<HugeiconsIcon
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
/>
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${definition.name}`}
onClick={onRemove}
>
<HugeiconsIcon icon={Delete02Icon} />
</Button>
</div>
)
}
>
<SectionTitle
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
>
{definition.name}
</SectionTitle>
</SectionHeader>
<SectionContent
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
>
<SectionFields>
{definition.params.map((param) => (
<div key={param.key} className="flex flex-col gap-3.5">
<div className="px-4">
<PropertyParamField
param={param}
value={renderParams[param.key] ?? param.default}
onPreview={previewParam(param.key)}
onCommit={onCommit}
/>
</div>
<Separator />
</div>
))}
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -47,7 +47,12 @@ const EMPTY_AUTHORS: MarbleAuthorList = {
};
function isMarbleConfigured() {
return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key);
return ![
"",
"placeholder",
"build-placeholder",
"your_workspace_key_here",
].includes(key);
}
async function fetchFromMarble<T>({

View File

@ -166,18 +166,17 @@ function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
</Form>
{entries.length > 0 && (
<div className="border-t">
<div className="px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
Previous feedback
</span>
</div>
<div className="max-h-48 overflow-y-auto px-3 pb-3">
<div className="flex flex-col gap-2">
{entries.map((entry) => (
<FeedbackEntryItem key={entry.id} entry={entry} />
))}
</div>
<div className="border-t overflow-hidden">
<div
className="max-h-52 overflow-y-auto divide-y"
style={{
maskImage:
"linear-gradient(to bottom, black 70%, transparent 100%)",
}}
>
{entries.map((entry) => (
<FeedbackEntryItem key={entry.id} entry={entry} />
))}
</div>
</div>
)}
@ -185,17 +184,29 @@ function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
);
}
function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
const formatted = new Date(entry.createdAt).toLocaleDateString(undefined, {
function relativeDate(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
return (
<div className="rounded-md border px-2.5 py-2">
<p className="text-sm whitespace-pre-wrap break-words">{entry.message}</p>
<span className="mt-1 block text-xs text-muted-foreground">
{formatted}
<div className="px-3 py-2.5">
<p className="text-sm text-muted-foreground leading-snug whitespace-pre-wrap break-words">
{entry.message}
</p>
<span className="mt-1 block text-[11px] text-muted-foreground/50">
{relativeDate(entry.createdAt)}
</span>
</div>
);

View File

@ -1,7 +1,5 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/sizes";
const DEFAULT_FPS = 30;
@ -135,9 +133,7 @@ describe("V1 to V2 Migration", () => {
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.blurIntensity).toBe(
DEFAULT_BACKGROUND_BLUR_INTENSITY,
);
expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY);
});
test("preserves currentSceneId", async () => {

View File

@ -1,6 +1,4 @@
import {
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/sizes";
const DEFAULT_FPS = 30;