mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 08:54:05 +10:00
📦 v5.0.15 - https://docs.rxresu.me/changelog
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user