chore: formatting
This commit is contained in:
parent
eb28942dab
commit
8161de00e6
|
|
@ -254,9 +254,7 @@ export function Captions() {
|
|||
<HugeiconsIcon icon={AlertCircleIcon} size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{diagnostic.message}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{diagnostic.message}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue