feat(cover): frontend for requesting cover letters

This commit is contained in:
Leons Aleksandrovs
2025-07-10 22:48:01 +03:00
parent 88deee3ea6
commit 6cf30698b0
11 changed files with 465 additions and 32 deletions
+3 -3
View File
@@ -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
}
+52
View File
@@ -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>
);
}
+183
View File
@@ -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,
}
+2
View File
@@ -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,
+33 -2
View File
@@ -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,
}
+59
View File
@@ -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>
);
}
+12 -2
View File
@@ -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>
);
}
+12 -25
View File
@@ -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>
);