google dork links (#239)
* Add ready tab Google dork links * ui changes * docs * ci fix
This commit is contained in:
parent
432529b581
commit
6a19fff436
@ -36,6 +36,7 @@ It exists to ensure:
|
|||||||
- a consistent path from discovery to tailored output
|
- a consistent path from discovery to tailored output
|
||||||
- clear status transitions across manual and automated workflows
|
- clear status transitions across manual and automated workflows
|
||||||
- predictable regeneration behavior when job data changes
|
- predictable regeneration behavior when job data changes
|
||||||
|
- faster external research from the Ready tab with prebuilt search links for LinkedIn, GitHub, and broader web results
|
||||||
|
|
||||||
## How to use it
|
## How to use it
|
||||||
|
|
||||||
@ -57,6 +58,18 @@ Ghostwriter is available in `discovered` and `ready` job views.
|
|||||||
|
|
||||||
For details, see [Ghostwriter](/docs/next/features/ghostwriter).
|
For details, see [Ghostwriter](/docs/next/features/ghostwriter).
|
||||||
|
|
||||||
|
### Ready tab search links
|
||||||
|
|
||||||
|
In the `ready` view, JobOps can show prebuilt search links based on the current job's employer, title, and skills.
|
||||||
|
|
||||||
|
This enables you to:
|
||||||
|
|
||||||
|
- quickly open Google searches for likely LinkedIn profiles tied to the company and target skills
|
||||||
|
- search GitHub for matching public profiles or repositories without rewriting the query yourself
|
||||||
|
- run a broader web search to gather context before applying
|
||||||
|
|
||||||
|
Open the **search links** row in the Ready summary to reveal the generated links.
|
||||||
|
|
||||||
### Opening documentation from the sidebar
|
### Opening documentation from the sidebar
|
||||||
|
|
||||||
1. Open the sidebar menu.
|
1. Open the sidebar menu.
|
||||||
|
|||||||
@ -57,9 +57,10 @@ These jobs already have tailored PDFs generated for the specific job description
|
|||||||
At this stage:
|
At this stage:
|
||||||
|
|
||||||
1. Open job details.
|
1. Open job details.
|
||||||
2. Optionally enable tracer links for that specific job.
|
2. Open the **search links** row when you want quick external research on LinkedIn, GitHub, or the wider web.
|
||||||
3. Download the tailored PDF.
|
3. Optionally enable tracer links for that specific job.
|
||||||
4. Submit your application externally.
|
4. Download the tailored PDF.
|
||||||
|
5. Submit your application externally.
|
||||||
|
|
||||||
### 5) Mark jobs as applied in JobOps
|
### 5) Mark jobs as applied in JobOps
|
||||||
|
|
||||||
|
|||||||
@ -147,4 +147,44 @@ describe("ReadyPanel", () => {
|
|||||||
screen.queryByTestId("job-details-edit-drawer"),
|
screen.queryByTestId("job-details-edit-drawer"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders descriptive google dork links in the ready summary", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ReadyPanel
|
||||||
|
job={createJob({
|
||||||
|
employer: "HP",
|
||||||
|
title: "Frontend Engineer",
|
||||||
|
skills: "Wolf Security, React, TypeScript",
|
||||||
|
})}
|
||||||
|
onJobUpdated={vi.fn()}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /3 search links/i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedInLink = screen.getByRole("link", {
|
||||||
|
name: "LinkedIn profiles with HP, Wolf Security, and React in them",
|
||||||
|
});
|
||||||
|
expect(linkedInLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`https://www.google.com/search?q=${encodeURIComponent('site:linkedin.com/in "HP" "Wolf Security" "React"')}`,
|
||||||
|
);
|
||||||
|
expect(linkedInLink).toHaveAttribute("target", "_blank");
|
||||||
|
expect(linkedInLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
expect(linkedInLink).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
'site:linkedin.com/in "HP" "Wolf Security" "React"',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,13 +25,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -58,6 +52,8 @@ import { TailorMode } from "./discovered-panel/TailorMode";
|
|||||||
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
|
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
|
||||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||||
import { KbdHint } from "./KbdHint";
|
import { KbdHint } from "./KbdHint";
|
||||||
|
import { ReadySummaryAccordion } from "./ReadySummaryAccordion";
|
||||||
|
import { buildReadyPanelGoogleDorks } from "./ready-panel-google-dorks";
|
||||||
|
|
||||||
type PanelMode = "ready" | "tailor";
|
type PanelMode = "ready" | "tailor";
|
||||||
|
|
||||||
@ -127,6 +123,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const selectedProjectIds = useMemo(() => {
|
const selectedProjectIds = useMemo(() => {
|
||||||
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||||
}, [job?.selectedProjectIds]);
|
}, [job?.selectedProjectIds]);
|
||||||
|
const googleDorks = useMemo(
|
||||||
|
() => (job ? buildReadyPanelGoogleDorks(job) : []),
|
||||||
|
[job],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUndoApplied = useCallback(
|
const handleUndoApplied = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
@ -427,47 +427,66 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─────────────────────────────────────────────────────────────────────
|
|
||||||
APPLICATION KIT SUMMARY
|
|
||||||
Abstract representation of what the PDF contains - verify at a glance
|
|
||||||
───────────────────────────────────────────────────────────────────── */}
|
|
||||||
<div className="flex-1 py-4 space-y-4">
|
<div className="flex-1 py-4 space-y-4">
|
||||||
{/* Job identity - confirm this is the right role */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FitAssessment job={job} />
|
<FitAssessment job={job} />
|
||||||
<TailoredSummary job={job} />
|
<TailoredSummary job={job} />
|
||||||
|
|
||||||
|
{googleDorks.length > 0 ? (
|
||||||
|
<ReadySummaryAccordion
|
||||||
|
icon={ExternalLink}
|
||||||
|
summary={
|
||||||
|
<>
|
||||||
|
{googleDorks.length}{" "}
|
||||||
|
{googleDorks.length === 1 ? "search link" : "search links"}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
value="search-dorks"
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground flex flex-col items-start gap-2">
|
||||||
|
{googleDorks.map((dork) => (
|
||||||
|
<a
|
||||||
|
key={dork.query}
|
||||||
|
href={dork.href}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
title={dork.query}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "link", size: "sm" }),
|
||||||
|
"justify-start w-fit h-fit gap-1 px-0 wrap-break-word",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dork.label}
|
||||||
|
<ExternalLink className="ml-1" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ReadySummaryAccordion>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Project selection - expandable accordion */}
|
{/* Project selection - expandable accordion */}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<ReadySummaryAccordion
|
||||||
<AccordionItem value="projects" className="border-none">
|
icon={FolderKanban}
|
||||||
<AccordionTrigger className="hover:no-underline py-0 data-[state=open]:pb-2">
|
summary={
|
||||||
<div className="flex items-center gap-3 w-full">
|
<>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
|
{selectedProjectIds.length}{" "}
|
||||||
<FolderKanban className="h-4 w-4" />
|
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
|
||||||
</div>
|
selected
|
||||||
<div className="min-w-0 flex-1 text-left">
|
</>
|
||||||
<div className="text-sm font-medium text-foreground leading-tight">
|
}
|
||||||
{selectedProjectIds.length}{" "}
|
value="projects"
|
||||||
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
|
>
|
||||||
selected
|
<ul className="list-disc text-xs text-muted-foreground space-y-1">
|
||||||
</div>
|
{selectedProjectIds.map((id) => {
|
||||||
</div>
|
const name = catalog.find((p) => p.id === id)?.name;
|
||||||
</div>
|
if (!name) return null;
|
||||||
</AccordionTrigger>
|
return <li key={id}>{name}</li>;
|
||||||
<AccordionContent className="pt-1 pl-11">
|
})}
|
||||||
<ul className="list-disc text-xs text-muted-foreground space-y-1">
|
{selectedProjectIds.length === 0 && (
|
||||||
{selectedProjectIds.map((id) => {
|
<li className="list-none italic">No projects selected</li>
|
||||||
const name = catalog.find((p) => p.id === id)?.name;
|
)}
|
||||||
if (!name) return null;
|
</ul>
|
||||||
return <li key={id}>{name}</li>;
|
</ReadySummaryAccordion>
|
||||||
})}
|
|
||||||
{selectedProjectIds.length === 0 && (
|
|
||||||
<li className="list-none italic">No projects selected</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
46
orchestrator/src/client/components/ReadySummaryAccordion.tsx
Normal file
46
orchestrator/src/client/components/ReadySummaryAccordion.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
interface ReadySummaryAccordionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
summary: React.ReactNode;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadySummaryAccordion: React.FC<ReadySummaryAccordionProps> = ({
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
summary,
|
||||||
|
value,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value={value} className="border-none">
|
||||||
|
<AccordionTrigger className="cursor-pointer rounded-xl border border-border/40 px-2 py-1 hover:bg-muted/50 hover:no-underline data-[state=open]:rounded-b-none data-[state=open]:bg-muted/10 data-[state=open]:pb-2">
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="text-sm font-medium text-foreground leading-tight">
|
||||||
|
{summary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent className="rounded-b-xl border border-border/40 bg-muted/10 pt-4 pl-13">
|
||||||
|
{children}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildReadyPanelGoogleDorks } from "./ready-panel-google-dorks";
|
||||||
|
|
||||||
|
describe("buildReadyPanelGoogleDorks", () => {
|
||||||
|
it("returns three links from employer, title, and skills", () => {
|
||||||
|
const links = buildReadyPanelGoogleDorks(
|
||||||
|
createJob({
|
||||||
|
employer: "HP",
|
||||||
|
title: "Frontend Engineer",
|
||||||
|
skills: "Wolf Security, React, TypeScript",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toHaveLength(3);
|
||||||
|
expect(links[0]).toMatchObject({
|
||||||
|
query: 'site:linkedin.com/in "HP" "Wolf Security" "React"',
|
||||||
|
label: "LinkedIn profiles with HP, Wolf Security, and React in them",
|
||||||
|
});
|
||||||
|
expect(links[1]).toMatchObject({
|
||||||
|
query: 'site:github.com "HP" "Wolf Security" "React"',
|
||||||
|
label: "GitHub pages with HP, Wolf Security, and React in them",
|
||||||
|
});
|
||||||
|
expect(links[2]).toMatchObject({
|
||||||
|
query: '"HP" "Frontend Engineer" "Wolf Security"',
|
||||||
|
label:
|
||||||
|
"Web results with HP, Frontend Engineer, and Wolf Security in them",
|
||||||
|
});
|
||||||
|
expect(links[0]?.href).toContain(
|
||||||
|
encodeURIComponent('site:linkedin.com/in "HP" "Wolf Security" "React"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to tailored skills when raw skills are absent", () => {
|
||||||
|
const links = buildReadyPanelGoogleDorks(
|
||||||
|
createJob({
|
||||||
|
employer: "Acme",
|
||||||
|
title: "Backend Engineer",
|
||||||
|
skills: null,
|
||||||
|
tailoredSkills: JSON.stringify(["Node.js", "TypeScript"]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links[0]?.query).toBe(
|
||||||
|
'site:linkedin.com/in "Acme" "Node.js" "TypeScript"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates repeated keywords and excludes employer matches", () => {
|
||||||
|
const links = buildReadyPanelGoogleDorks(
|
||||||
|
createJob({
|
||||||
|
employer: "Acme",
|
||||||
|
skills: "Acme, React, react, TypeScript, TypeScript",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links[0]?.query).toBe(
|
||||||
|
'site:linkedin.com/in "Acme" "React" "TypeScript"',
|
||||||
|
);
|
||||||
|
expect(links[1]?.query).toBe('site:github.com "Acme" "React" "TypeScript"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no links when employer and usable keywords are missing", () => {
|
||||||
|
const links = buildReadyPanelGoogleDorks(
|
||||||
|
createJob({
|
||||||
|
employer: "",
|
||||||
|
skills: null,
|
||||||
|
tailoredSkills: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
orchestrator/src/client/components/ready-panel-google-dorks.ts
Normal file
122
orchestrator/src/client/components/ready-panel-google-dorks.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import type { Job } from "@shared/types.js";
|
||||||
|
|
||||||
|
export interface ReadyPanelGoogleDorkLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRawSkills(skills: string): string[] {
|
||||||
|
return skills
|
||||||
|
.split(/[,\n|]+/g)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTailoredSkills(raw: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeywordTerms(job: Job): string[] {
|
||||||
|
const employer = job.employer.trim().toLowerCase();
|
||||||
|
const rawTerms = job.skills
|
||||||
|
? splitRawSkills(job.skills)
|
||||||
|
: job.tailoredSkills
|
||||||
|
? parseTailoredSkills(job.tailoredSkills)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const terms: string[] = [];
|
||||||
|
|
||||||
|
for (const term of rawTerms) {
|
||||||
|
const normalized = term.toLowerCase();
|
||||||
|
if (normalized === employer || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
terms.push(term);
|
||||||
|
if (terms.length === 2) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteTerms(terms: string[]): string {
|
||||||
|
return terms.map((term) => `"${term}"`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTermList(terms: string[]): string {
|
||||||
|
if (terms.length === 0) return "";
|
||||||
|
if (terms.length === 1) return terms[0];
|
||||||
|
if (terms.length === 2) return `${terms[0]} and ${terms[1]}`;
|
||||||
|
return `${terms.slice(0, -1).join(", ")}, and ${terms.at(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDork(
|
||||||
|
prefix: "LinkedIn profiles" | "GitHub pages" | "Web results",
|
||||||
|
queryTerms: string[],
|
||||||
|
): ReadyPanelGoogleDorkLink | null {
|
||||||
|
if (queryTerms.length === 0) return null;
|
||||||
|
|
||||||
|
const query = quoteTerms(queryTerms);
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
href: `https://www.google.com/search?q=${encodeURIComponent(query)}`,
|
||||||
|
label: `${prefix} with ${formatTermList(queryTerms)} in them`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReadyPanelGoogleDorks(
|
||||||
|
job: Job,
|
||||||
|
): ReadyPanelGoogleDorkLink[] {
|
||||||
|
const employer = job.employer.trim();
|
||||||
|
const title = job.title.trim();
|
||||||
|
const keywords = getKeywordTerms(job);
|
||||||
|
|
||||||
|
if (!employer && keywords.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedinTerms = [employer, ...keywords].filter(Boolean);
|
||||||
|
const githubTerms = [employer, ...keywords].filter(Boolean);
|
||||||
|
const webTerms = [employer, title, keywords[0]].filter(Boolean);
|
||||||
|
|
||||||
|
const links: ReadyPanelGoogleDorkLink[] = [];
|
||||||
|
|
||||||
|
const linkedinQuery =
|
||||||
|
linkedinTerms.length > 0
|
||||||
|
? `site:linkedin.com/in ${quoteTerms(linkedinTerms)}`
|
||||||
|
: "";
|
||||||
|
if (linkedinQuery) {
|
||||||
|
links.push({
|
||||||
|
query: linkedinQuery,
|
||||||
|
href: `https://www.google.com/search?q=${encodeURIComponent(linkedinQuery)}`,
|
||||||
|
label: `LinkedIn profiles with ${formatTermList(linkedinTerms)} in them`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubQuery =
|
||||||
|
githubTerms.length > 0 ? `site:github.com ${quoteTerms(githubTerms)}` : "";
|
||||||
|
if (githubQuery) {
|
||||||
|
links.push({
|
||||||
|
query: githubQuery,
|
||||||
|
href: `https://www.google.com/search?q=${encodeURIComponent(githubQuery)}`,
|
||||||
|
label: `GitHub pages with ${formatTermList(githubTerms)} in them`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const webLink = buildDork("Web results", webTerms);
|
||||||
|
if (webLink) {
|
||||||
|
links.push(webLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ const buttonVariants = cva(
|
|||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-foreground underline-offset-4 hover:underline py-0 h-auto",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user