Merge pull request #5 from Skrazzo/feat/view-generated-cover-letters

Feat/view generated cover letters
This commit is contained in:
Leons Aleksandrovs
2025-07-13 13:06:49 +03:00
committed by GitHub
18 changed files with 527 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ import (
res "backend/utils/responses"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
@@ -16,7 +17,61 @@ import (
var validate = validator.New()
type CoverGet struct {
Id int `json:"id"`
Name string `json:"name"`
}
func Get(c *gin.Context) {
user, err := jwt.GetUser(c)
if err != nil {
res.NeedsToLogin(c)
return
}
covers, err := cover.Get("user_id = $1 ORDER BY created_at DESC", user.Id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
// Asign only id and name, for efficieny
coverPreviews := make([]CoverGet, len(covers))
for i, cover := range covers {
coverPreviews[i] = CoverGet{
Id: cover.ID,
Name: cover.Name,
}
}
res.Success(c, gin.H{"covers": coverPreviews})
}
func GetID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
user, err := jwt.GetUser(c)
if err != nil {
res.NeedsToLogin(c)
return
}
cover, err := cover.Get("id = $1 AND user_id = $2", id, user.Id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
if len(cover) == 0 {
res.Error(c, "Cover not found", http.StatusNotFound)
return
}
res.Success(c, gin.H{"cover": cover[0]})
}
type CoverPost struct {
@@ -87,8 +142,83 @@ func Post(c *gin.Context) {
res.Success(c, gin.H{"message": "Successfully created " + coverName})
}
type CoverPut struct {
Name string `json:"name" validate:"required,min=1"`
Letter string `json:"letter" validate:"required,min=50"`
}
func Put(c *gin.Context) {
// Get request data
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
var data CoverPut
if err := utils.BindAndValidate(&data, c); err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
user, err := jwt.GetUser(c)
if err != nil {
res.NeedsToLogin(c)
return
}
// Find cover letter in database, verify it exists, and update it
letters, err := cover.Get("user_id = $1 AND id = $2", user.Id, id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
if len(letters) == 0 {
res.Error(c, "Cover letter not found", http.StatusNotFound)
return
}
err = cover.Update(data.Name, data.Letter, id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
res.Success(c, gin.H{"message": "Successfully updated cover letter"})
}
func Delete(c *gin.Context) {
// Get request data
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
user, err := jwt.GetUser(c)
if err != nil {
res.NeedsToLogin(c)
return
}
// Find cover letter in database, verify it exists, and delete it
letters, err := cover.Get("user_id = $1 AND id = $2", user.Id, id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
if len(letters) == 0 {
res.Error(c, "Cover letter not found", http.StatusNotFound)
return
}
err = cover.Delete(id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
res.Success(c, gin.H{"message": "Successfully deleted cover letter"})
}

View File

@@ -69,7 +69,7 @@ func Get(c *gin.Context) {
}
// Get all user templates
templates, err := template.Get("user_id = $1", user.Id)
templates, err := template.Get("user_id = $1 ORDER BY created_at DESC", user.Id)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return

View File

@@ -6,7 +6,7 @@ import (
"time"
)
type Template struct {
type Cover struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Name string `json:"name"`
@@ -14,7 +14,7 @@ type Template struct {
CreatedAt time.Time `json:"created_at"`
}
func Get(where string, args ...any) ([]Template, error) {
func Get(where string, args ...any) ([]Cover, error) {
// Create timeout context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -36,9 +36,9 @@ func Get(where string, args ...any) ([]Template, error) {
defer rows.Close()
// Prepeare results now
var results []Template
var results []Cover
for rows.Next() {
var t Template
var t Cover
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Letter, &t.CreatedAt); err != nil {
return nil, err
}
@@ -61,3 +61,23 @@ func Create(name string, letter string, userId float64) error {
return err
}
func Update(name string, letter string, id int) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
query := `UPDATE cover_letters SET name = $1, letter = $2 WHERE id = $3`
_, err := db.Pool.Exec(ctx, query, name, letter, id)
return err
}
func Delete(id int) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
query := `DELETE FROM cover_letters WHERE id = $1`
_, err := db.Pool.Exec(ctx, query, id)
return err
}

View File

@@ -32,7 +32,11 @@ func SetupRoutes() *gin.Engine {
// Cover letter routes
covers := auth.Group("/cover")
covers.POST("", cover.Post)
covers.GET("", cover.Get) // Get all letters
covers.GET("/:id", cover.GetID) // get single letter
covers.POST("", cover.Post) // create new letter
covers.PUT("/:id", cover.Put) // edit letter
covers.DELETE("/:id", cover.Delete) // delete letter
return r
}

View File

@@ -37,12 +37,12 @@ func GenerateCoverLetter(templateHTML string, jobHTML string) (GeneratedCover, e
}
payload := ChatRequest{
Model: "gpt-4o", // o4-mini
Model: "gpt-4o", // o4-mini
ResponseFormat: &ResponseFormat{Type: "json_object"},
Messages: []ChatMessage{
{
Role: "system",
Content: `You are a helpful assistant that fills out cover letter templates in HTML format and provides a name for it. Replace all <...> tags like <company>, <experience>, etc., with appropriate content based on the job application. You must respond with a JSON object with two keys: "name" for the cover letter title (e.g., "Cover Letter for a Software Engineer"), and "cover" for the filled HTML cover letter.`,
Content: `You are a helpful assistant that fills out cover letter templates in HTML format and provides a name for it. Replace all <...> tags like <company>, <experience>, etc., with appropriate content based on the job application. You must respond with a JSON object with two keys: "name" for the cover letter title (e.g., "Software Engineer at OpenAi"), and "cover" for the filled HTML cover letter.`,
},
{
Role: "user",

View File

@@ -20,6 +20,7 @@
"@tiptap/starter-kit": "^2.25.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.525.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -579,6 +580,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],

View File

@@ -25,6 +25,7 @@
"@tiptap/starter-kit": "^2.25.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.525.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -135,6 +135,8 @@ export default () => {
// Get field with predefined text type
const field = useFieldContext<string>();
if (field.state.value === null) return <div>Loading...</div>;
// Configure editor
const editor = useEditor({
onUpdate: ({ editor }) => field.handleChange(editor.getHTML()),
@@ -150,6 +152,7 @@ export default () => {
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{!field.state.meta.isValid && (
<span className="text-xs text-danger mt-1">
{field.state.meta.errors.map((e) => e.message).join(", ")}

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {

View File

@@ -1,3 +1,3 @@
export default function Container({ children, className = "" }: React.ComponentProps<"div">) {
return <div className={`container mx-auto max-w-6xl px-4 ${className}`}>{children}</div>;
return <div className={`container mx-auto max-w-6xl px-4 mb-16 ${className}`}>{children}</div>;
}

View File

@@ -24,6 +24,11 @@
margin-top: 0;
}
/* Text */
p {
margin-top: 0.5rem;
}
/* Links */
a {
color: var(--editor-accent);

View File

@@ -15,10 +15,18 @@ interface PostProps<T> extends RequestProps<T> {
data: Record<string, any>;
}
interface PutProps<T> extends RequestProps<T> {
data: Record<string, any>;
}
interface GetProps<T> extends RequestProps<T> {
params?: Record<string, any>;
}
interface DeleteProps<T> extends RequestProps<T> {
params?: Record<string, any>;
}
class Requests {
constructor() {}
@@ -54,7 +62,9 @@ class Requests {
props.before?.();
// Get url parameters
const urlParams = props.params ? new URLSearchParams(props.params).toString() : "";
const urlParams = props.params
? new URLSearchParams(props.params).toString()
: "";
// Normalize url
const finalUrl = normalizeLink(`${API_BASE}/${url}${urlParams}`);
@@ -115,6 +125,74 @@ class Requests {
props.finally?.();
}
}
async put<T>(url: string, props: PutProps<T>): Promise<T | void> {
props.before?.();
// Normalize url
const finalUrl = normalizeLink(`${API_BASE}/${url}`);
try {
// Do request
const res = await fetch(finalUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(props.data),
});
// Verify data
const responseData = await this.verifyData<T>(res);
// Otherwise return response data
props.success?.(responseData);
return responseData;
} catch (error) {
const err = error as Error;
// Show notification, and call error callback
toast.error(err.message);
props.error?.(err);
} finally {
props.finally?.();
}
}
async delete<T>(url: string, props: DeleteProps<T>): Promise<T | void> {
// Call before
props.before?.();
// Get url parameters
const urlParams = props.params
? new URLSearchParams(props.params).toString()
: "";
// Normalize url
const finalUrl = normalizeLink(`${API_BASE}/${url}${urlParams}`);
try {
// Do request
const res = await fetch(finalUrl, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
// Verify data
const responseData = await this.verifyData<T>(res);
// Otherwise return response data
props.success?.(responseData);
return responseData;
} catch (error) {
const err = error as Error;
// Show notification, and call error callback
toast.error(err.message);
props.error?.(err);
} finally {
props.finally?.();
}
}
}
export default new Requests();

View File

@@ -15,6 +15,8 @@ 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'
import { Route as CoverCoverIdRouteImport } from './routes/cover/$coverId'
import { Route as CoverEditCoverIdRouteImport } from './routes/cover/edit.$coverId'
const RegisterRoute = RegisterRouteImport.update({
id: '/register',
@@ -46,31 +48,47 @@ const CoverCreateRoute = CoverCreateRouteImport.update({
path: '/cover/create',
getParentRoute: () => rootRouteImport,
} as any)
const CoverCoverIdRoute = CoverCoverIdRouteImport.update({
id: '/cover/$coverId',
path: '/cover/$coverId',
getParentRoute: () => rootRouteImport,
} as any)
const CoverEditCoverIdRoute = CoverEditCoverIdRouteImport.update({
id: '/cover/edit/$coverId',
path: '/cover/edit/$coverId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute
'/templates': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute
'/templates': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute
'/templates/': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -78,34 +96,42 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/register'
| '/cover/$coverId'
| '/cover/create'
| '/templates/create'
| '/templates'
| '/cover/edit/$coverId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/register'
| '/cover/$coverId'
| '/cover/create'
| '/templates/create'
| '/templates'
| '/cover/edit/$coverId'
id:
| '__root__'
| '/'
| '/login'
| '/register'
| '/cover/$coverId'
| '/cover/create'
| '/templates/create'
| '/templates/'
| '/cover/edit/$coverId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
RegisterRoute: typeof RegisterRoute
CoverCoverIdRoute: typeof CoverCoverIdRoute
CoverCreateRoute: typeof CoverCreateRoute
TemplatesCreateRoute: typeof TemplatesCreateRoute
TemplatesIndexRoute: typeof TemplatesIndexRoute
CoverEditCoverIdRoute: typeof CoverEditCoverIdRoute
}
declare module '@tanstack/react-router' {
@@ -152,6 +178,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CoverCreateRouteImport
parentRoute: typeof rootRouteImport
}
'/cover/$coverId': {
id: '/cover/$coverId'
path: '/cover/$coverId'
fullPath: '/cover/$coverId'
preLoaderRoute: typeof CoverCoverIdRouteImport
parentRoute: typeof rootRouteImport
}
'/cover/edit/$coverId': {
id: '/cover/edit/$coverId'
path: '/cover/edit/$coverId'
fullPath: '/cover/edit/$coverId'
preLoaderRoute: typeof CoverEditCoverIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -159,9 +199,11 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute,
CoverCoverIdRoute: CoverCoverIdRoute,
CoverCreateRoute: CoverCreateRoute,
TemplatesCreateRoute: TemplatesCreateRoute,
TemplatesIndexRoute: TemplatesIndexRoute,
CoverEditCoverIdRoute: CoverEditCoverIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,101 @@
import renderQueryState from "@/components/RenderQueryState";
import Authorised from "@/layouts/Authorised";
import requests from "@/lib/requests";
import type { CoverLetter } from "@/types/api";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import "../../editor.css";
import { toPng } from "html-to-image";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import { DownloadIcon, EditIcon, Trash2 } from "lucide-react";
export const Route = createFileRoute("/cover/$coverId")({
component: RouteComponent,
});
function RouteComponent() {
const { coverId } = Route.useParams();
const navigate = useNavigate();
const cover = useQuery({
queryKey: ["cover", coverId],
queryFn: () => requests.get<{ cover: CoverLetter }>(`/cover/${coverId}`, {}),
});
const coverState = renderQueryState({
query: cover,
noFound: "cover letter",
skeleton: {
count: 1,
className: "h-[400px]",
},
});
// Handle png downloads
const coverRef = useRef<HTMLDivElement>(null);
const handleDownload = async () => {
if (coverRef.current === null) return;
const dataUrl = await toPng(coverRef.current, {
cacheBust: true,
pixelRatio: 2,
skipFonts: false,
});
const link = document.createElement("a");
link.download = `${cover.data?.cover.name || "Cover"}.png`;
link.href = dataUrl;
link.click();
};
const handleDelete = async () => {
const a = confirm("Are you sure?");
if (!a) return;
requests.delete(`/cover/${coverId}`, {
success() {
navigate({ to: "/" });
},
});
};
return (
<Authorised>
<div className="flex items-center gap-4 mb-8 md:justify-between">
<h1 className="text-2xl font-semibold">{cover.data?.cover.name || "Loading..."}</h1>
<div className="space-x-2">
<Button
className="hover:bg-danger hover:text-background"
variant="ghost"
onClick={handleDelete}
>
<Trash2 />
</Button>
<Link
to={"/cover/edit/$coverId"}
params={{ coverId: cover.data?.cover.id.toString() || "" }}
>
<Button variant="outline">
<EditIcon />
</Button>
</Link>
<Button onClick={handleDownload}>
<DownloadIcon />
</Button>
</div>
</div>
<div ref={coverRef} className="bg-background p-4 border">
{coverState !== null ? (
coverState
) : (
<div
className="tiptap"
dangerouslySetInnerHTML={{ __html: cover.data?.cover.letter || "" }}
/>
)}
</div>
</Authorised>
);
}

View File

@@ -0,0 +1,85 @@
import renderQueryState from "@/components/RenderQueryState";
import { Button } from "@/components/ui/button";
import { useAppForm } from "@/hooks/formHook";
import Authorised from "@/layouts/Authorised";
import requests from "@/lib/requests";
import type { CoverLetter } from "@/types/api";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod/v4";
export const Route = createFileRoute("/cover/edit/$coverId")({
component: RouteComponent,
});
const editSchema = z.object({
name: z.string().min(1, "Name is required"),
letter: z.string().min(50, "Application is too short"),
});
function RouteComponent() {
const { coverId } = Route.useParams();
const navigate = useNavigate();
const loading = useState(false);
const cover = useQuery({
queryKey: ["cover", coverId],
queryFn: () => requests.get<{ cover: CoverLetter }>(`/cover/${coverId}`, {}),
});
const coverState = renderQueryState({
query: cover,
noFound: "cover letter",
skeleton: {
count: 1,
className: "h-[400px]",
},
});
const edit = useAppForm({
defaultValues: {
name: cover.data?.cover.name || "",
letter: cover.data?.cover.letter || null,
},
validators: {
onBlur: editSchema,
},
onSubmit({ value }) {
requests.put(`/cover/${coverId}`, {
data: value,
before() {
loading[1](true);
},
finally() {
loading[1](false);
},
success() {
navigate({ to: "/cover/$coverId", params: { coverId: coverId } });
},
});
},
});
return (
<Authorised>
<h1 className="text-2xl font-bold text-primary">Edit cover letter</h1>
<div className="mt-4 space-y-4">
<edit.AppField
name="name"
children={(f) => <f.TextField label="Name" placeholder="Your cover letter name" />}
/>
{coverState !== null ? (
coverState
) : (
<edit.AppField name="letter" children={(f) => <f.RichTextEdit />} />
)}
</div>
<Button className="mt-4" onClick={edit.handleSubmit}>
Save
</Button>
</Authorised>
);
}

View File

@@ -2,16 +2,31 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import Authorised from "@/layouts/Authorised";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import requests from "@/lib/requests";
import type { CoverLetterPreview } from "@/types/api";
import renderQueryState from "@/components/RenderQueryState";
export const Route = createFileRoute("/")({
component: App,
});
function App() {
const letters = useQuery({
queryKey: ["cover_letters"],
queryFn: () => requests.get<{ covers: CoverLetterPreview[] }>("/cover", {}),
});
const lettersState = renderQueryState({
query: letters,
noFound: "cover letters",
});
return (
<Authorised>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-primary">0 Cover letters</h1>
<h1 className="text-2xl font-bold text-primary">
{letters.data?.covers.length} Cover letters
</h1>
<Link to="/cover/create">
<Button icon={<Plus />} variant="secondary">
@@ -19,6 +34,21 @@ function App() {
</Button>
</Link>
</div>
<div className="flex flex-col gap-2 mt-4">
{lettersState !== null
? lettersState
: letters.data?.covers.map((l) => (
<Link
className="px-3 py-2 cursor-pointer rounded hover:bg-secondary"
to={"/cover/$coverId"}
params={{ coverId: l.id.toString() }}
key={l.id}
>
<p>{l.name}</p>
</Link>
))}
</div>
</Authorised>
);
}

View File

@@ -1,11 +1,10 @@
import { Button } from "@/components/ui/button";
import Authorised from "@/layouts/Authorised";
import requests from "@/lib/requests";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
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/")({

View File

@@ -27,3 +27,15 @@ export interface Template {
template: string;
created_at: string;
}
// -------- Cover letters --------
export interface CoverLetterPreview {
id: number;
name: string;
}
export interface CoverLetter extends CoverLetterPreview {
user_id: number;
letter: string;
created_at: string;
}