This commit is contained in:
Amruth Pillai
2026-04-02 00:14:54 +02:00
parent d9a24448e8
commit 4fd43657dc
175 changed files with 11886 additions and 1840 deletions
+3 -6
View File
@@ -70,14 +70,13 @@ export function FAQ() {
>
<motion.h2
className={cn(
"flex-1 text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl",
"flex-1 text-2xl font-semibold tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
)}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<Trans context="Every word needs to be wrapped in a tag">
<span>Frequently</span>
@@ -87,12 +86,11 @@ export function FAQ() {
</motion.h2>
<motion.div
className="max-w-2xl flex-2 lg:ml-auto 2xl:max-w-3xl"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.08 }}
style={{ willChange: "transform, opacity" }}
className="max-w-2xl flex-2 will-change-[transform,opacity] lg:ml-auto 2xl:max-w-3xl"
>
<Accordion multiple>
{faqItems.map((item, index) => (
@@ -112,12 +110,11 @@ type FAQItemComponentProps = {
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
return (
<motion.div
className="last:border-b"
className="will-change-[transform,opacity] last:border-b"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
style={{ willChange: "transform, opacity" }}
>
<AccordionItem value={item.question} className="group border-t">
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
+2 -4
View File
@@ -136,7 +136,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
return (
<motion.div
className={cn(
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300",
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
"hover:bg-secondary/30",
)}
@@ -146,7 +146,6 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
transition={{ duration: 0.35, ease: "easeOut" }}
whileHover={{ y: -3, scale: 1.01 }}
whileTap={{ scale: 0.995 }}
style={{ willChange: "transform, opacity" }}
>
{/* Hover gradient overlay */}
<div
@@ -177,12 +176,11 @@ export function Features() {
<section id="features">
{/* Header */}
<motion.div
className="space-y-4 p-4 md:p-8 xl:py-16"
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
<Trans>Features</Trans>
+2 -4
View File
@@ -50,12 +50,11 @@ export function Footer() {
return (
<motion.footer
id="footer"
className="p-4 pb-8 md:p-8 md:pb-12"
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "opacity" }}
>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
{/* Brand Column */}
@@ -143,8 +142,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
initial={{ width: 0, opacity: 0 }}
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary"
style={{ willChange: "width, opacity" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
/>
</a>
</li>
+6 -9
View File
@@ -18,10 +18,10 @@ export function Hero() {
<Spotlight />
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.1, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<CometCard glareOpacity={0} className="3xl:max-w-7xl relative -mb-12 max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
<video
@@ -46,12 +46,12 @@ export function Hero() {
<div className="xs:px-0 relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 text-center">
{/* Badge */}
<motion.a
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.55 }}
whileHover={{ y: -2, scale: 1.01 }}
whileTap={{ scale: 0.985 }}
style={{ willChange: "transform, opacity" }}
target="_blank"
rel="noopener noreferrer"
href="https://docs.rxresu.me/getting-started"
@@ -64,10 +64,10 @@ export function Hero() {
{/* Headline */}
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.7 }}
style={{ willChange: "transform, opacity" }}
>
<Trans>
<p className="font-medium tracking-tight text-muted-foreground md:text-lg">Finally,</p>
@@ -79,11 +79,10 @@ export function Hero() {
{/* Description */}
<motion.p
className="max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg"
className="max-w-xl text-base leading-relaxed text-muted-foreground will-change-[transform,opacity] md:text-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.82 }}
style={{ willChange: "transform, opacity" }}
>
<Trans>
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
@@ -93,11 +92,10 @@ export function Hero() {
{/* CTA Buttons */}
<motion.div
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.95 }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -144,10 +142,9 @@ export function Hero() {
transition={{ delay: 1.25, duration: 0.7 }}
>
<motion.div
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
animate={{ y: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
style={{ willChange: "transform" }}
>
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
</motion.div>
+1 -2
View File
@@ -16,12 +16,11 @@ export function Prefooter() {
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
<motion.div
className="mx-auto max-w-3xl space-y-8 px-6 text-center md:px-8 xl:px-0"
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-bold tracking-tight md:text-4xl">
<Trans>By the community, for the community.</Trans>
+2 -4
View File
@@ -13,12 +13,11 @@ type TemplateItemProps = {
function TemplateItem({ metadata }: TemplateItemProps) {
return (
<motion.div
className="group relative shrink-0"
className="group relative shrink-0 will-change-transform"
initial={{ scale: 1, zIndex: 10 }}
whileHover={{ scale: 1.06, zIndex: 20 }}
whileTap={{ scale: 0.99 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ willChange: "transform" }}
>
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
@@ -86,12 +85,11 @@ export function Templates() {
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<motion.div
className="space-y-4"
className="space-y-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.35 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
<Trans>Templates</Trans>
+1 -2
View File
@@ -35,12 +35,11 @@ type TestimonialCardProps = {
function TestimonialCard({ testimonial }: TestimonialCardProps) {
return (
<motion.div
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
className="group relative w-[320px] shrink-0 will-change-transform sm:w-[360px] md:w-[400px]"
initial={{ scale: 1 }}
whileHover={{ y: -3, scale: 1.02 }}
whileTap={{ scale: 0.995 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
style={{ willChange: "transform" }}
>
<div className="relative flex h-full flex-col rounded-md border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
<p className="flex-1 leading-relaxed text-muted-foreground">"{testimonial}"</p>
+79 -45
View File
@@ -1,18 +1,55 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, GoogleLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import type { RouterOutput } from "@/integrations/orpc/client";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { authClient } from "@/integrations/auth/client";
import { orpc } from "@/integrations/orpc/client";
import { cn } from "@/utils/style";
export function SocialAuth() {
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
return (
<>
<div className="flex items-center gap-x-2">
<hr className="flex-1" />
<span className="text-xs font-medium tracking-wide">
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
or continue with
</Trans>
</span>
<hr className="flex-1" />
</div>
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} />}
</>
);
}
function SocialAuthSkeleton() {
return (
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
);
}
type SocialAuthButtonsProps = {
providers: RouterOutput["auth"]["providers"]["list"];
};
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
const router = useRouter();
const { data: authProviders = {} } = useQuery(orpc.auth.providers.list.queryOptions());
const handleSocialLogin = async (provider: string) => {
const toastId = toast.loading(t`Signing in...`);
@@ -49,51 +86,48 @@ export function SocialAuth() {
};
return (
<>
<div className="flex items-center gap-x-2">
<hr className="flex-1" />
<span className="text-xs font-medium tracking-wide">
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
or continue with
</Trans>
</span>
<hr className="flex-1" />
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
onClick={handleOAuthLogin}
className={cn("hidden", "custom" in providers && "inline-flex")}
>
<VaultIcon />
{providers.custom}
</Button>
<div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
onClick={handleOAuthLogin}
className={cn("hidden", "custom" in authProviders && "inline-flex")}
>
<VaultIcon />
{authProviders.custom}
</Button>
<Button
onClick={() => handleSocialLogin("google")}
className={cn(
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
"google" in providers && "inline-flex",
)}
>
<GoogleLogoIcon />
Google
</Button>
<Button
onClick={() => handleSocialLogin("google")}
className={cn(
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
"google" in authProviders && "inline-flex",
)}
>
<GoogleLogoIcon />
Google
</Button>
<Button
onClick={() => handleSocialLogin("github")}
className={cn(
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
"github" in providers && "inline-flex",
)}
>
<GithubLogoIcon />
GitHub
</Button>
<Button
onClick={() => handleSocialLogin("github")}
className={cn(
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
"github" in authProviders && "inline-flex",
)}
>
<GithubLogoIcon />
GitHub
</Button>
</div>
</div>
</>
<Button
onClick={() => handleSocialLogin("linkedin")}
className={cn(
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
"linkedin" in providers && "inline-flex",
)}
>
<LinkedinLogoIcon />
LinkedIn
</Button>
</div>
);
}
@@ -111,8 +111,7 @@ export function BuilderDock() {
animate={{ opacity: 0.6, y: 0 }}
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl"
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
>
<DockIcon
disabled={!canUndo}
@@ -166,10 +165,10 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
<TooltipTrigger
render={
<motion.div
className="will-change-transform"
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
whileTap={disabled ? undefined : { scale: 0.97 }}
transition={{ duration: 0.15, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
<Icon className={cn("size-4", iconClassName)} />
@@ -245,8 +245,7 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="group relative flex h-18 border-b select-none"
className="group relative flex h-18 border-b will-change-[transform,opacity] select-none"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] group-hover:opacity-100 hover:bg-secondary/40"
@@ -192,8 +192,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform" }}
className="absolute inset-0 flex size-8 items-center justify-center"
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
>
<div className="size-4 rounded-md bg-foreground" />
</motion.div>
+52 -2
View File
@@ -1,4 +1,4 @@
import type { usePanelRef } from "react-resizable-panels";
import type { Layout, usePanelRef } from "react-resizable-panels";
import { useCallback, useMemo } from "react";
import { useWindowSize } from "usehooks-ts";
@@ -8,12 +8,61 @@ import { useIsMobile } from "@/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
export type BuilderLayout = {
left: number;
artboard: number;
right: number;
};
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
left: 22,
artboard: 56,
right: 22,
};
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
const left = layout.left;
const artboard = layout.artboard;
const right = layout.right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
};
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
if (!value) return DEFAULT_BUILDER_LAYOUT;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
const left = (parsed as { left?: unknown }).left;
const artboard = (parsed as { artboard?: unknown }).artboard;
const right = (parsed as { right?: unknown }).right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
} catch {
return DEFAULT_BUILDER_LAYOUT;
}
};
interface BuilderSidebarState {
layout: BuilderLayout;
leftSidebar: PanelImperativeHandle | null;
rightSidebar: PanelImperativeHandle | null;
}
interface BuilderSidebarActions {
setLayout: (layout: BuilderLayout) => void;
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
}
@@ -21,9 +70,10 @@ interface BuilderSidebarActions {
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
isDragging: false,
layout: DEFAULT_BUILDER_LAYOUT,
leftSidebar: null,
rightSidebar: null,
setLayout: (layout) => set({ layout }),
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
setRightSidebar: (ref) => set({ rightSidebar: ref }),
}));
+41 -27
View File
@@ -1,13 +1,12 @@
import type React from "react";
import type { Layout } from "react-resizable-panels";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { useEffect } from "react";
import { type Layout, usePanelRef } from "react-resizable-panels";
import { useDebounceCallback } from "usehooks-ts";
import z from "zod";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { useCSSVariables } from "@/components/resume/hooks/use-css-variables";
@@ -19,7 +18,15 @@ import { orpc } from "@/integrations/orpc/client";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
import { useBuilderSidebar, useBuilderSidebarStore } from "./-store/sidebar";
import {
BUILDER_LAYOUT_COOKIE_NAME,
DEFAULT_BUILDER_LAYOUT,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebar,
useBuilderSidebarStore,
type BuilderLayout,
} from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
@@ -61,26 +68,36 @@ function RouteComponent() {
}
type BuilderLayoutProps = React.ComponentProps<"div"> & {
initialLayout: Layout;
initialLayout: BuilderLayout;
};
function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
const isMobile = useIsMobile();
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
maxSidebarSize: state.maxSidebarSize,
collapsedSidebarSize: state.collapsedSidebarSize,
}));
const onLayoutChange = useDebounceCallback((layout: Layout) => {
void setBuilderLayoutServerFn({ data: layout });
}, 200);
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
void setBuilderLayoutServerFn({ data: nextLayout });
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
@@ -89,22 +106,24 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
const leftSidebarSize = isMobile ? 0 : initialLayout.left;
const rightSidebarSize = isMobile ? 0 : initialLayout.right;
const artboardSize = isMobile ? 100 : initialLayout.artboard;
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
return (
<div className="flex h-svh flex-col" {...props}>
<BuilderHeader />
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChange={onLayoutChange}>
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
maxSize={maxSidebarSize}
minSize={collapsedSidebarSize * 2}
collapsedSize={collapsedSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
@@ -120,8 +139,8 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
id="right"
panelRef={rightSidebarRef}
maxSize={maxSidebarSize}
minSize={collapsedSidebarSize * 2}
collapsedSize={collapsedSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
@@ -132,19 +151,14 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
);
}
const defaultLayout = { left: 30, artboard: 40, right: 30 };
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
const layoutSchema = z.record(z.string(), z.number()).catch(defaultLayout);
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
.inputValidator(layoutSchema)
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
.handler(async ({ data }) => {
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data));
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
});
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async () => {
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return defaultLayout;
return layoutSchema.parse(JSON.parse(layout));
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
});
+1 -1
View File
@@ -194,11 +194,11 @@ export function DashboardSidebar() {
{state === "expanded" && (
<motion.div
key="copyright"
className="will-change-[transform,opacity]"
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 12, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Copyright className="shrink-0 p-2 wrap-break-word whitespace-normal" />
</motion.div>
@@ -0,0 +1,83 @@
import { Trans } from "@lingui/react/macro";
import { BriefcaseIcon, BuildingsIcon, ClockIcon, GlobeIcon, MapPinIcon, MoneyIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import type { JobResult } from "@/schema/jobs";
import { Badge } from "@/components/ui/badge";
import { formatPostedDate, formatSalary } from "./job-utils";
type Props = {
job: JobResult;
onClick: () => void;
};
export function JobCard({ job, onClick }: Props) {
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
const posted = formatPostedDate(job.job_posted_at_timestamp);
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
return (
<motion.button
type="button"
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex items-start gap-x-3">
{job.employer_logo ? (
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
<BuildingsIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium">{job.job_title}</h3>
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{location && (
<Badge variant="secondary" className="gap-x-1">
<MapPinIcon className="size-3" />
{location}
</Badge>
)}
{job.job_is_remote && (
<Badge variant="secondary" className="gap-x-1">
<GlobeIcon className="size-3" />
<Trans>Remote</Trans>
</Badge>
)}
{job.job_employment_type && (
<Badge variant="secondary" className="gap-x-1">
<BriefcaseIcon className="size-3" />
{job.job_employment_type.replaceAll("_", " ")}
</Badge>
)}
{salary && (
<Badge variant="secondary" className="gap-x-1">
<MoneyIcon className="size-3" />
{salary}
</Badge>
)}
{posted && (
<Badge variant="outline" className="gap-x-1">
<ClockIcon className="size-3" />
{posted}
</Badge>
)}
</div>
</motion.button>
);
}
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAIStore } from "@/integrations/ai/store";
import { sanitizeHtml } from "@/utils/sanitize";
import { formatSalary, isValidExternalUrl } from "./job-utils";
import { TailorDialog } from "./tailor-dialog";
@@ -31,7 +31,6 @@ type Props = {
};
export function JobDetailSheet({ job, open, onOpenChange }: Props) {
const isAIEnabled = useAIStore((s) => s.enabled);
const [tailorOpen, setTailorOpen] = useState(false);
if (!job) return null;
@@ -119,12 +118,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<Trans>Apply</Trans>
</Button>
<Button
variant="outline"
className="flex-1"
disabled={!isAIEnabled}
onClick={() => setTailorOpen(true)}
>
<Button variant="outline" className="flex-1" onClick={() => setTailorOpen(true)}>
<StarIcon />
<Trans>Tailor Resume</Trans>
</Button>
@@ -137,9 +131,10 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<h4 className="font-medium">
<Trans>Description</Trans>
</h4>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{job.job_description}
</p>
<div
className="text-sm leading-relaxed text-muted-foreground [&_a]:text-primary [&_a]:underline [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-sm [&_h3]:font-medium [&_h3]:text-foreground [&_li]:ml-4 [&_ol]:list-decimal [&_p]:mb-2 [&_strong]:font-semibold [&_strong]:text-foreground [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(job.job_description) }}
/>
</div>
)}
@@ -197,7 +192,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
</>
)}
{job.apply_options.length > 0 && (
{job.apply_options.some((option) => isValidExternalUrl(option.apply_link)) && (
<>
<Separator />
<div className="flex flex-col gap-y-2">
@@ -205,23 +200,25 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<Trans>Apply Via</Trans>
</h4>
<div className="flex flex-col gap-y-1.5">
{job.apply_options.map((option, i) => (
<a
key={i}
href={option.apply_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
>
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
{option.publisher || t`Apply Link`}
{option.is_direct && (
<Badge variant="outline" className="text-[10px]">
<Trans>Direct</Trans>
</Badge>
)}
</a>
))}
{job.apply_options
.filter((option) => isValidExternalUrl(option.apply_link))
.map((option, i) => (
<a
key={i}
href={option.apply_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
>
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
{option.publisher || t`Apply Link`}
{option.is_direct && (
<Badge variant="outline" className="text-[10px]">
<Trans>Direct</Trans>
</Badge>
)}
</a>
))}
</div>
</div>
</>
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ReadCvLogoIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
@@ -40,7 +39,6 @@ type DialogPhase =
| { step: "skill-sync"; newResumeId: string; newSkills: NewSkillInfo[]; sourceResumeId: string };
export function TailorDialog({ job, open, onOpenChange }: Props) {
const navigate = useNavigate();
const { data: resumes, isLoading } = useQuery(orpc.resume.list.queryOptions());
const [phase, setPhase] = useState<DialogPhase>({ step: "select" });
@@ -65,11 +63,7 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
};
const navigateToBuilder = (resumeId: string) => {
if (job.job_apply_link) {
window.open(job.job_apply_link, "_blank", "noopener,noreferrer");
}
handleOpenChange(false);
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
window.open(`/builder/${resumeId}`, "_blank", "noopener,noreferrer");
};
const handleSelectResume = async (resumeId: string, resumeName: string) => {
+17 -82
View File
@@ -2,14 +2,9 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
BriefcaseIcon,
BuildingsIcon,
CaretLeftIcon,
CaretRightIcon,
ClockIcon,
GlobeIcon,
MagnifyingGlassIcon,
MapPinIcon,
MoneyIcon,
WarningCircleIcon,
XIcon,
} from "@phosphor-icons/react";
@@ -17,19 +12,19 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useMemo } from "react";
import type { JobResult } from "@/schema/jobs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/utils/style";
import { DashboardHeader } from "../-components/header";
import { JobCard } from "./-components/job-card";
import { JobDetailSheet } from "./-components/job-detail";
import { formatPostedDate, formatSalary, getQuotaStatus } from "./-components/job-utils";
import { getQuotaStatus } from "./-components/job-utils";
import { hasActiveFilters, initialFilterState, SearchFilters } from "./-components/search-filters";
import { useJobSearch } from "./-components/use-job-search";
@@ -37,75 +32,6 @@ export const Route = createFileRoute("/dashboard/job-search/")({
component: RouteComponent,
});
function JobCard({ job, onClick }: { job: JobResult; onClick: () => void }) {
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
const posted = formatPostedDate(job.job_posted_at_timestamp);
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
return (
<motion.button
type="button"
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex items-start gap-x-3">
{job.employer_logo ? (
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
<BuildingsIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium">{job.job_title}</h3>
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{location && (
<Badge variant="secondary" className="gap-x-1">
<MapPinIcon className="size-3" />
{location}
</Badge>
)}
{job.job_is_remote && (
<Badge variant="secondary" className="gap-x-1">
<GlobeIcon className="size-3" />
<Trans>Remote</Trans>
</Badge>
)}
{job.job_employment_type && (
<Badge variant="secondary" className="gap-x-1">
<BriefcaseIcon className="size-3" />
{job.job_employment_type.replaceAll("_", " ")}
</Badge>
)}
{salary && (
<Badge variant="secondary" className="gap-x-1">
<MoneyIcon className="size-3" />
{salary}
</Badge>
)}
{posted && (
<Badge variant="outline" className="gap-x-1">
<ClockIcon className="size-3" />
{posted}
</Badge>
)}
</div>
</motion.button>
);
}
function RouteComponent() {
const {
activeFilterChips,
@@ -144,7 +70,7 @@ function RouteComponent() {
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
className="mx-auto flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
>
<MagnifyingGlassIcon className="size-12 text-muted-foreground" weight="light" />
<h2 className="text-lg font-medium">
@@ -230,10 +156,19 @@ function RouteComponent() {
{isPending && jobs.length === 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex animate-pulse flex-col gap-y-3 rounded-md border bg-card p-4">
<div className="h-4 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-5/6 rounded bg-muted" />
<div key={index} className="flex flex-col gap-y-3 rounded-md border bg-card p-4">
<div className="flex items-start gap-x-3">
<Skeleton className="size-10 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
</div>
))}
</div>
@@ -32,10 +32,10 @@ export function ResumeCard({ resume }: ResumeCardProps) {
<ResumeContextMenu resume={resume}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
<motion.div
className="will-change-transform"
whileHover={{ y: -2, scale: 1.005 }}
whileTap={{ scale: 0.998 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
style={{ willChange: "transform" }}
>
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
{match({ isLoading, imageSrc: screenshotData?.url })
@@ -71,8 +71,7 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{ willChange: "opacity" }}
className="absolute inset-0 flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center will-change-[opacity]"
>
<div className="flex items-center justify-center rounded-full bg-popover p-6">
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
@@ -20,7 +20,7 @@ export function GridView({ resumes }: Props) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<CreateResumeCard />
</motion.div>
@@ -30,7 +30,7 @@ export function GridView({ resumes }: Props) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<ImportResumeCard />
</motion.div>
@@ -48,7 +48,7 @@ export function GridView({ resumes }: Props) {
filter: "blur(8px)",
}}
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<ResumeCard resume={resume} />
</motion.div>
@@ -32,11 +32,11 @@ export function ListView({ resumes }: Props) {
return (
<div className="flex flex-col gap-y-1">
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -56,11 +56,11 @@ export function ListView({ resumes }: Props) {
</motion.div>
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -85,11 +85,11 @@ export function ListView({ resumes }: Props) {
<motion.div
layout
key={resume.id}
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<ResumeListItem resume={resume} />
</motion.div>
+29 -19
View File
@@ -11,6 +11,7 @@ import { useMemo } from "react";
import z from "zod";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { orpc } from "@/integrations/orpc/client";
@@ -74,26 +75,35 @@ function RouteComponent() {
<Separator />
<div className="flex items-center gap-x-4">
<Combobox
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: { tags, sort: value as SortOption } });
}}
/>
<div className="flex gap-2">
<Label>
<Trans>Sort by</Trans>
</Label>
<Combobox
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: { tags, sort: value as SortOption } });
}}
/>
</div>
<Combobox
multiple
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
className={cn({ hidden: tagOptions.length === 0 })}
onValueChange={(value) => {
void navigate({ search: { tags: value ?? [], sort } });
}}
/>
<div className={cn("flex gap-2", { hidden: tagOptions.length === 0 })}>
<Label>
<Trans>Filter by</Trans>
</Label>
<Combobox
multiple
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
onValueChange={(value) => {
void navigate({ search: { tags: value ?? [], sort } });
}}
/>
</div>
<Tabs className="ltr:ms-auto rtl:me-auto" value={view} onValueChange={onViewChange}>
<TabsList>
+1 -2
View File
@@ -222,8 +222,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
+3 -5
View File
@@ -67,8 +67,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
@@ -116,12 +115,11 @@ function RouteComponent() {
{apiKeys.map((key, index) => (
<motion.div
key={key.id}
className="flex items-center gap-x-4 py-4"
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
style={{ willChange: "transform, opacity" }}
>
<KeyIcon />
@@ -133,10 +131,10 @@ function RouteComponent() {
</div>
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.03 }}
whileTap={{ scale: 0.96 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
<TrashSimpleIcon />
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { GithubLogoIcon, GoogleLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";
@@ -20,6 +20,7 @@ export function getProviderName(providerId: AuthProvider): string {
.with("credential", () => "Password")
.with("google", () => "Google")
.with("github", () => "GitHub")
.with("linkedin", () => "LinkedIn")
.with("custom", () => "Custom OAuth")
.exhaustive();
}
@@ -32,6 +33,7 @@ export function getProviderIcon(providerId: AuthProvider): ReactNode {
.with("credential", () => <PasswordIcon />)
.with("google", () => <GoogleLogoIcon />)
.with("github", () => <GithubLogoIcon />)
.with("linkedin", () => <LinkedinLogoIcon />)
.with("custom", () => <VaultIcon />)
.exhaustive();
}
@@ -100,7 +102,7 @@ export function useAuthProviderActions() {
/**
* Hook to get enabled social providers for the current user
* Possible values: "credential", "google", "github", "custom"
* Possible values: "credential", "google", "github", "linkedin", "custom"
*/
export function useEnabledProviders() {
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
@@ -30,8 +30,7 @@ export function PasswordSection() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center justify-between gap-x-4"
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
>
<h2 className="flex items-center gap-x-3 text-base font-medium">
<PasswordIcon />
@@ -41,10 +40,10 @@ export function PasswordSection() {
{match(hasPassword)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleUpdatePassword}>
<PencilSimpleLineIcon />
@@ -54,10 +53,10 @@ export function PasswordSection() {
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button
variant="outline"
@@ -38,10 +38,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Separator />
@@ -54,10 +54,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
{match(isConnected)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleUnlink}>
<LinkBreakIcon />
@@ -67,10 +67,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleLink}>
<LinkIcon />
@@ -31,10 +31,10 @@ export function TwoFactorSection() {
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Separator />
@@ -47,10 +47,10 @@ export function TwoFactorSection() {
{match(hasTwoFactor)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleLeftIcon />
@@ -60,10 +60,10 @@ export function TwoFactorSection() {
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleRightIcon />
@@ -28,8 +28,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-4"
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
>
<PasswordSection />
@@ -46,8 +45,10 @@ function RouteComponent() {
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.6} name={enabledProviders.custom} />
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
)}
</motion.div>
</div>
@@ -63,8 +63,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<p className="leading-relaxed">
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
@@ -78,11 +77,10 @@ function RouteComponent() {
/>
<motion.div
className="justify-self-end"
className="justify-self-end will-change-transform"
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
<TrashSimpleIcon />
@@ -27,8 +27,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid gap-1.5">
<Label className="mb-0.5">
+2 -4
View File
@@ -124,8 +124,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
@@ -222,8 +221,7 @@ function RouteComponent() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center gap-x-4 justify-self-end"
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans>Cancel</Trans>