mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Refine banner login and dashboard actions, restore zoom holds
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 794 KiB After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,8 @@
|
||||
# Official provider logos
|
||||
|
||||
Unmodified standalone color logos displayed inside HeroUI buttons; labels and controls belong to Recordly.
|
||||
|
||||
- `google-logo.png`: https://developers.google.com/static/identity/images/g-logo.png — linked by https://developers.google.com/identity/branding-guidelines
|
||||
- `microsoft-logo.svg`: https://learn.microsoft.com/en-us/entra/identity-platform/media/howto-add-branding-in-apps/ms-symbollockup_mssymbol_19.svg — linked by https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps
|
||||
|
||||
Downloaded September 23, 2026. Trademarks belong to the respective providers. Preserve original colors and aspect ratio.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -1,9 +1,9 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { demoLoginEnabled } from "@/lib/auth/demoSession";
|
||||
import { WindowsLogo } from "@phosphor-icons/react";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { GoogleLogo, SignOut } from "@/components/ui/icons";
|
||||
import { SignOut } from "@/components/ui/icons";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { type FormEvent, useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
signOutRecordly,
|
||||
} from "@/lib/auth/recordlyAuth";
|
||||
|
||||
const MotionDialog = motion.create(Modal.Dialog);
|
||||
|
||||
export type SignInReason = "account" | "share";
|
||||
|
||||
type Props = {
|
||||
variant?: "compact" | "wide";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reason?: SignInReason;
|
||||
@@ -52,15 +55,17 @@ function friendlyAuthError(
|
||||
}
|
||||
|
||||
export function RecordlySignInDialog({
|
||||
variant = "compact",
|
||||
open,
|
||||
onOpenChange,
|
||||
reason = "account",
|
||||
user,
|
||||
configured,
|
||||
callbackError,
|
||||
onAuthenticated,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const awaitingSignIn = useRef(false);
|
||||
const wide = variant === "wide";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState<string>();
|
||||
@@ -70,6 +75,7 @@ export function RecordlySignInDialog({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword("");
|
||||
awaitingSignIn.current = false;
|
||||
setBusy(undefined);
|
||||
setMessage(undefined);
|
||||
setResetSent(false);
|
||||
@@ -77,8 +83,14 @@ export function RecordlySignInDialog({
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user && reason === "share") onAuthenticated();
|
||||
}, [onAuthenticated, open, reason, user]);
|
||||
if (!open) return;
|
||||
if (!user) awaitingSignIn.current = true;
|
||||
else if (awaitingSignIn.current) {
|
||||
awaitingSignIn.current = false;
|
||||
setMessage(undefined);
|
||||
onAuthenticated();
|
||||
}
|
||||
}, [open, user, onAuthenticated]);
|
||||
|
||||
const run = async (label: string, action: () => Promise<unknown>) => {
|
||||
setBusy(label);
|
||||
@@ -101,7 +113,6 @@ export function RecordlySignInDialog({
|
||||
throw new Error("Email sign-in is not available yet.");
|
||||
}
|
||||
await signInWithEmail(email.trim(), password);
|
||||
onAuthenticated();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,37 +128,73 @@ export function RecordlySignInDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const reduceMotion = useReducedMotion();
|
||||
const transition = { duration: reduceMotion ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const reveal = {
|
||||
initial: { opacity: 0, y: reduceMotion ? 0 : 10 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition,
|
||||
};
|
||||
const disabled = Boolean(busy);
|
||||
const expanded = email.trim().length > 0;
|
||||
const artwork = `${import.meta.env.BASE_URL}wallpapers/wallpaper1.jpg`;
|
||||
return (
|
||||
<Modal isOpen={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Backdrop
|
||||
className="bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#10102066, #10102066), url(${artwork})`,
|
||||
}}
|
||||
>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container size="cover" placement="center" className="p-4 sm:p-8">
|
||||
<Modal.Dialog className="grid h-[min(760px,calc(100dvh-64px))] min-h-0 w-full max-w-[1120px] grid-cols-1 gap-0 overflow-hidden rounded-[32px] p-2 md:grid-cols-2">
|
||||
<MotionDialog
|
||||
layout
|
||||
transition={transition}
|
||||
className={`h-auto min-h-0 w-full max-h-[calc(100dvh-48px)] gap-0 overflow-y-auto rounded-[32px] p-2 ${wide ? "max-w-[1120px] aspect-[28/19]" : "max-w-[600px]"}`}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative h-32 shrink-0 overflow-hidden rounded-[24px] sm:h-36"
|
||||
>
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Modal.CloseTrigger
|
||||
aria-label={t("common.actions.close")}
|
||||
className="z-20"
|
||||
/>
|
||||
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-6 py-10 sm:px-10">
|
||||
<div className="my-auto w-full max-w-[340px] space-y-7">
|
||||
<Modal.Header className="items-center text-center">
|
||||
<Modal.Heading className="text-4xl font-semibold tracking-tight">
|
||||
{user ? "Your account" : "Welcome back"}
|
||||
</Modal.Heading>
|
||||
<Description className="text-sm">
|
||||
{user
|
||||
? user.email
|
||||
: reason === "share"
|
||||
? "Sign in to share your recordings."
|
||||
: "Sign in to your Recordly account."}
|
||||
</Description>
|
||||
</Modal.Header>
|
||||
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={transition}
|
||||
className="flex flex-1 flex-col justify-center px-6 py-7 sm:px-10"
|
||||
>
|
||||
<div className="w-full max-w-[420px] self-center space-y-5 @container">
|
||||
<motion.div {...reveal}>
|
||||
<Modal.Header className="items-center gap-4 text-center">
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
aria-label="Recordly"
|
||||
>
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}app-icons/recordly-128.png`}
|
||||
alt=""
|
||||
className="size-12 rounded-xl"
|
||||
/>
|
||||
<span className="text-3xl font-semibold tracking-tight">
|
||||
Recordly
|
||||
</span>
|
||||
</div>
|
||||
<Modal.Heading className="whitespace-nowrap text-[clamp(12px,5cqw,22px)] font-semibold leading-tight tracking-tight">
|
||||
{user
|
||||
? "Your account"
|
||||
: "Beautiful, shareable screen recordings"}
|
||||
</Modal.Heading>
|
||||
{user && (
|
||||
<Description className="text-sm">
|
||||
{user.email}
|
||||
</Description>
|
||||
)}
|
||||
</Modal.Header>
|
||||
</motion.div>
|
||||
{user ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -162,10 +209,18 @@ export function RecordlySignInDialog({
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<motion.div
|
||||
{...reveal}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: reduceMotion ? 0 : 0.05,
|
||||
}}
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-11 w-full rounded-xl"
|
||||
size="lg"
|
||||
className="w-full gap-3"
|
||||
isDisabled={disabled || !configured}
|
||||
onPress={() =>
|
||||
void run("google", () =>
|
||||
@@ -173,12 +228,17 @@ export function RecordlySignInDialog({
|
||||
)
|
||||
}
|
||||
>
|
||||
<GoogleLogo className="size-5" />
|
||||
Google
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}auth/google-logo.png`}
|
||||
alt=""
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
Continue with Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-11 w-full rounded-xl"
|
||||
size="lg"
|
||||
className="w-full gap-3"
|
||||
isDisabled={disabled || !configured}
|
||||
onPress={() =>
|
||||
void run("azure", () =>
|
||||
@@ -186,10 +246,14 @@ export function RecordlySignInDialog({
|
||||
)
|
||||
}
|
||||
>
|
||||
<WindowsLogo className="size-5" />
|
||||
Microsoft
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}auth/microsoft-logo.svg`}
|
||||
alt=""
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
Continue with Microsoft
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
@@ -197,10 +261,7 @@ export function RecordlySignInDialog({
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={submitEmail}
|
||||
>
|
||||
<Form className="flex flex-col" onSubmit={submitEmail}>
|
||||
<TextField
|
||||
name="email"
|
||||
type="email"
|
||||
@@ -215,54 +276,76 @@ export function RecordlySignInDialog({
|
||||
>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
className="h-12 rounded-xl bg-default/50 shadow-none"
|
||||
className="h-12"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isRequired
|
||||
isDisabled={disabled}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="password-fields"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={transition}
|
||||
className="overflow-hidden"
|
||||
inert={!expanded}
|
||||
>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
className="h-12 rounded-xl bg-default/50 shadow-none"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mt-3 self-end"
|
||||
isDisabled={disabled}
|
||||
onPress={forgotPassword}
|
||||
<motion.div
|
||||
initial={{ y: reduceMotion ? 0 : -8 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: reduceMotion ? 0 : -8 }}
|
||||
transition={transition}
|
||||
className="flex flex-col gap-5 px-1 pb-1 pt-5"
|
||||
>
|
||||
{t("editor.cloud.forgotPassword")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 w-full rounded-xl"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
(!configured && !demoLoginEnabled)
|
||||
}
|
||||
>
|
||||
{busy === "email"
|
||||
? t("editor.cloud.signingIn")
|
||||
: "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isRequired
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
className="h-12"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mt-3 self-end"
|
||||
isDisabled={disabled}
|
||||
onPress={forgotPassword}
|
||||
>
|
||||
{t(
|
||||
"editor.cloud.forgotPassword",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
(!configured &&
|
||||
!demoLoginEnabled)
|
||||
}
|
||||
>
|
||||
{busy === "email"
|
||||
? t("editor.cloud.signingIn")
|
||||
: "Sign in"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
@@ -284,24 +367,8 @@ export function RecordlySignInDialog({
|
||||
</Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden min-h-0 overflow-hidden rounded-[26px] md:block">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-black/10" />
|
||||
<span className="absolute left-8 top-8 text-lg font-semibold tracking-tight text-white">
|
||||
Recordly
|
||||
</span>
|
||||
<p className="absolute bottom-10 left-8 right-8 text-4xl font-light leading-tight tracking-tight text-white">
|
||||
Make something
|
||||
<br />
|
||||
<strong className="font-semibold">worth sharing.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Dialog>
|
||||
</motion.div>
|
||||
</MotionDialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { DashboardProps } from "./types";
|
||||
import { Dropdown } from "@heroui/react";
|
||||
import { CaretDown, Trash, UploadSimple } from "@/components/ui/icons";
|
||||
import { CaretDown, Trash } from "@/components/ui/icons";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import type { DashboardModel } from "./useDashboardModel";
|
||||
|
||||
export function DashboardFilters({
|
||||
onImportFile,
|
||||
run,
|
||||
isRaw,
|
||||
period,
|
||||
setPeriod,
|
||||
@@ -23,8 +21,6 @@ export function DashboardFilters({
|
||||
setConfirmDelete,
|
||||
}: Pick<
|
||||
DashboardModel & DashboardProps,
|
||||
| "onImportFile"
|
||||
| "run"
|
||||
| "isRaw"
|
||||
| "period"
|
||||
| "setPeriod"
|
||||
@@ -74,16 +70,6 @@ export function DashboardFilters({
|
||||
<Trash weight="fill" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run(onImportFile)}
|
||||
className="h-7 shrink-0 gap-2 text-xs"
|
||||
>
|
||||
<UploadSimple className="size-3.5" />
|
||||
Import
|
||||
</Button>
|
||||
<div className="ml-auto">
|
||||
<Dropdown>
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RecordNewButton } from "./RecordNewButton";
|
||||
import { RawPreview } from "./RawRecordings";
|
||||
import { Cloud, ImageSquare } from "@/components/ui/icons";
|
||||
|
||||
@@ -89,7 +90,7 @@ export function DashboardGrid({
|
||||
) : visible.length ? (
|
||||
<ul
|
||||
aria-label={isRaw ? "Raw files" : "Your projects"}
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
|
||||
>
|
||||
{visible.map((entry) => (
|
||||
<ProjectCard
|
||||
@@ -114,7 +115,13 @@ export function DashboardGrid({
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<ImageSquare weight="fill" className="size-8 opacity-30" />
|
||||
{!isRaw && !query ? (
|
||||
<span aria-hidden="true" className="text-4xl">
|
||||
🦗
|
||||
</span>
|
||||
) : (
|
||||
<ImageSquare weight="fill" className="size-8 opacity-30" />
|
||||
)}
|
||||
<p>
|
||||
{isRaw
|
||||
? rawLoading
|
||||
@@ -123,8 +130,11 @@ export function DashboardGrid({
|
||||
(query ? "No matching raw files" : "No raw recordings yet")
|
||||
: query
|
||||
? "No matching projects"
|
||||
: "No projects yet"}
|
||||
: "It's looking empty in here..."}
|
||||
</p>
|
||||
{!isRaw && !query && (
|
||||
<RecordNewButton busy={busy} run={run} first className="mt-2" />
|
||||
)}
|
||||
{isRaw && rawError && (
|
||||
<Button onClick={() => void refreshRaw()}>Retry</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RecordNewButton } from "./RecordNewButton";
|
||||
import { SidebarCards } from "./SidebarCards";
|
||||
import { FolderRow } from "./FolderRow";
|
||||
import { Cloud, File, GearSix, House, Plus, UserCircle } from "@/components/ui/icons";
|
||||
@@ -10,6 +11,8 @@ import type { DashboardModel } from "./useDashboardModel";
|
||||
import { FOLDER_COLORS } from "./useProjectFolders";
|
||||
|
||||
export function DashboardSidebar({
|
||||
busy,
|
||||
run,
|
||||
folders,
|
||||
save,
|
||||
section,
|
||||
@@ -21,6 +24,8 @@ export function DashboardSidebar({
|
||||
accountLabel,
|
||||
}: Pick<
|
||||
DashboardProps & DashboardModel,
|
||||
| "busy"
|
||||
| "run"
|
||||
| "folders"
|
||||
| "save"
|
||||
| "section"
|
||||
@@ -37,7 +42,7 @@ export function DashboardSidebar({
|
||||
aria-label="Library navigation"
|
||||
className="flex w-48 shrink-0 flex-col bg-transparent px-4 pb-5 pt-12 lg:w-56"
|
||||
>
|
||||
<div className="mb-9 flex h-10 items-center gap-2.5 px-3">
|
||||
<div className="mb-4 flex h-10 items-center gap-2.5 px-3">
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}app-icons/recordly-64.png`}
|
||||
alt=""
|
||||
@@ -45,6 +50,7 @@ export function DashboardSidebar({
|
||||
/>
|
||||
<span className="text-[15px] font-semibold tracking-tight">Recordly</span>
|
||||
</div>
|
||||
<RecordNewButton busy={busy} run={run} className="mb-5 w-full" />
|
||||
<nav className="space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MagnifyingGlass, Plus } from "@/components/ui/icons";
|
||||
import { MagnifyingGlass, UploadSimple } from "@/components/ui/icons";
|
||||
import { type CSSProperties } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -9,12 +9,16 @@ import type { DashboardProps } from "./types";
|
||||
import type { DashboardModel } from "./useDashboardModel";
|
||||
|
||||
export function DashboardToolbar({
|
||||
onImportFile,
|
||||
isRaw,
|
||||
query,
|
||||
setQuery,
|
||||
run,
|
||||
busy,
|
||||
}: Pick<DashboardProps & DashboardModel, "query" | "setQuery" | "run" | "busy" | "isRaw">) {
|
||||
}: Pick<
|
||||
DashboardProps & DashboardModel,
|
||||
"onImportFile" | "query" | "setQuery" | "run" | "busy" | "isRaw"
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -34,16 +38,16 @@ export function DashboardToolbar({
|
||||
className="h-11 w-full border-0 bg-default/30 pl-10 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => void run(() => window.electronAPI.showRecordingHud())}
|
||||
disabled={busy}
|
||||
className="h-10 gap-2 text-[13px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New
|
||||
</Button>
|
||||
<div style={{ WebkitAppRegion: "no-drag" } as CSSProperties}>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void run(onImportFile)}
|
||||
className="h-10 shrink-0 gap-2 text-[13px]"
|
||||
>
|
||||
<UploadSimple className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Plus } from "@/components/ui/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RecordNewButton({
|
||||
busy,
|
||||
run,
|
||||
className,
|
||||
first = false,
|
||||
}: {
|
||||
busy: boolean;
|
||||
run: (action: () => Promise<void>) => Promise<void>;
|
||||
className?: string;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => window.electronAPI.showRecordingHud())}
|
||||
className={cn("h-10 shrink-0 gap-2 text-[13px]", className)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{first ? "Record your first video" : "Record new"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function EditorHeader(props: Props) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="mx-3 h-5 shrink-0" />
|
||||
<Separator orientation="vertical" className="mx-3 h-5 shrink-0 self-center" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -202,7 +202,7 @@ export function EditorHeader(props: Props) {
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
|
||||
<Separator orientation="vertical" className="mx-3 h-5 shrink-0 self-center" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type SpringState,
|
||||
stepSpringValue,
|
||||
} from "./motionSmoothing";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,46 +332,12 @@ describe("computeRegionStrength", () => {
|
||||
|
||||
it("falls smoothly during zoom-out", () => {
|
||||
// Sample the zoom-out ramp after the animation delay.
|
||||
const zoomOutStart = region.endMs - 500;
|
||||
const zoomOutStart = region.endMs - 150;
|
||||
const s = computeRegionStrength(region, zoomOutStart + 700);
|
||||
expect(s).toBeGreaterThan(0);
|
||||
expect(s).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("removes 500ms of hold without changing either ramp", () => {
|
||||
const entranceStart = region.startMs + 1000 - ZOOM_IN_TRANSITION_WINDOW_MS + 500;
|
||||
const exitStart = region.endMs - 500;
|
||||
for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
expect(
|
||||
computeRegionStrength(
|
||||
region,
|
||||
entranceStart + fraction * ZOOM_IN_TRANSITION_WINDOW_MS,
|
||||
),
|
||||
).toBeCloseTo(easeOutZoom(fraction));
|
||||
expect(
|
||||
computeRegionStrength(region, exitStart + fraction * TRANSITION_WINDOW_MS),
|
||||
).toBeCloseTo(1 - easeOutZoom(fraction));
|
||||
}
|
||||
expect(computeRegionStrength(region, exitStart - 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("only removes the available hold for short blocks", () => {
|
||||
const short = { ...region, endMs: 3700 };
|
||||
// The original 200ms plateau is removed; neither ramp is shortened.
|
||||
expect(computeRegionStrength(short, 3500)).toBe(1);
|
||||
expect(computeRegionStrength(short, 3500 + TRANSITION_WINDOW_MS / 2)).toBeCloseTo(
|
||||
1 - easeOutZoom(0.5),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shortened exit for the final zoom in a connected sequence", () => {
|
||||
const first = { ...region, id: "first", startMs: 0, endMs: 2000 };
|
||||
const time = region.endMs - 500 + TRANSITION_WINDOW_MS / 2;
|
||||
expect(
|
||||
findDominantRegion([first, region], time, { connectZooms: true }).strength,
|
||||
).toBeCloseTo(1 - easeOutZoom(0.5));
|
||||
});
|
||||
|
||||
it("shifts zoom timing when custom durations are provided", () => {
|
||||
const defaultStrength = computeRegionStrength(region, region.startMs);
|
||||
const fasterStrength = computeRegionStrength(region, region.startMs, {
|
||||
|
||||
@@ -13,7 +13,6 @@ const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
const ZOOM_IN_OVERLAP_MS = 1000;
|
||||
// Playback offset relative to timeline blocks; positive values delay the animation.
|
||||
const ZOOM_ANIMATION_DELAY_MS = 500;
|
||||
const ZOOM_HOLD_REDUCTION_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
@@ -36,12 +35,14 @@ type ConnectedPanTransition = {
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function getRegionTiming(
|
||||
export function computeRegionStrength(
|
||||
region: ZoomRegion,
|
||||
timeMs: number,
|
||||
options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {},
|
||||
) {
|
||||
const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS);
|
||||
const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS);
|
||||
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_DELAY_MS;
|
||||
const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS;
|
||||
let zoomInEnd = leadInStart + zoomInDurationMs;
|
||||
@@ -52,19 +53,6 @@ function getRegionTiming(
|
||||
zoomOutStart = midpoint;
|
||||
}
|
||||
|
||||
// Consume only the plateau: keep both animation durations and the entrance fixed.
|
||||
zoomOutStart = Math.max(zoomInEnd, zoomOutStart - ZOOM_HOLD_REDUCTION_MS);
|
||||
return { leadInStart, zoomInEnd, zoomOutStart, zoomInDurationMs, zoomOutDurationMs };
|
||||
}
|
||||
|
||||
export function computeRegionStrength(
|
||||
region: ZoomRegion,
|
||||
timeMs: number,
|
||||
options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {},
|
||||
) {
|
||||
const { leadInStart, zoomInEnd, zoomOutStart, zoomInDurationMs, zoomOutDurationMs } =
|
||||
getRegionTiming(region, options);
|
||||
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_DELAY_MS;
|
||||
const leadOutEnd = zoomOutStart + zoomOutDurationMs;
|
||||
|
||||
if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) {
|
||||
@@ -128,7 +116,8 @@ function getActiveRegion(
|
||||
}
|
||||
|
||||
const zoomOutStart =
|
||||
getRegionTiming(outgoingPair.currentRegion, options).zoomOutStart +
|
||||
outgoingPair.currentRegion.endMs -
|
||||
ZOOM_OUT_EARLY_START_MS +
|
||||
ZOOM_ANIMATION_DELAY_MS;
|
||||
if (timeMs >= zoomOutStart) {
|
||||
return { region, strength: 1 };
|
||||
@@ -142,7 +131,8 @@ function getActiveRegion(
|
||||
}
|
||||
|
||||
const nextRegionZoomOutStart =
|
||||
getRegionTiming(incomingPair.nextRegion, options).zoomOutStart +
|
||||
incomingPair.nextRegion.endMs -
|
||||
ZOOM_OUT_EARLY_START_MS +
|
||||
ZOOM_ANIMATION_DELAY_MS;
|
||||
if (timeMs < nextRegionZoomOutStart) {
|
||||
return { region, strength: 1 };
|
||||
|
||||
+11
-14
@@ -136,15 +136,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Visible keyboard focus for HeroUI controls and native focusable elements. */
|
||||
/* HeroUI owns control focus rings; native controls use the same accent token.
|
||||
Do not outline dialogs or other programmatically focused containers. */
|
||||
:root,
|
||||
.dark {
|
||||
--focus: var(--foreground);
|
||||
--focus: var(--accent);
|
||||
}
|
||||
[data-focus-visible="true"],
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
:focus-visible:not([data-rac]):not([role="dialog"]):not([role="alertdialog"]):not([tabindex="-1"]) {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@container (max-width: 110px) {
|
||||
@@ -184,13 +188,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep dashboard focus visible without the oversized default halo. */
|
||||
.dashboard-surface :is(button, input, [role="combobox"]):focus-visible,
|
||||
.dashboard-surface [data-focus-visible="true"] {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dashboard-surface .folder-row > button {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -205,7 +202,7 @@
|
||||
color: inherit;
|
||||
}
|
||||
.inline-project-name:focus { outline: none; box-shadow: none; }
|
||||
.inline-project-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.inline-project-name:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
.dashboard-shell {
|
||||
background-color: color-mix(in srgb, var(--background) 92%, var(--foreground) 8%);
|
||||
|
||||
@@ -69,6 +69,16 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy
|
||||
"padding-left",
|
||||
trafficLightsVisible ? "76px" : "0px",
|
||||
);
|
||||
const header = await page.locator(".editor-header").boundingBox();
|
||||
const dividers = await page.locator(".editor-header").getByRole("separator").all();
|
||||
expect(dividers).toHaveLength(2);
|
||||
for (const divider of dividers) {
|
||||
const box = await divider.boundingBox();
|
||||
expect(box!.height).toBe(20);
|
||||
expect(
|
||||
Math.abs(box!.y + box!.height / 2 - (header!.y + header!.height / 2)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
const stage = await page.locator(".editor-preview-stage").boundingBox();
|
||||
const frame = await page.locator(".editor-preview-frame").boundingBox();
|
||||
expect(
|
||||
|
||||
+78
-5
@@ -1,18 +1,62 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test("split login expands email, rejects incorrect credentials, and persists the local account until sign-out", async ({
|
||||
test("banner login animates email expansion, rejects incorrect credentials, and persists the local account until sign-out", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
const login = page.getByRole("dialog", { name: "Welcome back", exact: true });
|
||||
await expect(login.getByRole("button", { name: "Google", exact: true })).toBeVisible();
|
||||
await expect(login.getByRole("button", { name: "Microsoft", exact: true })).toBeVisible();
|
||||
const login = page.getByRole("dialog", {
|
||||
name: "Beautiful, shareable screen recordings",
|
||||
exact: true,
|
||||
});
|
||||
await expect(
|
||||
login.getByRole("button", { name: "Continue with Google", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
login.getByRole("button", { name: "Continue with Microsoft", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
|
||||
await expect(login.getByText("Recordly", { exact: true })).toBeVisible();
|
||||
await expect(login.getByText("worth sharing.", { exact: false })).toHaveCount(0);
|
||||
for (const provider of ["Google", "Microsoft"]) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
login
|
||||
.getByRole("button", { name: `Continue with ${provider}`, exact: true })
|
||||
.locator("img")
|
||||
.evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const heading = await login.getByRole("heading").boundingBox();
|
||||
const textSize = await login.getByRole("heading").evaluate((element) => ({
|
||||
height: element.clientHeight,
|
||||
width: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
lineHeight: parseFloat(getComputedStyle(element).lineHeight),
|
||||
}));
|
||||
expect(textSize.height).toBeLessThanOrEqual(textSize.lineHeight + 1);
|
||||
expect(textSize.scrollWidth).toBeLessThanOrEqual(textSize.width);
|
||||
const artwork = await login.locator('img[src$="wallpaper1.jpg"]').boundingBox();
|
||||
expect(artwork!.y + artwork!.height).toBeLessThan(heading!.y);
|
||||
await page.keyboard.press("Tab");
|
||||
await login.evaluate((element: HTMLElement) => element.focus());
|
||||
await expect(login).toHaveCSS("outline-style", "none");
|
||||
|
||||
await page.screenshot({ path: "test-results/login.png", animations: "disabled" });
|
||||
const collapsed = (await login.boundingBox())!.height;
|
||||
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
|
||||
await expect
|
||||
.poll(async () => (await login.boundingBox())!.height)
|
||||
.toBeGreaterThan(collapsed + 80);
|
||||
await login.getByLabel("Email", { exact: true }).fill("");
|
||||
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => Math.abs((await login.boundingBox())!.height - collapsed))
|
||||
.toBeLessThan(2);
|
||||
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
|
||||
await login.getByLabel("Password", { exact: true }).fill("incorrect");
|
||||
await login.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
@@ -20,14 +64,43 @@ test("split login expands email, rejects incorrect credentials, and persists the
|
||||
await login.getByLabel("Password", { exact: true }).fill("1234");
|
||||
await page.screenshot({ path: "test-results/login-expanded.png", animations: "disabled" });
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
await login
|
||||
.getByRole("heading", { name: "Beautiful, shareable screen recordings", exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
await expect(
|
||||
login.getByRole("heading", { name: "Beautiful, shareable screen recordings", exact: true }),
|
||||
).toBeInViewport();
|
||||
await login.getByRole("button", { name: "Sign in", exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(login.getByRole("button", { name: "Sign in", exact: true })).toBeInViewport();
|
||||
await login.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(login).not.toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Projects dashboard" })).toBeVisible();
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await page.getByRole("button", { name: "test@email.com", exact: true }).click();
|
||||
const account = page.getByRole("dialog", { name: "Your account", exact: true });
|
||||
await expect(account.getByText("test@email.com")).toBeVisible();
|
||||
await account.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Welcome back" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Beautiful, shareable screen recordings" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("login banner and email expansion work with reduced motion", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
|
||||
const login = page.getByRole("dialog", {
|
||||
name: "Beautiful, shareable screen recordings",
|
||||
exact: true,
|
||||
});
|
||||
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
|
||||
await expect(login.getByLabel("Password", { exact: true })).toBeVisible();
|
||||
await login.getByLabel("Email", { exact: true }).fill("");
|
||||
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
|
||||
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
|
||||
await login.getByLabel("Password", { exact: true }).fill("1234");
|
||||
await login.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(login).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -55,8 +55,10 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
|
||||
await home.getByRole("button", { name: "Sort projects" }).click();
|
||||
await page.getByRole("menuitem", { name: "Name", exact: true }).click();
|
||||
await expect(cards.nth(1)).toHaveAccessibleName("Getting started");
|
||||
const normalCardWidth = (await cards.first().boundingBox())!.width;
|
||||
await home.getByRole("textbox", { name: "Search projects" }).fill("launch");
|
||||
await expect(cards).toHaveCount(1);
|
||||
expect(Math.abs((await cards.first().boundingBox())!.width - normalCardWidth)).toBeLessThan(1);
|
||||
await home.getByRole("textbox", { name: "Search projects" }).fill("missing");
|
||||
await expect(home.getByText("No matching projects")).toBeVisible();
|
||||
await home.getByRole("button", { name: "Clear search" }).click();
|
||||
@@ -102,7 +104,7 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
|
||||
await home.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible();
|
||||
await home.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await home.getByRole("button", { name: "New", exact: true }).click();
|
||||
await home.getByRole("complementary", { name: "Library navigation" }).getByRole("button", { name: "Record new", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
|
||||
await home.getByRole("button", { name: "Select projects to delete" }).click();
|
||||
await cards.first().click();
|
||||
@@ -136,7 +138,19 @@ test("home dashboard explains an empty library", async ({ page }) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click();
|
||||
await expect(page.getByText("No projects yet")).toBeVisible();
|
||||
await expect(page.getByText("It's looking empty in here...")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Record new", exact: true })).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Record your first video", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
|
||||
await page.evaluate(() => {
|
||||
delete document.documentElement.dataset.hudOpened;
|
||||
});
|
||||
await page
|
||||
.getByRole("complementary", { name: "Library navigation" })
|
||||
.getByRole("button", { name: "Record new", exact: true })
|
||||
.click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
|
||||
await page.screenshot({ path: "test-results/dashboard-empty.png", animations: "disabled" });
|
||||
await expect(page.getByRole("button", { name: "Back to editor" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
|
||||
@@ -694,8 +708,8 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
|
||||
const importButton = home.getByRole("button", { name: "Import", exact: true });
|
||||
const all = home.getByRole("button", { name: "All", exact: true });
|
||||
expect(
|
||||
Math.abs((await importButton.boundingBox())!.y - (await all.boundingBox())!.y),
|
||||
).toBeLessThan(2);
|
||||
(await importButton.boundingBox())!.y - (await all.boundingBox())!.y,
|
||||
).toBeLessThan(0);
|
||||
expect(await all.evaluate((element) => element.parentElement!.textContent)).not.toContain(
|
||||
"Import",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user