mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Organize settings with HeroUI section tags and default new zooms to 1.8x
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 43 KiB |
@@ -1,3 +1,4 @@
|
||||
import { SettingsSections, SettingsCategory } from "./SettingsSections";
|
||||
import { Card, RadioGroup, Radio, Label, Description } from "@heroui/react";
|
||||
import { ProgressBar } from "@heroui/react";
|
||||
import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
|
||||
@@ -2236,301 +2237,329 @@ export function SettingsPanel({
|
||||
|
||||
const effectSectionContent = (() => {
|
||||
const settingsSectionContent = (
|
||||
<div className="space-y-5">
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{t("editor.theme.appearance", "Appearance")}</SectionLabel>
|
||||
<ChoiceGroup
|
||||
type="single"
|
||||
aria-label={t("editor.theme.appearance", "Appearance")}
|
||||
value={themePreference}
|
||||
onValueChange={(value) => {
|
||||
if (value === "light" || value === "dark" || value === "system")
|
||||
setThemePreference(value);
|
||||
}}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
<ChoiceItem value="light" className="flex-1">
|
||||
{t("editor.theme.light", "Light")}
|
||||
</ChoiceItem>
|
||||
<ChoiceItem value="dark" className="flex-1">
|
||||
{t("editor.theme.dark", "Dark")}
|
||||
</ChoiceItem>
|
||||
<ChoiceItem value="system" className="flex-1">
|
||||
{t("editor.theme.system", "System")}
|
||||
</ChoiceItem>
|
||||
</ChoiceGroup>
|
||||
</section>
|
||||
|
||||
<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-9 w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LOCALES.map((candidateLocale) => (
|
||||
<SelectItem key={candidateLocale} value={candidateLocale}>
|
||||
{APP_LANGUAGE_LABELS[candidateLocale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</section>
|
||||
|
||||
{advanced && (
|
||||
<SettingsSections
|
||||
categories={advanced ? ["general", "motion", "advanced"] : ["general", "motion"]}
|
||||
>
|
||||
<SettingsCategory category="general">
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{tSettings("updates.title", "Updates")}</SectionLabel>
|
||||
<SectionLabel>{t("editor.theme.appearance", "Appearance")}</SectionLabel>
|
||||
<ChoiceGroup
|
||||
type="single"
|
||||
aria-label={t("editor.theme.appearance", "Appearance")}
|
||||
value={themePreference}
|
||||
onValueChange={(value) => {
|
||||
if (value === "light" || value === "dark" || value === "system")
|
||||
setThemePreference(value);
|
||||
}}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
<ChoiceItem value="light" className="flex-1">
|
||||
{t("editor.theme.light", "Light")}
|
||||
</ChoiceItem>
|
||||
<ChoiceItem value="dark" className="flex-1">
|
||||
{t("editor.theme.dark", "Dark")}
|
||||
</ChoiceItem>
|
||||
<ChoiceItem value="system" className="flex-1">
|
||||
{t("editor.theme.system", "System")}
|
||||
</ChoiceItem>
|
||||
</ChoiceGroup>
|
||||
</section>
|
||||
|
||||
<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-9 w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LOCALES.map((candidateLocale) => (
|
||||
<SelectItem key={candidateLocale} value={candidateLocale}>
|
||||
{APP_LANGUAGE_LABELS[candidateLocale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</section>
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="advanced">
|
||||
{advanced && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{tSettings("updates.title", "Updates")}</SectionLabel>
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings("updates.experimental", "Experimental updates")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"updates.experimentalDescription",
|
||||
"This is the front line of user testing - highly experimental so expect bugs",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={experimentalUpdatesEnabled}
|
||||
disabled={savingExperimentalUpdates}
|
||||
onCheckedChange={(enabled) =>
|
||||
void updateExperimentalUpdatesPreference(enabled)
|
||||
}
|
||||
aria-label={tSettings(
|
||||
"updates.experimental",
|
||||
"Experimental updates",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="motion">
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings("updates.experimental", "Experimental updates")}
|
||||
{tSettings(
|
||||
"effects.autoApplyFreshRecordingZooms",
|
||||
"Auto-apply fresh recording zooms",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"updates.experimentalDescription",
|
||||
"This is the front line of user testing - highly experimental so expect bugs",
|
||||
"effects.autoApplyFreshRecordingZoomsDescription",
|
||||
"Suggest cursor-follow zooms automatically when you open a new recording.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={experimentalUpdatesEnabled}
|
||||
disabled={savingExperimentalUpdates}
|
||||
onCheckedChange={(enabled) =>
|
||||
void updateExperimentalUpdatesPreference(enabled)
|
||||
}
|
||||
aria-label={tSettings(
|
||||
"updates.experimental",
|
||||
"Experimental updates",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings(
|
||||
"effects.autoApplyFreshRecordingZooms",
|
||||
"Auto-apply fresh recording zooms",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"effects.autoApplyFreshRecordingZoomsDescription",
|
||||
"Suggest cursor-follow zooms automatically when you open a new recording.",
|
||||
)}
|
||||
</div>
|
||||
checked={autoApplyFreshRecordingAutoZooms}
|
||||
onCheckedChange={onAutoApplyFreshRecordingAutoZoomsChange}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={tSettings(
|
||||
"effects.autoApplyFreshRecordingZooms",
|
||||
"Auto-apply fresh recording zooms",
|
||||
)}
|
||||
checked={autoApplyFreshRecordingAutoZooms}
|
||||
onCheckedChange={onAutoApplyFreshRecordingAutoZoomsChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings("effects.connectZooms", "Connect neighboring zooms")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"effects.connectZoomsDescription",
|
||||
"Smooth consecutive zoom regions into a continuous camera move.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={tSettings(
|
||||
"effects.connectZooms",
|
||||
"Connect neighboring zooms",
|
||||
)}
|
||||
checked={connectZooms}
|
||||
onCheckedChange={onConnectZoomsChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<MotionPresetCards
|
||||
title={tSettings("effects.motionPresetsTitle", "Motion Presets")}
|
||||
activePresetId={activeMotionPresetId}
|
||||
onApply={applyMotionPreset}
|
||||
tSettings={tSettings}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel>
|
||||
<KeyboardShortcutsDialog
|
||||
triggerLabel={t("editor.keyboardShortcuts.customize")}
|
||||
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>
|
||||
|
||||
{advanced && showDevMotionControls ? (
|
||||
<section className="flex flex-col gap-4 rounded-xl border border-[#2563EB]/15 bg-[#2563EB]/5 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{tSettings("effects.devSection", "Dev")}
|
||||
</SectionLabel>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{tSettings(
|
||||
"effects.devSectionHint",
|
||||
"Temporary testing controls for native capture and motion tuning.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-[#2563EB]">
|
||||
DEV
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings(
|
||||
"effects.nativeCaptureWarningTester",
|
||||
"Native capture warning",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{nativeCaptureUnavailableSession
|
||||
? tSettings(
|
||||
"effects.nativeCaptureWarningTesterUnavailable",
|
||||
"This project is currently marked as native capture unavailable.",
|
||||
)
|
||||
: tSettings(
|
||||
"effects.nativeCaptureWarningTesterAvailable",
|
||||
"This project is not marked as unsupported, but you can still open the modal for UI testing.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenNativeCaptureUnavailableModal?.()}
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
{tSettings("effects.openNativeCaptureWarning", "Open warning")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")}
|
||||
{tSettings("effects.connectZooms", "Connect neighboring zooms")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"effects.cameraDebugTuningHint",
|
||||
"Development-only spring tuning controls for camera motion.",
|
||||
"effects.connectZoomsDescription",
|
||||
"Smooth consecutive zoom regions into a continuous camera move.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringStiffnessMultiplier",
|
||||
"Camera stiffness",
|
||||
<Switch
|
||||
aria-label={tSettings(
|
||||
"effects.connectZooms",
|
||||
"Connect neighboring zooms",
|
||||
)}
|
||||
value={cameraSpringStiffnessMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCameraSpringStiffnessMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringDampingMultiplier",
|
||||
"Camera damping",
|
||||
)}
|
||||
value={cameraSpringDampingMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) => onCameraSpringDampingMultiplierChange?.(value)}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringMassMultiplier",
|
||||
"Camera mass",
|
||||
)}
|
||||
value={cameraSpringMassMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) => onCameraSpringMassMultiplierChange?.(value)}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{tSettings(
|
||||
"effects.cursorDebugTuningHint",
|
||||
"Development-only spring tuning controls.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringStiffnessMultiplier",
|
||||
"Spring stiffness",
|
||||
)}
|
||||
value={cursorSpringStiffnessMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCursorSpringStiffnessMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringDampingMultiplier",
|
||||
"Spring damping",
|
||||
)}
|
||||
value={cursorSpringDampingMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) => onCursorSpringDampingMultiplierChange?.(value)}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringMassMultiplier",
|
||||
"Spring mass",
|
||||
)}
|
||||
value={cursorSpringMassMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) => onCursorSpringMassMultiplierChange?.(value)}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
checked={connectZooms}
|
||||
onCheckedChange={onConnectZoomsChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<MotionPresetCards
|
||||
title={tSettings("effects.motionPresetsTitle", "Motion Presets")}
|
||||
activePresetId={activeMotionPresetId}
|
||||
onApply={applyMotionPreset}
|
||||
tSettings={tSettings}
|
||||
/>
|
||||
</section>
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="general">
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel>
|
||||
<KeyboardShortcutsDialog
|
||||
triggerLabel={t("editor.keyboardShortcuts.customize")}
|
||||
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>
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="advanced">
|
||||
{advanced && showDevMotionControls ? (
|
||||
<section className="flex flex-col gap-4 rounded-xl border border-[#2563EB]/15 bg-[#2563EB]/5 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{tSettings("effects.devSection", "Dev")}
|
||||
</SectionLabel>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{tSettings(
|
||||
"effects.devSectionHint",
|
||||
"Temporary testing controls for native capture and motion tuning.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-[#2563EB]">
|
||||
DEV
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings(
|
||||
"effects.nativeCaptureWarningTester",
|
||||
"Native capture warning",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{nativeCaptureUnavailableSession
|
||||
? tSettings(
|
||||
"effects.nativeCaptureWarningTesterUnavailable",
|
||||
"This project is currently marked as native capture unavailable.",
|
||||
)
|
||||
: tSettings(
|
||||
"effects.nativeCaptureWarningTesterAvailable",
|
||||
"This project is not marked as unsupported, but you can still open the modal for UI testing.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenNativeCaptureUnavailableModal?.()}
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
{tSettings(
|
||||
"effects.openNativeCaptureWarning",
|
||||
"Open warning",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings(
|
||||
"effects.cameraDebugTuning",
|
||||
"Camera Debug Tuning",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{tSettings(
|
||||
"effects.cameraDebugTuningHint",
|
||||
"Development-only spring tuning controls for camera motion.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringStiffnessMultiplier",
|
||||
"Camera stiffness",
|
||||
)}
|
||||
value={cameraSpringStiffnessMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCameraSpringStiffnessMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringDampingMultiplier",
|
||||
"Camera damping",
|
||||
)}
|
||||
value={cameraSpringDampingMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCameraSpringDampingMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cameraSpringMassMultiplier",
|
||||
"Camera mass",
|
||||
)}
|
||||
value={cameraSpringMassMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCameraSpringMassMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{tSettings(
|
||||
"effects.cursorDebugTuning",
|
||||
"Cursor Debug Tuning",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{tSettings(
|
||||
"effects.cursorDebugTuningHint",
|
||||
"Development-only spring tuning controls.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringStiffnessMultiplier",
|
||||
"Spring stiffness",
|
||||
)}
|
||||
value={cursorSpringStiffnessMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCursorSpringStiffnessMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringDampingMultiplier",
|
||||
"Spring damping",
|
||||
)}
|
||||
value={cursorSpringDampingMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCursorSpringDampingMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorSpringMassMultiplier",
|
||||
"Spring mass",
|
||||
)}
|
||||
value={cursorSpringMassMultiplier}
|
||||
min={0.25}
|
||||
max={3}
|
||||
step={0.01}
|
||||
onChange={(value) =>
|
||||
onCursorSpringMassMultiplierChange?.(value)
|
||||
}
|
||||
formatValue={(value) => `${value.toFixed(2)}×`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</SettingsCategory>
|
||||
</SettingsSections>
|
||||
);
|
||||
|
||||
const sceneSectionContent = (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { ChoiceGroup, ChoiceItem } from "@/components/ui/choice-group";
|
||||
|
||||
type Category = "general" | "motion" | "recording" | "files" | "advanced";
|
||||
const labels: Record<Category, string> = {
|
||||
general: "General",
|
||||
motion: "Motion",
|
||||
recording: "Recording",
|
||||
files: "Files",
|
||||
advanced: "Advanced",
|
||||
};
|
||||
const SettingsCategoryContext = createContext<Category | null>(null);
|
||||
|
||||
/** Dashboard and editor share the same controls and category selection. */
|
||||
export function SettingsSections({
|
||||
children,
|
||||
categories,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
categories: Category[];
|
||||
}) {
|
||||
const parent = useContext(SettingsCategoryContext);
|
||||
const [selected, setSelected] = useState<Category>("general");
|
||||
if (parent) return <div className="space-y-6">{children}</div>;
|
||||
const active = categories.includes(selected) ? selected : categories[0];
|
||||
return (
|
||||
<SettingsCategoryContext.Provider value={active}>
|
||||
<div className="space-y-6">
|
||||
<ChoiceGroup
|
||||
aria-label="Settings sections"
|
||||
value={active}
|
||||
onValueChange={(value) => setSelected(value as Category)}
|
||||
size="sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<ChoiceItem key={category} value={category}>
|
||||
{labels[category]}
|
||||
</ChoiceItem>
|
||||
))}
|
||||
</ChoiceGroup>
|
||||
<div role="region" aria-label={`${labels[active]} settings`} className="space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCategoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsCategory({
|
||||
category,
|
||||
children,
|
||||
}: {
|
||||
category: Category | Category[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const active = useContext(SettingsCategoryContext);
|
||||
const visible = Array.isArray(category)
|
||||
? active !== null && category.includes(active)
|
||||
: active === category;
|
||||
return visible ? <div className="space-y-6">{children}</div> : null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SettingsSections, SettingsCategory } from "../SettingsSections";
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { supportsHudCaptureProtection } from "@/lib/hudCaptureProtection";
|
||||
@@ -42,119 +43,131 @@ export function DashboardSettings({ onImportFile }: { onImportFile: () => Promis
|
||||
return (
|
||||
<section aria-label="Dashboard settings" className="dashboard-settings max-w-2xl py-10">
|
||||
<h1 className="mb-8 text-lg font-semibold">Settings</h1>
|
||||
<div className="space-y-8">
|
||||
{settingsContent}
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<p className="text-sm">Open video or project</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void run(onImportFile)}
|
||||
>
|
||||
Open file
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">Recordings folder</p>
|
||||
<p
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
title={recordings}
|
||||
>
|
||||
{recordings}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const result = await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) return;
|
||||
if (!result.success || !result.path)
|
||||
throw Error("Could not change recordings folder");
|
||||
setRecordings(result.path);
|
||||
})
|
||||
}
|
||||
>
|
||||
Change folder
|
||||
</Button>
|
||||
</div>
|
||||
{captureSupported && (
|
||||
<SettingsSections categories={["general", "motion", "recording", "files", "advanced"]}>
|
||||
<SettingsCategory category={["general", "motion", "advanced"]}>
|
||||
{settingsContent}
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="files">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-sm">Hide HUD from recordings</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only while recording. The idle HUD stays visible in captures.
|
||||
<p className="text-sm">Open video or project</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void run(onImportFile)}
|
||||
>
|
||||
Open file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="recording">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">Recordings folder</p>
|
||||
<p
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
title={recordings}
|
||||
>
|
||||
{recordings}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Hide HUD from recordings"
|
||||
checked={hideHud}
|
||||
disabled={busy}
|
||||
onCheckedChange={(enabled) =>
|
||||
void run(async () => {
|
||||
const result =
|
||||
await window.electronAPI.setHudOverlayCaptureProtection(
|
||||
enabled,
|
||||
);
|
||||
if (!result.success)
|
||||
throw Error("Could not update capture protection");
|
||||
setHideHud(result.enabled);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{import.meta.env.DEV && (
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<p className="text-sm">Preview update UI</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await window.electronAPI.previewUpdateToast();
|
||||
const result =
|
||||
await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) return;
|
||||
if (!result.success || !result.path)
|
||||
throw Error("Could not change recordings folder");
|
||||
setRecordings(result.path);
|
||||
})
|
||||
}
|
||||
>
|
||||
Preview
|
||||
Change folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-sm">Projects folder</p>
|
||||
{directory && (
|
||||
<p
|
||||
className="mt-1 max-w-xs truncate text-xs text-muted-foreground"
|
||||
title={directory}
|
||||
{captureSupported && (
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-sm">Hide HUD from recordings</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only while recording. The idle HUD stays visible in captures.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Hide HUD from recordings"
|
||||
checked={hideHud}
|
||||
disabled={busy}
|
||||
onCheckedChange={(enabled) =>
|
||||
void run(async () => {
|
||||
const result =
|
||||
await window.electronAPI.setHudOverlayCaptureProtection(
|
||||
enabled,
|
||||
);
|
||||
if (!result.success)
|
||||
throw Error("Could not update capture protection");
|
||||
setHideHud(result.enabled);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="advanced">
|
||||
{import.meta.env.DEV && (
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<p className="text-sm">Preview update UI</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await window.electronAPI.previewUpdateToast();
|
||||
})
|
||||
}
|
||||
>
|
||||
{directory}
|
||||
</p>
|
||||
)}
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="files">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-sm">Projects folder</p>
|
||||
{directory && (
|
||||
<p
|
||||
className="mt-1 max-w-xs truncate text-xs text-muted-foreground"
|
||||
title={directory}
|
||||
>
|
||||
{directory}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.getProjectsDirectory();
|
||||
if (!result.success || !result.path)
|
||||
throw Error("Could not open projects folder");
|
||||
setDirectory(result.path);
|
||||
await window.electronAPI.revealInFolder(result.path);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show folder
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.getProjectsDirectory();
|
||||
if (!result.success || !result.path)
|
||||
throw Error("Could not open projects folder");
|
||||
setDirectory(result.path);
|
||||
await window.electronAPI.revealInFolder(result.path);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Named projects save automatically. Previews refresh when you return to Projects.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Named projects save automatically. Previews refresh when you return to
|
||||
Projects.
|
||||
</p>
|
||||
</SettingsCategory>
|
||||
</SettingsSections>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback
|
||||
import {
|
||||
clampFocusToDepth,
|
||||
DEFAULT_AUTO_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type EditorEffectSection,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
@@ -72,7 +73,7 @@ export function useZoomRegionCommands({
|
||||
const handleZoomAdded = useCallback(
|
||||
(span: Span) => {
|
||||
const id = `zoom-${nextZoomIdRef.current++}`;
|
||||
const depth: ZoomDepth = 2;
|
||||
const depth = DEFAULT_ZOOM_DEPTH;
|
||||
const newRegion: ZoomRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
|
||||
@@ -647,7 +647,7 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
};
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
export const DEFAULT_AUTO_ZOOM_DEPTH: ZoomDepth = 2;
|
||||
export const DEFAULT_AUTO_ZOOM_DEPTH: ZoomDepth = DEFAULT_ZOOM_DEPTH;
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ test("editor inspector and playback controls fit a smaller desktop", async ({ pa
|
||||
await expect(page.getByRole("button", { name: "Export", exact: true })).toBeInViewport();
|
||||
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport();
|
||||
await page.getByRole("radio", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("row", { name: "Motion", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("radiogroup", { name: "Motion Presets", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -140,3 +140,14 @@ test("header name edits in place and saves on blur without a boxed input", async
|
||||
await input.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Rename project" })).toContainText("Launch demo");
|
||||
});
|
||||
|
||||
test("new zoom blocks default to 1.8x", async ({ page }) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
|
||||
await page.locator('[data-variant="zoom"] .timeline-block').first().click();
|
||||
await expect(page.getByRole("row", { name: "1.8×", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
+8
-32
@@ -64,7 +64,9 @@ test("editor loads video, switches tools and edits export options", async ({ pag
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Projects", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Projects dashboard", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Crop Video", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Crop Video", exact: true })).toBeVisible();
|
||||
@@ -72,15 +74,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag
|
||||
await page.getByRole("button", { name: "16:9", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "1:1", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "1:1", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open presets", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Preset name", exact: true })
|
||||
.fill("HeroUI test preset");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Delete preset HeroUI test preset", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Open presets", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
|
||||
await expect
|
||||
@@ -117,27 +111,9 @@ test("recorder opens device and source popovers", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Built-in Display", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "More", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Dark", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await page.screenshot({
|
||||
path: "test-results/hud-dark.png",
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("button", { name: "More", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Light", exact: true }).click();
|
||||
await page.getByRole("button", { name: "More", exact: true }).click();
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Open project", exact: false })
|
||||
.click();
|
||||
await expect(page.getByText("No saved projects yet", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.screenshot({
|
||||
path: "test-results/hud-light.png",
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-dashboard-opened", "true");
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -381,17 +381,27 @@ test("dashboard supports creation sort, independent folders, shared settings and
|
||||
await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0);
|
||||
await expect(home.getByRole("button", { name: "Sort projects" })).toHaveCount(0);
|
||||
await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible();
|
||||
await expect(home.getByRole("grid", { name: "Settings sections" })).toHaveCount(1);
|
||||
await expect(home.getByRole("switch", { name: "Connect Zooms" })).toHaveCount(0);
|
||||
await home.getByRole("row", { name: "Advanced", exact: true }).click();
|
||||
await expect(home.getByRole("switch", { name: "Experimental updates" })).toBeVisible();
|
||||
await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible();
|
||||
await home.getByRole("row", { name: "Motion", exact: true }).click();
|
||||
await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible();
|
||||
await home.getByRole("row", { name: "Recording", exact: true }).click();
|
||||
await expect(home.getByText("Recordings folder", { exact: true })).toBeVisible();
|
||||
await home.getByRole("button", { name: "Change folder" }).click();
|
||||
await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible();
|
||||
const capture = home.getByRole("switch", { name: "Hide HUD from recordings" });
|
||||
await capture.press("Space");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hide-hud", "true");
|
||||
await home.getByRole("row", { name: "Files", exact: true }).click();
|
||||
await expect(home.getByRole("button", { name: "Open file", exact: true })).toBeVisible();
|
||||
await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "test-results/dashboard-settings.png" });
|
||||
await home.getByRole("row", { name: "Recording", exact: true }).click();
|
||||
await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible();
|
||||
await home.getByRole("row", { name: "General", exact: true }).click();
|
||||
await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "test-results/dashboard-settings.png", animations: "disabled" });
|
||||
});
|
||||
|
||||
test("Solar navigation selection, circular initials, and Raw sources are consistent", async ({
|
||||
|
||||
Reference in New Issue
Block a user