feat(cover): frontend for requesting cover letters
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { House, LayoutTemplate } from "lucide-react";
|
||||
import { LayoutTemplate, LetterText } from "lucide-react";
|
||||
|
||||
const linkClass = { className: "flex items-center gap-2" };
|
||||
const iconProps = { size: 20 };
|
||||
@@ -9,8 +9,8 @@ export default function Header() {
|
||||
<header className="py-3 px-4 flex gap-2 bg-panel text-black justify-between shadow mb-4">
|
||||
<nav className="flex items-center gap-4 font-bold ">
|
||||
<Link to="/" {...linkClass}>
|
||||
<House {...iconProps} />
|
||||
Home
|
||||
<LetterText {...iconProps} />
|
||||
Cover letters
|
||||
</Link>
|
||||
<Link to="/templates" {...linkClass}>
|
||||
<LayoutTemplate {...iconProps} />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
interface Props {
|
||||
query: UseQueryResult<any, Error>;
|
||||
noFound: string;
|
||||
skeleton?: {
|
||||
count?: number;
|
||||
className?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function renderQueryState({
|
||||
query,
|
||||
noFound,
|
||||
skeleton = { count: 5, className: "h-10" },
|
||||
}: Props) {
|
||||
// Render loading
|
||||
if (query.isPending) {
|
||||
const skelets = new Array(skeleton.count).fill(null);
|
||||
return skelets.map((_, i) => <Skeleton className={skeleton.className} key={i} />);
|
||||
}
|
||||
|
||||
// Render error
|
||||
if (query.isError) {
|
||||
return <div className="text-danger font-bold">Error: {query.error.message}</div>;
|
||||
}
|
||||
|
||||
// Render null
|
||||
if (query.data === null) {
|
||||
return <div className="text-primary">No {noFound} found</div>;
|
||||
}
|
||||
|
||||
return null; // Render actual component
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useFieldContext } from "@/hooks/formHook";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
// --- field components ---
|
||||
interface SelectProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
data:
|
||||
| {
|
||||
value: any;
|
||||
name: any;
|
||||
}[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export default function SelectField({ data, label, className = "" }: SelectProps) {
|
||||
// Get field with predefined text type
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
// Render custom field
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
{label && (
|
||||
<label htmlFor={field.name} className="ml-1 mb-2 text-secondary-foreground text-sm">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<Select
|
||||
onValueChange={field.handleChange}
|
||||
onOpenChange={field.handleBlur}
|
||||
defaultValue={field.state.value}
|
||||
>
|
||||
<SelectTrigger className={"w-full"}>
|
||||
<SelectValue placeholder="Template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data?.map((i) => (
|
||||
<SelectItem value={i.value}>{i.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!field.state.meta.isValid && (
|
||||
<span className="text-xs text-danger mt-1">
|
||||
{field.state.meta.errors.map((e) => e.message).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import RichTextEdit from "@/components/forms/RichTextEdit";
|
||||
import SelectField from "@/components/forms/Select";
|
||||
import TextField from "@/components/forms/TextField";
|
||||
import { createFormHookContexts, createFormHook } from "@tanstack/react-form";
|
||||
|
||||
@@ -9,6 +10,7 @@ export const { useAppForm, withForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
RichTextEdit,
|
||||
SelectField,
|
||||
},
|
||||
formComponents: {},
|
||||
fieldContext,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as TemplatesIndexRouteImport } from './routes/templates/index'
|
||||
import { Route as TemplatesCreateRouteImport } from './routes/templates/create'
|
||||
import { Route as CoverCreateRouteImport } from './routes/cover/create'
|
||||
|
||||
const RegisterRoute = RegisterRouteImport.update({
|
||||
id: '/register',
|
||||
@@ -40,11 +41,17 @@ const TemplatesCreateRoute = TemplatesCreateRouteImport.update({
|
||||
path: '/templates/create',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CoverCreateRoute = CoverCreateRouteImport.update({
|
||||
id: '/cover/create',
|
||||
path: '/cover/create',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/cover/create': typeof CoverCreateRoute
|
||||
'/templates/create': typeof TemplatesCreateRoute
|
||||
'/templates': typeof TemplatesIndexRoute
|
||||
}
|
||||
@@ -52,6 +59,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/cover/create': typeof CoverCreateRoute
|
||||
'/templates/create': typeof TemplatesCreateRoute
|
||||
'/templates': typeof TemplatesIndexRoute
|
||||
}
|
||||
@@ -60,19 +68,33 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/cover/create': typeof CoverCreateRoute
|
||||
'/templates/create': typeof TemplatesCreateRoute
|
||||
'/templates/': typeof TemplatesIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/login' | '/register' | '/templates/create' | '/templates'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/cover/create'
|
||||
| '/templates/create'
|
||||
| '/templates'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/login' | '/register' | '/templates/create' | '/templates'
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/cover/create'
|
||||
| '/templates/create'
|
||||
| '/templates'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/cover/create'
|
||||
| '/templates/create'
|
||||
| '/templates/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -81,6 +103,7 @@ export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
RegisterRoute: typeof RegisterRoute
|
||||
CoverCreateRoute: typeof CoverCreateRoute
|
||||
TemplatesCreateRoute: typeof TemplatesCreateRoute
|
||||
TemplatesIndexRoute: typeof TemplatesIndexRoute
|
||||
}
|
||||
@@ -122,6 +145,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TemplatesCreateRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/cover/create': {
|
||||
id: '/cover/create'
|
||||
path: '/cover/create'
|
||||
fullPath: '/cover/create'
|
||||
preLoaderRoute: typeof CoverCreateRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +159,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
RegisterRoute: RegisterRoute,
|
||||
CoverCreateRoute: CoverCreateRoute,
|
||||
TemplatesCreateRoute: TemplatesCreateRoute,
|
||||
TemplatesIndexRoute: TemplatesIndexRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import renderQueryState from "@/components/RenderQueryState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAppForm } from "@/hooks/formHook";
|
||||
import Authorised from "@/layouts/Authorised";
|
||||
import requests from "@/lib/requests";
|
||||
import type { Template } from "@/types/api";
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/cover/create")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const templates = useQuery({
|
||||
queryKey: ["user_templates"],
|
||||
queryFn: () => requests.get<Template[]>("/templates", {}),
|
||||
});
|
||||
const templateState = renderQueryState({
|
||||
query: templates,
|
||||
noFound: "templates",
|
||||
skeleton: { count: 1, className: "h-16" },
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
templateId: "",
|
||||
application: "Paste job application here",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Authorised>
|
||||
<h1 className="text-2xl font-bold text-primary">Create new cover letter</h1>
|
||||
<div className="border p-4 mt-4 rounded-md">
|
||||
{templateState !== null ? (
|
||||
templateState
|
||||
) : (
|
||||
<form.AppField
|
||||
name="templateId"
|
||||
children={(f) => (
|
||||
<f.SelectField
|
||||
data={templates.data?.map((t) => ({ value: t.id, name: t.name }))}
|
||||
label={"Select template for cover letter"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<form.AppField name="application" children={(f) => <f.RichTextEdit />} />
|
||||
</div>
|
||||
|
||||
<Button className="mt-4">Generate cover letter</Button>
|
||||
</Authorised>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import Authorised from "@/layouts/Authorised";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: App,
|
||||
@@ -8,7 +10,15 @@ export const Route = createFileRoute("/")({
|
||||
function App() {
|
||||
return (
|
||||
<Authorised>
|
||||
<h1>Welcome to cover letter</h1>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-primary">0 Cover letters</h1>
|
||||
|
||||
<Link to="/cover/create">
|
||||
<Button icon={<Plus />} variant="secondary">
|
||||
Create new
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Authorised>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,40 +6,21 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Template } from "@/types/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import renderQueryState from "@/components/RenderQueryState";
|
||||
|
||||
export const Route = createFileRoute("/templates/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RenderTemplates({ templates }: { templates: UseQueryResult<null | Template[], Error> }) {
|
||||
// Render loading
|
||||
if (templates.isPending) {
|
||||
const skelets = new Array(5).fill(null);
|
||||
return skelets.map((_, i) => <Skeleton className="h-10" key={i} />);
|
||||
}
|
||||
|
||||
// Render error
|
||||
if (templates.isError) {
|
||||
return <div className="text-danger font-bold">Error: {templates.error.message}</div>;
|
||||
}
|
||||
|
||||
// Render null
|
||||
if (templates.data === null) {
|
||||
return <div className="text-primary">No templates found</div>;
|
||||
}
|
||||
|
||||
return templates.data.map((template, i) => (
|
||||
<div className="flex gap-2 items-center" key={i}>
|
||||
<p className="text-lg">{template.name}</p>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const templates = useQuery({
|
||||
queryKey: ["user_templates"],
|
||||
queryFn: () => requests.get<Template[]>("/templates", {}),
|
||||
});
|
||||
const templatesState = renderQueryState({
|
||||
query: templates,
|
||||
noFound: "templates",
|
||||
});
|
||||
|
||||
return (
|
||||
<Authorised>
|
||||
@@ -54,7 +35,13 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<RenderTemplates templates={templates} />
|
||||
{templatesState !== null
|
||||
? templatesState
|
||||
: templates.data?.map((template, i) => (
|
||||
<div className="flex gap-2 items-center" key={i}>
|
||||
<p className="text-lg">{template.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Authorised>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user