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
+130
View File
@@ -9,6 +9,7 @@ import (
res "backend/utils/responses" res "backend/utils/responses"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -16,7 +17,61 @@ import (
var validate = validator.New() var validate = validator.New()
type CoverGet struct {
Id int `json:"id"`
Name string `json:"name"`
}
func Get(c *gin.Context) { 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 { type CoverPost struct {
@@ -87,8 +142,83 @@ func Post(c *gin.Context) {
res.Success(c, gin.H{"message": "Successfully created " + coverName}) 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) { 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) { 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"})
} }
+1 -1
View File
@@ -69,7 +69,7 @@ func Get(c *gin.Context) {
} }
// Get all user templates // 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 { if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError) res.Error(c, err.Error(), http.StatusInternalServerError)
return return
+24 -4
View File
@@ -6,7 +6,7 @@ import (
"time" "time"
) )
type Template struct { type Cover struct {
ID int `json:"id"` ID int `json:"id"`
UserID int `json:"user_id"` UserID int `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
@@ -14,7 +14,7 @@ type Template struct {
CreatedAt time.Time `json:"created_at"` 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 // Create timeout context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
@@ -36,9 +36,9 @@ func Get(where string, args ...any) ([]Template, error) {
defer rows.Close() defer rows.Close()
// Prepeare results now // Prepeare results now
var results []Template var results []Cover
for rows.Next() { 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 { if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Letter, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
@@ -61,3 +61,23 @@ func Create(name string, letter string, userId float64) error {
return err 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
}
+5 -1
View File
@@ -32,7 +32,11 @@ func SetupRoutes() *gin.Engine {
// Cover letter routes // Cover letter routes
covers := auth.Group("/cover") 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 return r
} }
+2 -2
View File
@@ -37,12 +37,12 @@ func GenerateCoverLetter(templateHTML string, jobHTML string) (GeneratedCover, e
} }
payload := ChatRequest{ payload := ChatRequest{
Model: "gpt-4o", // o4-mini Model: "gpt-4o", // o4-mini
ResponseFormat: &ResponseFormat{Type: "json_object"}, ResponseFormat: &ResponseFormat{Type: "json_object"},
Messages: []ChatMessage{ Messages: []ChatMessage{
{ {
Role: "system", 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", Role: "user",
+3
View File
@@ -20,6 +20,7 @@
"@tiptap/starter-kit": "^2.25.0", "@tiptap/starter-kit": "^2.25.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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-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=="], "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=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+1
View File
@@ -25,6 +25,7 @@
"@tiptap/starter-kit": "^2.25.0", "@tiptap/starter-kit": "^2.25.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -135,6 +135,8 @@ export default () => {
// Get field with predefined text type // Get field with predefined text type
const field = useFieldContext<string>(); const field = useFieldContext<string>();
if (field.state.value === null) return <div>Loading...</div>;
// Configure editor // Configure editor
const editor = useEditor({ const editor = useEditor({
onUpdate: ({ editor }) => field.handleChange(editor.getHTML()), onUpdate: ({ editor }) => field.handleChange(editor.getHTML()),
@@ -150,6 +152,7 @@ export default () => {
<MenuBar editor={editor} /> <MenuBar editor={editor} />
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div> </div>
{!field.state.meta.isValid && ( {!field.state.meta.isValid && (
<span className="text-xs text-danger mt-1"> <span className="text-xs text-danger mt-1">
{field.state.meta.errors.map((e) => e.message).join(", ")} {field.state.meta.errors.map((e) => e.message).join(", ")}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
+1 -1
View File
@@ -1,3 +1,3 @@
export default function Container({ children, className = "" }: React.ComponentProps<"div">) { 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>;
} }
+5
View File
@@ -24,6 +24,11 @@
margin-top: 0; margin-top: 0;
} }
/* Text */
p {
margin-top: 0.5rem;
}
/* Links */ /* Links */
a { a {
color: var(--editor-accent); color: var(--editor-accent);
+79 -1
View File
@@ -15,10 +15,18 @@ interface PostProps<T> extends RequestProps<T> {
data: Record<string, any>; data: Record<string, any>;
} }
interface PutProps<T> extends RequestProps<T> {
data: Record<string, any>;
}
interface GetProps<T> extends RequestProps<T> { interface GetProps<T> extends RequestProps<T> {
params?: Record<string, any>; params?: Record<string, any>;
} }
interface DeleteProps<T> extends RequestProps<T> {
params?: Record<string, any>;
}
class Requests { class Requests {
constructor() {} constructor() {}
@@ -54,7 +62,9 @@ class Requests {
props.before?.(); props.before?.();
// Get url parameters // Get url parameters
const urlParams = props.params ? new URLSearchParams(props.params).toString() : ""; const urlParams = props.params
? new URLSearchParams(props.params).toString()
: "";
// Normalize url // Normalize url
const finalUrl = normalizeLink(`${API_BASE}/${url}${urlParams}`); const finalUrl = normalizeLink(`${API_BASE}/${url}${urlParams}`);
@@ -115,6 +125,74 @@ class Requests {
props.finally?.(); 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(); export default new Requests();
+42
View File
@@ -15,6 +15,8 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as TemplatesIndexRouteImport } from './routes/templates/index' import { Route as TemplatesIndexRouteImport } from './routes/templates/index'
import { Route as TemplatesCreateRouteImport } from './routes/templates/create' import { Route as TemplatesCreateRouteImport } from './routes/templates/create'
import { Route as CoverCreateRouteImport } from './routes/cover/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({ const RegisterRoute = RegisterRouteImport.update({
id: '/register', id: '/register',
@@ -46,31 +48,47 @@ const CoverCreateRoute = CoverCreateRouteImport.update({
path: '/cover/create', path: '/cover/create',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute '/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute '/templates/create': typeof TemplatesCreateRoute
'/templates': typeof TemplatesIndexRoute '/templates': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute '/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute '/templates/create': typeof TemplatesCreateRoute
'/templates': typeof TemplatesIndexRoute '/templates': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/register': typeof RegisterRoute '/register': typeof RegisterRoute
'/cover/$coverId': typeof CoverCoverIdRoute
'/cover/create': typeof CoverCreateRoute '/cover/create': typeof CoverCreateRoute
'/templates/create': typeof TemplatesCreateRoute '/templates/create': typeof TemplatesCreateRoute
'/templates/': typeof TemplatesIndexRoute '/templates/': typeof TemplatesIndexRoute
'/cover/edit/$coverId': typeof CoverEditCoverIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -78,34 +96,42 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/register' | '/register'
| '/cover/$coverId'
| '/cover/create' | '/cover/create'
| '/templates/create' | '/templates/create'
| '/templates' | '/templates'
| '/cover/edit/$coverId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/login' | '/login'
| '/register' | '/register'
| '/cover/$coverId'
| '/cover/create' | '/cover/create'
| '/templates/create' | '/templates/create'
| '/templates' | '/templates'
| '/cover/edit/$coverId'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/login' | '/login'
| '/register' | '/register'
| '/cover/$coverId'
| '/cover/create' | '/cover/create'
| '/templates/create' | '/templates/create'
| '/templates/' | '/templates/'
| '/cover/edit/$coverId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
RegisterRoute: typeof RegisterRoute RegisterRoute: typeof RegisterRoute
CoverCoverIdRoute: typeof CoverCoverIdRoute
CoverCreateRoute: typeof CoverCreateRoute CoverCreateRoute: typeof CoverCreateRoute
TemplatesCreateRoute: typeof TemplatesCreateRoute TemplatesCreateRoute: typeof TemplatesCreateRoute
TemplatesIndexRoute: typeof TemplatesIndexRoute TemplatesIndexRoute: typeof TemplatesIndexRoute
CoverEditCoverIdRoute: typeof CoverEditCoverIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -152,6 +178,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CoverCreateRouteImport preLoaderRoute: typeof CoverCreateRouteImport
parentRoute: typeof rootRouteImport 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, IndexRoute: IndexRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
RegisterRoute: RegisterRoute, RegisterRoute: RegisterRoute,
CoverCoverIdRoute: CoverCoverIdRoute,
CoverCreateRoute: CoverCreateRoute, CoverCreateRoute: CoverCreateRoute,
TemplatesCreateRoute: TemplatesCreateRoute, TemplatesCreateRoute: TemplatesCreateRoute,
TemplatesIndexRoute: TemplatesIndexRoute, TemplatesIndexRoute: TemplatesIndexRoute,
CoverEditCoverIdRoute: CoverEditCoverIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+101
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>
);
}
@@ -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>
);
}
+31 -1
View File
@@ -2,16 +2,31 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import Authorised from "@/layouts/Authorised"; import Authorised from "@/layouts/Authorised";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; 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("/")({ export const Route = createFileRoute("/")({
component: App, component: App,
}); });
function App() { function App() {
const letters = useQuery({
queryKey: ["cover_letters"],
queryFn: () => requests.get<{ covers: CoverLetterPreview[] }>("/cover", {}),
});
const lettersState = renderQueryState({
query: letters,
noFound: "cover letters",
});
return ( return (
<Authorised> <Authorised>
<div className="flex justify-between items-center"> <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"> <Link to="/cover/create">
<Button icon={<Plus />} variant="secondary"> <Button icon={<Plus />} variant="secondary">
@@ -19,6 +34,21 @@ function App() {
</Button> </Button>
</Link> </Link>
</div> </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> </Authorised>
); );
} }
+1 -2
View File
@@ -1,11 +1,10 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Authorised from "@/layouts/Authorised"; import Authorised from "@/layouts/Authorised";
import requests from "@/lib/requests"; 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 { createFileRoute, Link } from "@tanstack/react-router";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { Template } from "@/types/api"; import type { Template } from "@/types/api";
import { Skeleton } from "@/components/ui/skeleton";
import renderQueryState from "@/components/RenderQueryState"; import renderQueryState from "@/components/RenderQueryState";
export const Route = createFileRoute("/templates/")({ export const Route = createFileRoute("/templates/")({
+12
View File
@@ -27,3 +27,15 @@ export interface Template {
template: string; template: string;
created_at: 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;
}