) {
return (
)
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
)
@@ -75,7 +75,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
index 89169058d0..ab73fda055 100644
--- a/frontend/src/components/ui/input.tsx
+++ b/frontend/src/components/ui/input.tsx
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "file:text-foreground placeholder:text-muted-foreground/90 selection:bg-primary selection:text-primary-foreground border-input h-[var(--control-height)] w-full min-w-0 rounded-[var(--radius-control)] border bg-[var(--surface-input)] px-3.5 py-2 text-base shadow-[var(--shadow-elev-1)] transition-[color,background-color,border-color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
diff --git a/frontend/src/components/ui/loading-button.tsx b/frontend/src/components/ui/loading-button.tsx
index 4ff14dd114..2c03369fb7 100644
--- a/frontend/src/components/ui/loading-button.tsx
+++ b/frontend/src/components/ui/loading-button.tsx
@@ -4,27 +4,27 @@ import { Loader2 } from "lucide-react"
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",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius-control)] border border-transparent text-sm font-medium tracking-[0.01em] transition-[color,background-color,border-color,box-shadow,transform] duration-200 ease-out 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: {
default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ "bg-primary text-primary-foreground shadow-[var(--shadow-elev-1)] hover:-translate-y-px hover:bg-primary/92 hover:shadow-[var(--shadow-elev-2)]",
destructive:
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "bg-destructive text-white shadow-[var(--shadow-elev-1)] hover:-translate-y-px hover:bg-destructive/92 hover:shadow-[var(--shadow-elev-2)] focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ "border-border/80 bg-background/95 shadow-[var(--shadow-elev-1)] hover:-translate-y-px hover:bg-accent/80 hover:text-accent-foreground hover:shadow-[var(--shadow-elev-2)] dark:border-input dark:bg-[var(--surface-input)] dark:hover:bg-input/60",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ "bg-secondary text-secondary-foreground shadow-[var(--shadow-elev-1)] hover:-translate-y-px hover:bg-secondary/88 hover:shadow-[var(--shadow-elev-2)]",
ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "shadow-none hover:bg-accent/70 hover:text-accent-foreground dark:hover:bg-accent/60",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
+ default: "h-[var(--control-height)] px-4 py-2 has-[>svg]:px-3",
+ sm: "h-[var(--control-height-sm)] gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-[var(--control-height-lg)] px-6 has-[>svg]:px-4",
+ icon: "size-[var(--control-height)]",
},
},
defaultVariants: {
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index 7ccc795c48..e1c8df44ed 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router"
import {
type Body_login_login_access_token as AccessToken,
LoginService,
+ OpenAPI,
type UserPublic,
type UserRegister,
UsersService,
@@ -45,6 +46,28 @@ const useAuth = () => {
localStorage.setItem("access_token", response.access_token)
}
+ const loginWithGoogleIdToken = async (idToken: string) => {
+ const apiBase = OpenAPI.BASE.replace(/\/$/, "")
+ const response = await fetch(`${apiBase}/api/v1/login/google`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id_token: idToken }),
+ })
+
+ const payload = (await response.json().catch(() => ({}))) as {
+ access_token?: string
+ detail?: string
+ }
+
+ if (!response.ok || !payload.access_token) {
+ throw new Error(payload.detail || "Google login failed")
+ }
+
+ localStorage.setItem("access_token", payload.access_token)
+ }
+
const loginMutation = useMutation({
mutationFn: login,
onSuccess: () => {
@@ -53,6 +76,18 @@ const useAuth = () => {
onError: handleError.bind(showErrorToast),
})
+ const googleLoginMutation = useMutation({
+ mutationFn: loginWithGoogleIdToken,
+ onSuccess: () => {
+ navigate({ to: "/" })
+ },
+ onError: (error) => {
+ showErrorToast(
+ error instanceof Error ? error.message : "Google login failed",
+ )
+ },
+ })
+
const logout = () => {
localStorage.removeItem("access_token")
navigate({ to: "/login" })
@@ -61,6 +96,7 @@ const useAuth = () => {
return {
signUpMutation,
loginMutation,
+ googleLoginMutation,
logout,
user,
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 47e56960ad..92eab8484d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -43,6 +43,20 @@
:root {
--radius: 0.625rem;
+ --radius-control: 0.85rem;
+ --radius-panel: 1.05rem;
+ --radius-overlay: 1.1rem;
+ --space-card-x: 1.1rem;
+ --space-card-y: 1rem;
+ --control-height: 2.5rem;
+ --control-height-sm: 2.125rem;
+ --control-height-lg: 2.875rem;
+ --shadow-elev-1:
+ 0 1px 2px rgb(15 23 42 / 0.05), 0 8px 18px rgb(15 23 42 / 0.05);
+ --shadow-elev-2:
+ 0 3px 10px rgb(15 23 42 / 0.08), 0 20px 38px rgb(15 23 42 / 0.09);
+ --surface-input: oklch(0.995 0 0);
+ --surface-card-muted: oklch(0.985 0.003 200);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -77,6 +91,10 @@
}
.dark {
+ --surface-input: oklch(0.23 0 0);
+ --surface-card-muted: oklch(0.24 0.004 200);
+ --shadow-elev-1: 0 1px 2px rgb(0 0 0 / 0.22), 0 10px 28px rgb(0 0 0 / 0.22);
+ --shadow-elev-2: 0 6px 14px rgb(0 0 0 / 0.28), 0 24px 48px rgb(0 0 0 / 0.32);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -115,7 +133,19 @@
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground;
+ @apply bg-background text-foreground antialiased;
+ background-image:
+ radial-gradient(
+ 1200px 420px at 12% -8%,
+ color-mix(in oklab, var(--primary) 10%, transparent),
+ transparent 58%
+ ),
+ radial-gradient(
+ 900px 380px at 100% 0%,
+ color-mix(in oklab, var(--foreground) 3%, transparent),
+ transparent 62%
+ );
+ background-attachment: fixed;
}
button,
[role="button"] {
diff --git a/frontend/src/lib/googleIdentity.ts b/frontend/src/lib/googleIdentity.ts
new file mode 100644
index 0000000000..e36b92a15a
--- /dev/null
+++ b/frontend/src/lib/googleIdentity.ts
@@ -0,0 +1,98 @@
+type GoogleCredentialResponse = {
+ credential?: string
+}
+
+type GoogleAccountsId = {
+ initialize: (config: {
+ client_id: string
+ callback: (response: GoogleCredentialResponse) => void
+ }) => void
+ renderButton: (
+ element: HTMLElement,
+ options: Record
,
+ ) => void
+}
+
+type GoogleWindow = Window & {
+ google?: {
+ accounts?: {
+ id?: GoogleAccountsId
+ }
+ }
+}
+
+let googleScriptPromise: Promise | null = null
+
+export const loadGoogleIdentityScript = () => {
+ if (typeof window === "undefined") {
+ return Promise.reject(
+ new Error("Google Identity is only available in browser"),
+ )
+ }
+
+ const existing = (window as GoogleWindow).google?.accounts?.id
+ if (existing) return Promise.resolve()
+
+ if (googleScriptPromise) return googleScriptPromise
+
+ googleScriptPromise = new Promise((resolve, reject) => {
+ const existingScript = document.querySelector(
+ 'script[src="https://accounts.google.com/gsi/client"]',
+ )
+ if (existingScript) {
+ existingScript.addEventListener("load", () => resolve(), { once: true })
+ existingScript.addEventListener(
+ "error",
+ () => reject(new Error("Failed to load Google Identity script")),
+ { once: true },
+ )
+ return
+ }
+
+ const script = document.createElement("script")
+ script.src = "https://accounts.google.com/gsi/client"
+ script.async = true
+ script.defer = true
+ script.onload = () => resolve()
+ script.onerror = () =>
+ reject(new Error("Failed to load Google Identity script"))
+ document.head.appendChild(script)
+ })
+
+ return googleScriptPromise
+}
+
+export const renderGoogleSignInButton = async ({
+ container,
+ clientId,
+ onCredential,
+}: {
+ container: HTMLElement
+ clientId: string
+ onCredential: (idToken: string) => void
+}) => {
+ await loadGoogleIdentityScript()
+
+ const googleId = (window as GoogleWindow).google?.accounts?.id
+ if (!googleId) {
+ throw new Error("Google Identity SDK not available after script load")
+ }
+
+ googleId.initialize({
+ client_id: clientId,
+ callback: (response) => {
+ if (response.credential) {
+ onCredential(response.credential)
+ }
+ },
+ })
+
+ container.innerHTML = ""
+ googleId.renderButton(container, {
+ theme: "outline",
+ size: "large",
+ text: "signin_with",
+ shape: "rectangular",
+ width: Math.max(container.clientWidth || 320, 240),
+ })
+}
diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts
new file mode 100644
index 0000000000..ac2422db65
--- /dev/null
+++ b/frontend/src/lib/pdf.ts
@@ -0,0 +1,76 @@
+function sanitizeFilename(input: string): string {
+ return input
+ .trim()
+ .replace(/[\\/:*?"<>|]+/g, "-")
+ .replace(/\s+/g, " ")
+ .slice(0, 80)
+}
+
+export async function downloadTextAsPdf(params: {
+ title: string
+ body: string
+ subtitle?: string
+}) {
+ const { jsPDF } = await import("jspdf")
+ const { title, body, subtitle } = params
+ const doc = new jsPDF({ unit: "pt", format: "a4" })
+
+ const marginX = 48
+ const marginTop = 56
+ const marginBottom = 48
+ const pageWidth = doc.internal.pageSize.getWidth()
+ const pageHeight = doc.internal.pageSize.getHeight()
+ const usableWidth = pageWidth - marginX * 2
+ const maxY = pageHeight - marginBottom
+
+ let y = marginTop
+
+ const ensureSpace = (height: number) => {
+ if (y + height <= maxY) {
+ return
+ }
+ doc.addPage()
+ y = marginTop
+ }
+
+ doc.setFont("helvetica", "bold")
+ doc.setFontSize(16)
+ ensureSpace(22)
+ doc.text(title || "Generation", marginX, y)
+ y += 24
+
+ if (subtitle) {
+ doc.setFont("helvetica", "normal")
+ doc.setFontSize(10)
+ doc.setTextColor(100)
+ ensureSpace(14)
+ doc.text(subtitle, marginX, y)
+ y += 20
+ doc.setTextColor(0)
+ }
+
+ doc.setFont("helvetica", "normal")
+ doc.setFontSize(11)
+
+ const paragraphs = body.split(/\r?\n/)
+ for (const paragraph of paragraphs) {
+ if (!paragraph.trim()) {
+ ensureSpace(12)
+ y += 12
+ continue
+ }
+
+ const lines = doc.splitTextToSize(paragraph, usableWidth) as string[]
+ for (const line of lines) {
+ ensureSpace(14)
+ doc.text(line, marginX, y)
+ y += 14
+ }
+ ensureSpace(6)
+ y += 6
+ }
+
+ const filenameBase =
+ sanitizeFilename(title || "generation-output") || "generation-output"
+ doc.save(`${filenameBase}.pdf`)
+}
diff --git a/frontend/src/lib/templateMvpApi.ts b/frontend/src/lib/templateMvpApi.ts
new file mode 100644
index 0000000000..853ac8ad29
--- /dev/null
+++ b/frontend/src/lib/templateMvpApi.ts
@@ -0,0 +1,317 @@
+export type TemplateCategory = "cover_letter" | "email" | "proposal" | "other"
+export type TemplateLanguage = "fr" | "en" | "zh" | "other"
+export type TemplateVariableType = "text" | "list"
+
+export interface TemplateVariableConfig {
+ required: boolean
+ type: TemplateVariableType
+ description: string
+ example: unknown
+ default: unknown
+}
+
+export interface TemplateVersion {
+ id: string
+ template_id: string
+ version: number
+ content: string
+ variables_schema: Record
+ created_at: string | null
+ created_by: string
+}
+
+export interface TemplateSummary {
+ id: string
+ user_id: string
+ name: string
+ category: TemplateCategory
+ language: TemplateLanguage
+ tags: string[]
+ is_archived: boolean
+ created_at: string | null
+ updated_at: string | null
+ versions_count: number
+ latest_version_number: number | null
+}
+
+export interface Template {
+ id: string
+ user_id: string
+ name: string
+ category: TemplateCategory
+ language: TemplateLanguage
+ tags: string[]
+ is_archived: boolean
+ created_at: string | null
+ updated_at: string | null
+ versions_count: number
+ latest_version: TemplateVersion | null
+}
+
+export interface TemplatesResponse {
+ data: TemplateSummary[]
+ count: number
+}
+
+export interface TemplateVersionsResponse {
+ data: TemplateVersion[]
+ count: number
+}
+
+export interface ExtractVariablesResponse {
+ values: Record
+ missing_required: string[]
+ confidence: Record
+ notes: Record
+}
+
+export interface Generation {
+ id: string
+ user_id: string
+ template_id: string
+ template_version_id: string
+ title: string
+ input_text: string
+ extracted_values: Record
+ output_text: string
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface GenerationsResponse {
+ data: Generation[]
+ count: number
+}
+
+export interface RecentTemplate {
+ template_id: string
+ template_name: string
+ category: TemplateCategory
+ language: TemplateLanguage
+ last_used_at: string
+ usage_count: number
+}
+
+export interface RecentTemplatesResponse {
+ data: RecentTemplate[]
+ count: number
+}
+
+export interface CreateTemplatePayload {
+ name: string
+ category: TemplateCategory
+ language: TemplateLanguage
+ tags: string[]
+}
+
+export interface UpdateTemplatePayload {
+ name?: string
+ category?: TemplateCategory
+ language?: TemplateLanguage
+ tags?: string[]
+ is_archived?: boolean
+}
+
+export interface CreateTemplateVersionPayload {
+ content: string
+ variables_schema: Record
+}
+
+export interface ExtractVariablesPayload {
+ template_version_id: string
+ input_text: string
+ profile_context?: Record
+}
+
+export interface RenderTemplatePayload {
+ template_version_id: string
+ values: Record
+ style?: {
+ tone?: string
+ length?: string
+ }
+}
+
+export interface CreateGenerationPayload {
+ template_id: string
+ template_version_id: string
+ title: string
+ input_text: string
+ extracted_values: Record
+ output_text: string
+}
+
+export interface UpdateGenerationPayload {
+ title?: string
+ extracted_values?: Record
+ output_text?: string
+}
+
+const API_BASE = `${import.meta.env.VITE_API_URL.replace(/\/$/, "")}/api/v1`
+
+async function apiRequest(path: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers)
+ const token = localStorage.getItem("access_token")
+ if (token) {
+ headers.set("Authorization", `Bearer ${token}`)
+ }
+
+ const response = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ headers,
+ })
+
+ if (!response.ok) {
+ let detail = "Request failed"
+ try {
+ const body = (await response.json()) as { detail?: unknown }
+ if (typeof body.detail === "string") {
+ detail = body.detail
+ }
+ } catch {
+ // Ignore JSON parse errors and keep fallback message.
+ }
+ throw new Error(detail)
+ }
+
+ return (await response.json()) as T
+}
+
+export async function listTemplates(
+ params: {
+ category?: TemplateCategory
+ language?: TemplateLanguage
+ search?: string
+ } = {},
+): Promise {
+ const query = new URLSearchParams()
+ if (params.category) {
+ query.set("category", params.category)
+ }
+ if (params.language) {
+ query.set("language", params.language)
+ }
+ if (params.search) {
+ query.set("search", params.search)
+ }
+
+ const suffix = query.toString() ? `/?${query.toString()}` : "/"
+ return apiRequest(`/templates${suffix}`)
+}
+
+export async function createTemplate(
+ payload: CreateTemplatePayload,
+): Promise {
+ return apiRequest("/templates/", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function getTemplate(templateId: string): Promise {
+ return apiRequest(`/templates/${templateId}`)
+}
+
+export async function updateTemplate(
+ templateId: string,
+ payload: UpdateTemplatePayload,
+): Promise {
+ return apiRequest(`/templates/${templateId}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function listTemplateVersions(
+ templateId: string,
+): Promise {
+ return apiRequest(
+ `/templates/${templateId}/versions`,
+ )
+}
+
+export async function createTemplateVersion(
+ templateId: string,
+ payload: CreateTemplateVersionPayload,
+): Promise {
+ return apiRequest(`/templates/${templateId}/versions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function extractVariables(
+ payload: ExtractVariablesPayload,
+): Promise {
+ return apiRequest("/generate/extract", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function renderTemplate(
+ payload: RenderTemplatePayload,
+): Promise<{ output_text: string }> {
+ return apiRequest<{ output_text: string }>("/generate/render", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function createGeneration(
+ payload: CreateGenerationPayload,
+): Promise {
+ return apiRequest("/generations/", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
+
+export async function listGenerations(): Promise {
+ return apiRequest("/generations/")
+}
+
+export async function listRecentTemplates(
+ limit = 5,
+): Promise {
+ const query = new URLSearchParams()
+ query.set("limit", String(limit))
+ return apiRequest(
+ `/dashboard/recent-templates?${query.toString()}`,
+ )
+}
+
+export async function getGeneration(generationId: string): Promise {
+ return apiRequest(`/generations/${generationId}`)
+}
+
+export async function updateGeneration(
+ generationId: string,
+ payload: UpdateGenerationPayload,
+): Promise {
+ return apiRequest(`/generations/${generationId}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+}
diff --git a/frontend/src/lib/templateVariables.ts b/frontend/src/lib/templateVariables.ts
new file mode 100644
index 0000000000..4ce9c8607f
--- /dev/null
+++ b/frontend/src/lib/templateVariables.ts
@@ -0,0 +1,103 @@
+import type {
+ TemplateVariableConfig,
+ TemplateVariableType,
+} from "@/lib/templateMvpApi"
+
+const VARIABLE_REGEX = /{{\s*([a-zA-Z0-9_]+)\s*}}/g
+
+export function extractTemplateVariables(content: string): string[] {
+ const seen = new Set()
+ const variables: string[] = []
+
+ const matches = content.matchAll(VARIABLE_REGEX)
+ for (const match of matches) {
+ const variable = match[1]
+ if (!seen.has(variable)) {
+ seen.add(variable)
+ variables.push(variable)
+ }
+ }
+
+ return variables
+}
+
+const defaultConfig = (
+ type: TemplateVariableType = "text",
+): TemplateVariableConfig => ({
+ required: false,
+ type,
+ description: "",
+ example: type === "list" ? [] : "",
+ default: type === "list" ? [] : "",
+})
+
+export function syncSchemaWithContent(
+ content: string,
+ schema: Record,
+): Record {
+ const variables = extractTemplateVariables(content)
+ const next: Record = {}
+
+ for (const variable of variables) {
+ next[variable] = schema[variable] || defaultConfig()
+ }
+
+ return next
+}
+
+function valueToText(value: unknown): string {
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => String(item).trim())
+ .filter(Boolean)
+ .join(", ")
+ }
+ if (value === null || value === undefined) {
+ return ""
+ }
+ return String(value)
+}
+
+export function renderTemplateText(
+ content: string,
+ values: Record,
+): string {
+ return content.replace(VARIABLE_REGEX, (_, variable: string) => {
+ return valueToText(values[variable])
+ })
+}
+
+export function buildPreviewValues(
+ schema: Record,
+): Record {
+ const values: Record = {}
+
+ for (const [name, config] of Object.entries(schema)) {
+ if (
+ config.default !== null &&
+ config.default !== undefined &&
+ config.default !== ""
+ ) {
+ values[name] = config.default
+ continue
+ }
+ if (
+ config.example !== null &&
+ config.example !== undefined &&
+ config.example !== ""
+ ) {
+ values[name] = config.example
+ continue
+ }
+ values[name] = config.type === "list" ? [] : `{{${name}}}`
+ }
+
+ return values
+}
+
+export function errorToMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message
+ }
+ return "Request failed"
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 8afe946cb5..8ebfe3d544 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -18,8 +18,18 @@ OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || ""
}
+const isAuthUserNotFound = (error: ApiError) => {
+ if (error.status !== 404) return false
+ if (!error.body || typeof error.body !== "object") return false
+ const detail = (error.body as { detail?: unknown }).detail
+ return detail === "User not found"
+}
+
const handleApiError = (error: Error) => {
- if (error instanceof ApiError && [401, 403].includes(error.status)) {
+ if (
+ error instanceof ApiError &&
+ ([401, 403].includes(error.status) || isAuthUserNotFound(error))
+ ) {
localStorage.removeItem("access_token")
window.location.href = "/login"
}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 8849130b4c..2cb9776cac 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -15,8 +15,12 @@ import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
+import { Route as LayoutTemplatesRouteImport } from './routes/_layout/templates'
+import { Route as LayoutTemplateEditorRouteImport } from './routes/_layout/template-editor'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
+import { Route as LayoutHistoryRouteImport } from './routes/_layout/history'
+import { Route as LayoutGenerateRouteImport } from './routes/_layout/generate'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({
@@ -48,6 +52,16 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
path: '/',
getParentRoute: () => LayoutRoute,
} as any)
+const LayoutTemplatesRoute = LayoutTemplatesRouteImport.update({
+ id: '/templates',
+ path: '/templates',
+ getParentRoute: () => LayoutRoute,
+} as any)
+const LayoutTemplateEditorRoute = LayoutTemplateEditorRouteImport.update({
+ id: '/template-editor',
+ path: '/template-editor',
+ getParentRoute: () => LayoutRoute,
+} as any)
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -58,6 +72,16 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({
path: '/items',
getParentRoute: () => LayoutRoute,
} as any)
+const LayoutHistoryRoute = LayoutHistoryRouteImport.update({
+ id: '/history',
+ path: '/history',
+ getParentRoute: () => LayoutRoute,
+} as any)
+const LayoutGenerateRoute = LayoutGenerateRouteImport.update({
+ id: '/generate',
+ path: '/generate',
+ getParentRoute: () => LayoutRoute,
+} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -65,14 +89,18 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
} as any)
export interface FileRoutesByFullPath {
+ '/': typeof LayoutIndexRoute
'/login': typeof LoginRoute
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
+ '/generate': typeof LayoutGenerateRoute
+ '/history': typeof LayoutHistoryRoute
'/items': typeof LayoutItemsRoute
'/settings': typeof LayoutSettingsRoute
- '/': typeof LayoutIndexRoute
+ '/template-editor': typeof LayoutTemplateEditorRoute
+ '/templates': typeof LayoutTemplatesRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -80,8 +108,12 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
+ '/generate': typeof LayoutGenerateRoute
+ '/history': typeof LayoutHistoryRoute
'/items': typeof LayoutItemsRoute
'/settings': typeof LayoutSettingsRoute
+ '/template-editor': typeof LayoutTemplateEditorRoute
+ '/templates': typeof LayoutTemplatesRoute
'/': typeof LayoutIndexRoute
}
export interface FileRoutesById {
@@ -92,21 +124,29 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
+ '/_layout/generate': typeof LayoutGenerateRoute
+ '/_layout/history': typeof LayoutHistoryRoute
'/_layout/items': typeof LayoutItemsRoute
'/_layout/settings': typeof LayoutSettingsRoute
+ '/_layout/template-editor': typeof LayoutTemplateEditorRoute
+ '/_layout/templates': typeof LayoutTemplatesRoute
'/_layout/': typeof LayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
+ | '/'
| '/login'
| '/recover-password'
| '/reset-password'
| '/signup'
| '/admin'
+ | '/generate'
+ | '/history'
| '/items'
| '/settings'
- | '/'
+ | '/template-editor'
+ | '/templates'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -114,8 +154,12 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/admin'
+ | '/generate'
+ | '/history'
| '/items'
| '/settings'
+ | '/template-editor'
+ | '/templates'
| '/'
id:
| '__root__'
@@ -125,8 +169,12 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/_layout/admin'
+ | '/_layout/generate'
+ | '/_layout/history'
| '/_layout/items'
| '/_layout/settings'
+ | '/_layout/template-editor'
+ | '/_layout/templates'
| '/_layout/'
fileRoutesById: FileRoutesById
}
@@ -171,7 +219,7 @@ declare module '@tanstack/react-router' {
'/_layout': {
id: '/_layout'
path: ''
- fullPath: ''
+ fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport
}
@@ -182,6 +230,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutIndexRouteImport
parentRoute: typeof LayoutRoute
}
+ '/_layout/templates': {
+ id: '/_layout/templates'
+ path: '/templates'
+ fullPath: '/templates'
+ preLoaderRoute: typeof LayoutTemplatesRouteImport
+ parentRoute: typeof LayoutRoute
+ }
+ '/_layout/template-editor': {
+ id: '/_layout/template-editor'
+ path: '/template-editor'
+ fullPath: '/template-editor'
+ preLoaderRoute: typeof LayoutTemplateEditorRouteImport
+ parentRoute: typeof LayoutRoute
+ }
'/_layout/settings': {
id: '/_layout/settings'
path: '/settings'
@@ -196,6 +258,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutItemsRouteImport
parentRoute: typeof LayoutRoute
}
+ '/_layout/history': {
+ id: '/_layout/history'
+ path: '/history'
+ fullPath: '/history'
+ preLoaderRoute: typeof LayoutHistoryRouteImport
+ parentRoute: typeof LayoutRoute
+ }
+ '/_layout/generate': {
+ id: '/_layout/generate'
+ path: '/generate'
+ fullPath: '/generate'
+ preLoaderRoute: typeof LayoutGenerateRouteImport
+ parentRoute: typeof LayoutRoute
+ }
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
@@ -208,15 +284,23 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
+ LayoutGenerateRoute: typeof LayoutGenerateRoute
+ LayoutHistoryRoute: typeof LayoutHistoryRoute
LayoutItemsRoute: typeof LayoutItemsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
+ LayoutTemplateEditorRoute: typeof LayoutTemplateEditorRoute
+ LayoutTemplatesRoute: typeof LayoutTemplatesRoute
LayoutIndexRoute: typeof LayoutIndexRoute
}
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
+ LayoutGenerateRoute: LayoutGenerateRoute,
+ LayoutHistoryRoute: LayoutHistoryRoute,
LayoutItemsRoute: LayoutItemsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
+ LayoutTemplateEditorRoute: LayoutTemplateEditorRoute,
+ LayoutTemplatesRoute: LayoutTemplatesRoute,
LayoutIndexRoute: LayoutIndexRoute,
}
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
index 8644b83d05..7b02c72665 100644
--- a/frontend/src/routes/__root.tsx
+++ b/frontend/src/routes/__root.tsx
@@ -4,13 +4,20 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import ErrorComponent from "@/components/Common/ErrorComponent"
import NotFound from "@/components/Common/NotFound"
+const showTanStackDevtools =
+ import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS === "true"
+
export const Route = createRootRoute({
component: () => (
<>
-
-
+ {showTanStackDevtools ? (
+ <>
+
+
+ >
+ ) : null}
>
),
notFoundComponent: () => ,
diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx
index 169730546e..58745f7a9d 100644
--- a/frontend/src/routes/_layout.tsx
+++ b/frontend/src/routes/_layout.tsx
@@ -25,7 +25,7 @@ function Layout() {
-
+
diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx
index a53ff2c4e9..23758d66c3 100644
--- a/frontend/src/routes/_layout/admin.tsx
+++ b/frontend/src/routes/_layout/admin.tsx
@@ -29,7 +29,7 @@ export const Route = createFileRoute("/_layout/admin")({
head: () => ({
meta: [
{
- title: "Admin - FastAPI Template",
+ title: "Admin - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/routes/_layout/generate.tsx b/frontend/src/routes/_layout/generate.tsx
new file mode 100644
index 0000000000..68ff1b164b
--- /dev/null
+++ b/frontend/src/routes/_layout/generate.tsx
@@ -0,0 +1,501 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
+import { useEffect, useMemo, useState } from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import useCustomToast from "@/hooks/useCustomToast"
+import {
+ createGeneration,
+ createTemplateVersion,
+ extractVariables,
+ getTemplate,
+ listTemplates,
+ listTemplateVersions,
+ renderTemplate,
+ type Template,
+ type TemplateVariableConfig,
+ type TemplateVersion,
+} from "@/lib/templateMvpApi"
+import { errorToMessage } from "@/lib/templateVariables"
+
+function listToInput(value: unknown): string {
+ if (!Array.isArray(value)) {
+ return ""
+ }
+ return value.join(", ")
+}
+
+function inputToList(value: string): string[] {
+ return value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+function escapeRegExp(input: string): string {
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+function generalizeOutputToTemplate(
+ outputText: string,
+ values: Record,
+): string {
+ let templateContent = outputText
+
+ const replacementEntries = Object.entries(values)
+ .map(([key, value]) => [key, value] as const)
+ .sort((a, b) => {
+ const aLength = Array.isArray(a[1])
+ ? a[1].join(", ").length
+ : String(a[1] ?? "").length
+ const bLength = Array.isArray(b[1])
+ ? b[1].join(", ").length
+ : String(b[1] ?? "").length
+ return bLength - aLength
+ })
+
+ for (const [key, rawValue] of replacementEntries) {
+ if (Array.isArray(rawValue)) {
+ for (const item of rawValue) {
+ if (!String(item).trim()) {
+ continue
+ }
+ templateContent = templateContent.replace(
+ new RegExp(escapeRegExp(String(item)), "g"),
+ `{{${key}}}`,
+ )
+ }
+ continue
+ }
+
+ if (!String(rawValue ?? "").trim()) {
+ continue
+ }
+
+ templateContent = templateContent.replace(
+ new RegExp(escapeRegExp(String(rawValue)), "g"),
+ `{{${key}}}`,
+ )
+ }
+
+ return templateContent
+}
+
+export const Route = createFileRoute("/_layout/generate")({
+ component: GeneratePage,
+ head: () => ({
+ meta: [
+ {
+ title: "Generate - TemplateForge AI",
+ },
+ ],
+ }),
+})
+
+function GeneratePage() {
+ const queryClient = useQueryClient()
+ const { showErrorToast, showSuccessToast } = useCustomToast()
+
+ const [selectedTemplateId, setSelectedTemplateId] = useState("")
+ const [selectedVersionId, setSelectedVersionId] = useState("")
+ const [inputText, setInputText] = useState("")
+ const [values, setValues] = useState>({})
+ const [missingRequired, setMissingRequired] = useState([])
+ const [outputText, setOutputText] = useState("")
+ const [generalizeTemplate, setGeneralizeTemplate] = useState(true)
+
+ const templatesQuery = useQuery({
+ queryKey: ["templates", "generate"],
+ queryFn: () => listTemplates(),
+ })
+
+ useEffect(() => {
+ if (selectedTemplateId || !templatesQuery.data?.data.length) {
+ return
+ }
+ setSelectedTemplateId(templatesQuery.data.data[0].id)
+ }, [selectedTemplateId, templatesQuery.data])
+
+ const templateQuery = useQuery({
+ queryKey: ["template", selectedTemplateId],
+ queryFn: () => getTemplate(selectedTemplateId),
+ enabled: Boolean(selectedTemplateId),
+ })
+
+ const versionsQuery = useQuery({
+ queryKey: ["templateVersions", selectedTemplateId],
+ queryFn: () => listTemplateVersions(selectedTemplateId),
+ enabled: Boolean(selectedTemplateId),
+ })
+
+ useEffect(() => {
+ if (!versionsQuery.data?.data.length) {
+ setSelectedVersionId("")
+ return
+ }
+ if (
+ selectedVersionId &&
+ versionsQuery.data.data.some((item) => item.id === selectedVersionId)
+ ) {
+ return
+ }
+ setSelectedVersionId(versionsQuery.data.data[0].id)
+ }, [selectedVersionId, versionsQuery.data])
+
+ const currentVersion = useMemo(() => {
+ if (!versionsQuery.data?.data.length || !selectedVersionId) {
+ return null
+ }
+ return (
+ versionsQuery.data.data.find((item) => item.id === selectedVersionId) ||
+ null
+ )
+ }, [selectedVersionId, versionsQuery.data])
+
+ const currentTemplate = useMemo(() => {
+ return templateQuery.data || null
+ }, [templateQuery.data])
+
+ const extractMutation = useMutation({
+ mutationFn: extractVariables,
+ })
+
+ const renderMutation = useMutation({
+ mutationFn: renderTemplate,
+ })
+
+ const saveGenerationMutation = useMutation({
+ mutationFn: createGeneration,
+ })
+
+ const saveAsVersionMutation = useMutation({
+ mutationFn: (payload: {
+ templateId: string
+ content: string
+ schema: Record
+ }) =>
+ createTemplateVersion(payload.templateId, {
+ content: payload.content,
+ variables_schema: payload.schema,
+ }),
+ })
+
+ useEffect(() => {
+ if (!currentVersion) {
+ setValues({})
+ setMissingRequired([])
+ return
+ }
+
+ const initialValues: Record = {}
+ for (const [variable, config] of Object.entries(
+ currentVersion.variables_schema,
+ )) {
+ initialValues[variable] =
+ config.default !== undefined && config.default !== null
+ ? config.default
+ : config.type === "list"
+ ? []
+ : ""
+ }
+ setValues(initialValues)
+ setMissingRequired([])
+ setOutputText("")
+ }, [currentVersion?.id, currentVersion])
+
+ const handleExtract = async () => {
+ if (!selectedVersionId) {
+ showErrorToast("Select a template version first")
+ return
+ }
+
+ if (!inputText.trim()) {
+ showErrorToast("Please paste your requirement text first")
+ return
+ }
+
+ try {
+ const result = await extractMutation.mutateAsync({
+ template_version_id: selectedVersionId,
+ input_text: inputText,
+ })
+ setValues(result.values)
+ setMissingRequired(result.missing_required)
+ showSuccessToast("Variables extracted")
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const handleRender = async () => {
+ if (!selectedVersionId) {
+ showErrorToast("Select a template version first")
+ return
+ }
+
+ try {
+ const result = await renderMutation.mutateAsync({
+ template_version_id: selectedVersionId,
+ values,
+ style: {
+ tone: "professional",
+ length: "medium",
+ },
+ })
+ setOutputText(result.output_text)
+ showSuccessToast("Draft generated")
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const handleSaveGeneration = async () => {
+ if (!selectedTemplateId || !selectedVersionId || !outputText.trim()) {
+ showErrorToast("Generate content before saving")
+ return
+ }
+
+ try {
+ const title = `${currentTemplate?.name || "Generation"} ${new Date().toLocaleDateString()}`
+ await saveGenerationMutation.mutateAsync({
+ template_id: selectedTemplateId,
+ template_version_id: selectedVersionId,
+ title,
+ input_text: inputText,
+ extracted_values: values,
+ output_text: outputText,
+ })
+ showSuccessToast("Generation saved")
+ await queryClient.invalidateQueries({ queryKey: ["generations"] })
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const handleSaveAsVersion = async () => {
+ if (!selectedTemplateId || !currentVersion || !outputText.trim()) {
+ showErrorToast("Generate and edit content before saving as a version")
+ return
+ }
+
+ try {
+ const versionContent = generalizeTemplate
+ ? generalizeOutputToTemplate(outputText, values)
+ : outputText
+
+ await saveAsVersionMutation.mutateAsync({
+ templateId: selectedTemplateId,
+ content: versionContent,
+ schema: currentVersion.variables_schema,
+ })
+
+ showSuccessToast("Saved as new template version")
+ await queryClient.invalidateQueries({
+ queryKey: ["templateVersions", selectedTemplateId],
+ })
+ await queryClient.invalidateQueries({
+ queryKey: ["template", selectedTemplateId],
+ })
+ await queryClient.invalidateQueries({ queryKey: ["templates"] })
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const updateValue = (variable: string, rawValue: string, isList: boolean) => {
+ setValues((current) => ({
+ ...current,
+ [variable]: isList ? inputToList(rawValue) : rawValue,
+ }))
+
+ setMissingRequired((current) => current.filter((item) => item !== variable))
+ }
+
+ return (
+
+
+
Generate
+
+ Select a template, extract variables, confirm values, and generate
+ output.
+
+
+
+
+
+ Step A: Select Template
+
+
+
+
+
+
+ {templatesQuery.error ? (
+
+ {errorToMessage(templatesQuery.error)}
+
+ ) : null}
+
+
+
+
+
+ Step B: Input Requirement
+
+ Paste JD, notes, or context for extraction.
+
+
+
+
+
+
+
+
+ Step C: Confirm Variables
+
+ Fill missing required fields before generating.
+
+
+
+ {currentVersion ? (
+ Object.entries(currentVersion.variables_schema).map(
+ ([variable, config]) => {
+ const isList = config.type === "list"
+ const rawValue = values[variable]
+
+ return (
+
+
+
{variable}
+
+ {missingRequired.includes(variable) ? (
+
+ Missing required
+
+ ) : null}
+ {config.required ? (
+
+ Required
+
+ ) : null}
+
+
+
+ updateValue(variable, event.target.value, isList)
+ }
+ />
+
+ )
+ },
+ )
+ ) : (
+
+ Select a template version to load variables.
+
+ )}
+
+
+
+
+
+
+
+ Step D: Output
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/routes/_layout/history.tsx b/frontend/src/routes/_layout/history.tsx
new file mode 100644
index 0000000000..a237ff7bc7
--- /dev/null
+++ b/frontend/src/routes/_layout/history.tsx
@@ -0,0 +1,216 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
+import { useEffect, useState } from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import useCustomToast from "@/hooks/useCustomToast"
+import { downloadTextAsPdf } from "@/lib/pdf"
+import {
+ getGeneration,
+ listGenerations,
+ updateGeneration,
+} from "@/lib/templateMvpApi"
+import { errorToMessage } from "@/lib/templateVariables"
+
+export const Route = createFileRoute("/_layout/history")({
+ component: HistoryPage,
+ head: () => ({
+ meta: [
+ {
+ title: "Generations History - TemplateForge AI",
+ },
+ ],
+ }),
+})
+
+function HistoryPage() {
+ const queryClient = useQueryClient()
+ const { showErrorToast, showSuccessToast } = useCustomToast()
+
+ const [selectedGenerationId, setSelectedGenerationId] = useState("")
+ const [editableOutput, setEditableOutput] = useState("")
+
+ const generationsQuery = useQuery({
+ queryKey: ["generations"],
+ queryFn: listGenerations,
+ })
+
+ useEffect(() => {
+ if (selectedGenerationId || !generationsQuery.data?.data.length) {
+ return
+ }
+ setSelectedGenerationId(generationsQuery.data.data[0].id)
+ }, [generationsQuery.data, selectedGenerationId])
+
+ const generationDetailQuery = useQuery({
+ queryKey: ["generation", selectedGenerationId],
+ queryFn: () => getGeneration(selectedGenerationId),
+ enabled: Boolean(selectedGenerationId),
+ })
+
+ useEffect(() => {
+ if (!generationDetailQuery.data) {
+ return
+ }
+ setEditableOutput(generationDetailQuery.data.output_text)
+ }, [generationDetailQuery.data])
+
+ const updateMutation = useMutation({
+ mutationFn: (payload: { id: string; output_text: string }) =>
+ updateGeneration(payload.id, { output_text: payload.output_text }),
+ })
+
+ const handleSaveEditedOutput = async () => {
+ if (!selectedGenerationId) {
+ return
+ }
+
+ try {
+ const updatedGeneration = await updateMutation.mutateAsync({
+ id: selectedGenerationId,
+ output_text: editableOutput,
+ })
+ await queryClient.invalidateQueries({
+ queryKey: ["generation", selectedGenerationId],
+ })
+ await queryClient.invalidateQueries({ queryKey: ["generations"] })
+
+ try {
+ await downloadTextAsPdf({
+ title: updatedGeneration.title,
+ body: updatedGeneration.output_text,
+ subtitle: `Saved at ${new Date().toLocaleString()}`,
+ })
+ showSuccessToast("Generation updated and PDF downloaded")
+ } catch {
+ showSuccessToast("Generation updated")
+ showErrorToast("PDF download failed")
+ }
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ return (
+
+
+
+ Generations History
+
+
+ Browse saved generations and edit final outputs.
+
+
+
+
+
+
+ Records
+
+ {generationsQuery.data?.count || 0} generation(s)
+
+
+
+ {generationsQuery.isLoading ? (
+
+ Loading history...
+
+ ) : generationsQuery.error ? (
+
+ {errorToMessage(generationsQuery.error)}
+
+ ) : generationsQuery.data &&
+ generationsQuery.data.data.length > 0 ? (
+ generationsQuery.data.data.map((generation) => (
+
+ ))
+ ) : (
+ No history yet.
+ )}
+
+
+
+
+
+ Details
+
+
+ {!selectedGenerationId ? (
+
+ Select a generation to view details.
+
+ ) : generationDetailQuery.isLoading ? (
+ Loading detail...
+ ) : generationDetailQuery.error ? (
+
+ {errorToMessage(generationDetailQuery.error)}
+
+ ) : generationDetailQuery.data ? (
+ <>
+
+
Input
+
+ {generationDetailQuery.data.input_text}
+
+
+
+
+
Extracted Values
+
+ {JSON.stringify(
+ generationDetailQuery.data.extracted_values,
+ null,
+ 2,
+ )}
+
+
+
+
+ >
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx
index 3e640cbbb8..9adcea48a0 100644
--- a/frontend/src/routes/_layout/index.tsx
+++ b/frontend/src/routes/_layout/index.tsx
@@ -1,13 +1,15 @@
+import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import useAuth from "@/hooks/useAuth"
+import { listRecentTemplates } from "@/lib/templateMvpApi"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
head: () => ({
meta: [
{
- title: "Dashboard - FastAPI Template",
+ title: "Dashboard - TemplateForge AI",
},
],
}),
@@ -15,17 +17,83 @@ export const Route = createFileRoute("/_layout/")({
function Dashboard() {
const { user: currentUser } = useAuth()
+ const recentTemplatesQuery = useQuery({
+ queryKey: ["dashboard", "recent-templates", 5],
+ queryFn: () => listRecentTemplates(5),
+ staleTime: 60_000,
+ })
+ const recentTemplates = recentTemplatesQuery.data?.data ?? []
return (
-
+
- Hi, {currentUser?.full_name || currentUser?.email} 👋
+ Hi, {currentUser?.full_name || currentUser?.email}
Welcome back, nice to see you again!!!
+
+
+
+
Recently Used Templates
+
+ Based on your saved generations
+
+
+
+ {recentTemplatesQuery.isLoading ? (
+
+ Loading recent templates...
+
+ ) : recentTemplatesQuery.isError ? (
+
+ Failed to load recent templates.
+
+ ) : recentTemplates.length === 0 ? (
+
+ No recent templates yet. Save a generation to see it here.
+
+ ) : (
+
+ {recentTemplates.map((template) => (
+
+
+
+
+ {template.template_name}
+
+
+ {template.category} • {template.language}
+
+
+
+
+ {formatLastUsed(template.last_used_at)}
+
+
+ {template.usage_count} use
+ {template.usage_count === 1 ? "" : "s"}
+
+
+
+
+ ))}
+
+ )}
+
)
}
+
+function formatLastUsed(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return "Last used: unknown"
+ }
+ return `Last used: ${date.toLocaleString()}`
+}
diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx
index a4df200023..73fe005867 100644
--- a/frontend/src/routes/_layout/items.tsx
+++ b/frontend/src/routes/_layout/items.tsx
@@ -21,7 +21,7 @@ export const Route = createFileRoute("/_layout/items")({
head: () => ({
meta: [
{
- title: "Items - FastAPI Template",
+ title: "Items - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx
index e109b5ae81..8dd012ccae 100644
--- a/frontend/src/routes/_layout/settings.tsx
+++ b/frontend/src/routes/_layout/settings.tsx
@@ -17,7 +17,7 @@ export const Route = createFileRoute("/_layout/settings")({
head: () => ({
meta: [
{
- title: "Settings - FastAPI Template",
+ title: "Settings - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/routes/_layout/template-editor.tsx b/frontend/src/routes/_layout/template-editor.tsx
new file mode 100644
index 0000000000..55d6556520
--- /dev/null
+++ b/frontend/src/routes/_layout/template-editor.tsx
@@ -0,0 +1,562 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { createFileRoute, useNavigate } from "@tanstack/react-router"
+import { useEffect, useMemo, useState } from "react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import useCustomToast from "@/hooks/useCustomToast"
+import {
+ createTemplate,
+ createTemplateVersion,
+ getTemplate,
+ listTemplateVersions,
+ type TemplateCategory,
+ type TemplateLanguage,
+ type TemplateVariableConfig,
+ updateTemplate,
+} from "@/lib/templateMvpApi"
+import {
+ buildPreviewValues,
+ errorToMessage,
+ extractTemplateVariables,
+ renderTemplateText,
+ syncSchemaWithContent,
+} from "@/lib/templateVariables"
+
+const CATEGORY_OPTIONS: Array<{ label: string; value: TemplateCategory }> = [
+ { label: "Cover Letter", value: "cover_letter" },
+ { label: "Email", value: "email" },
+ { label: "Proposal", value: "proposal" },
+ { label: "Other", value: "other" },
+]
+
+const LANGUAGE_OPTIONS: Array<{ label: string; value: TemplateLanguage }> = [
+ { label: "English", value: "en" },
+ { label: "French", value: "fr" },
+ { label: "Chinese", value: "zh" },
+ { label: "Other", value: "other" },
+]
+
+const DEFAULT_TEMPLATE_CONTENT = "Dear {{company}},\n\n{{body}}"
+
+const defaultTemplateSchema = (): TemplateVariableConfig => ({
+ required: false,
+ type: "text",
+ description: "",
+ example: "",
+ default: "",
+})
+
+function stringToList(value: string): string[] {
+ return value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+function listToString(value: unknown): string {
+ if (!Array.isArray(value)) {
+ return ""
+ }
+ return value.join(", ")
+}
+
+export const Route = createFileRoute("/_layout/template-editor")({
+ validateSearch: (search: Record
) => ({
+ templateId:
+ typeof search.templateId === "string" && search.templateId
+ ? search.templateId
+ : undefined,
+ }),
+ component: TemplateEditorPage,
+ head: () => ({
+ meta: [
+ {
+ title: "Template Editor - TemplateForge AI",
+ },
+ ],
+ }),
+})
+
+function TemplateEditorPage() {
+ const { templateId } = Route.useSearch()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const { showErrorToast, showSuccessToast } = useCustomToast()
+
+ const [name, setName] = useState("Untitled Template")
+ const [category, setCategory] = useState("cover_letter")
+ const [language, setLanguage] = useState("en")
+ const [tagsInput, setTagsInput] = useState("")
+ const [content, setContent] = useState(DEFAULT_TEMPLATE_CONTENT)
+ const [variablesSchema, setVariablesSchema] = useState<
+ Record
+ >({})
+ const [preview, setPreview] = useState("")
+ const [hydratedTemplateId, setHydratedTemplateId] = useState(
+ null,
+ )
+
+ const templateQuery = useQuery({
+ queryKey: ["template", templateId],
+ queryFn: () => getTemplate(templateId!),
+ enabled: Boolean(templateId),
+ refetchOnWindowFocus: false,
+ })
+
+ const versionsQuery = useQuery({
+ queryKey: ["templateVersions", templateId],
+ queryFn: () => listTemplateVersions(templateId!),
+ enabled: Boolean(templateId),
+ refetchOnWindowFocus: false,
+ })
+
+ useEffect(() => {
+ if (!templateId) {
+ setHydratedTemplateId(null)
+ return
+ }
+ if (hydratedTemplateId !== null && hydratedTemplateId !== templateId) {
+ setHydratedTemplateId(null)
+ }
+ }, [templateId, hydratedTemplateId])
+
+ useEffect(() => {
+ if (!templateQuery.data) {
+ return
+ }
+
+ if (hydratedTemplateId === templateQuery.data.id) {
+ return
+ }
+
+ const template = templateQuery.data
+ setName(template.name)
+ setCategory(template.category)
+ setLanguage(template.language)
+ setTagsInput(template.tags.join(", "))
+
+ const latestContent =
+ template.latest_version?.content || DEFAULT_TEMPLATE_CONTENT
+ const latestSchema = template.latest_version?.variables_schema || {}
+
+ setContent(latestContent)
+ setVariablesSchema(syncSchemaWithContent(latestContent, latestSchema))
+ setHydratedTemplateId(template.id)
+ }, [templateQuery.data, hydratedTemplateId])
+
+ useEffect(() => {
+ setVariablesSchema((current) => {
+ const synced = syncSchemaWithContent(content, current)
+ if (JSON.stringify(synced) === JSON.stringify(current)) {
+ return current
+ }
+ return synced
+ })
+ }, [content])
+
+ const variableNames = useMemo(
+ () => extractTemplateVariables(content),
+ [content],
+ )
+
+ const createTemplateMutation = useMutation({
+ mutationFn: createTemplate,
+ })
+
+ const updateTemplateMutation = useMutation({
+ mutationFn: (payload: {
+ id: string
+ data: {
+ name: string
+ category: TemplateCategory
+ language: TemplateLanguage
+ tags: string[]
+ }
+ }) => updateTemplate(payload.id, payload.data),
+ })
+
+ const createVersionMutation = useMutation({
+ mutationFn: (payload: {
+ templateId: string
+ content: string
+ variables_schema: Record
+ }) =>
+ createTemplateVersion(payload.templateId, {
+ content: payload.content,
+ variables_schema: payload.variables_schema,
+ }),
+ })
+
+ const parseTags = () =>
+ tagsInput
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+ const saveTemplateMetadata = async (): Promise => {
+ const tags = parseTags()
+
+ if (templateId) {
+ await updateTemplateMutation.mutateAsync({
+ id: templateId,
+ data: { name, category, language, tags },
+ })
+ return templateId
+ }
+
+ const created = await createTemplateMutation.mutateAsync({
+ name,
+ category,
+ language,
+ tags,
+ })
+ await navigate({
+ to: "/template-editor",
+ search: { templateId: created.id },
+ replace: true,
+ })
+ return created.id
+ }
+
+ const handleSave = async () => {
+ try {
+ const currentTemplateId = await saveTemplateMetadata()
+
+ if (!templateId) {
+ await createVersionMutation.mutateAsync({
+ templateId: currentTemplateId,
+ content,
+ variables_schema: variablesSchema,
+ })
+ }
+
+ showSuccessToast("Template saved")
+ await queryClient.invalidateQueries({ queryKey: ["templates"] })
+ await queryClient.invalidateQueries({
+ queryKey: ["template", currentTemplateId],
+ })
+ await queryClient.invalidateQueries({
+ queryKey: ["templateVersions", currentTemplateId],
+ })
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const handleSaveNewVersion = async () => {
+ try {
+ const currentTemplateId = await saveTemplateMetadata()
+ await createVersionMutation.mutateAsync({
+ templateId: currentTemplateId,
+ content,
+ variables_schema: variablesSchema,
+ })
+
+ showSuccessToast("New template version saved")
+ await queryClient.invalidateQueries({ queryKey: ["templates"] })
+ await queryClient.invalidateQueries({
+ queryKey: ["template", currentTemplateId],
+ })
+ await queryClient.invalidateQueries({
+ queryKey: ["templateVersions", currentTemplateId],
+ })
+ } catch (error) {
+ showErrorToast(errorToMessage(error))
+ }
+ }
+
+ const handlePreview = () => {
+ const values = buildPreviewValues(variablesSchema)
+ setPreview(renderTemplateText(content, values))
+ }
+
+ const updateVariableConfig = (
+ variableName: string,
+ updater: (current: TemplateVariableConfig) => TemplateVariableConfig,
+ ) => {
+ setVariablesSchema((current) => {
+ const base = current[variableName] || defaultTemplateSchema()
+ return {
+ ...current,
+ [variableName]: updater(base),
+ }
+ })
+ }
+
+ const isSaving =
+ createTemplateMutation.isPending ||
+ updateTemplateMutation.isPending ||
+ createVersionMutation.isPending
+
+ return (
+
+
+
+
Template Editor
+
+ Edit template content and keep versioned prompt assets.
+
+
+
+
+
+
+
+
+
+
+
+ Template Metadata
+
+
+
+ setName(event.target.value)}
+ />
+
+ setTagsInput(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Template Content
+
+ Use placeholders like {"{{company}}"}. Variables are synced
+ automatically.
+
+
+
+
+
+
+
+
+ Variables
+
+ {variableNames.length} variable(s) in this template
+
+
+
+ {variableNames.length === 0 ? (
+
+ Add placeholders in the editor to configure variables.
+
+ ) : (
+ variableNames.map((variableName) => {
+ const config =
+ variablesSchema[variableName] || defaultTemplateSchema()
+ const isList = config.type === "list"
+
+ return (
+
+
+
{variableName}
+
+
+
+
+
+
+ updateVariableConfig(variableName, (current) => ({
+ ...current,
+ description: event.target.value,
+ }))
+ }
+ />
+
+
+ updateVariableConfig(variableName, (current) => ({
+ ...current,
+ example: isList
+ ? stringToList(event.target.value)
+ : event.target.value,
+ }))
+ }
+ />
+
+
+ updateVariableConfig(variableName, (current) => ({
+ ...current,
+ default: isList
+ ? stringToList(event.target.value)
+ : event.target.value,
+ }))
+ }
+ />
+
+ )
+ })
+ )}
+
+
+
+
+ {templateQuery.error ? (
+
+ {errorToMessage(templateQuery.error)}
+
+ ) : null}
+
+
+
+ Version History
+
+ {versionsQuery.data?.count || 0} version(s)
+
+
+
+ {versionsQuery.isLoading ? (
+ Loading versions...
+ ) : versionsQuery.data && versionsQuery.data.data.length > 0 ? (
+
+ {versionsQuery.data.data.map((version) => (
+
+
v{version.version}
+
+ {version.created_at
+ ? new Date(version.created_at).toLocaleString()
+ : "-"}
+
+
+ ))}
+
+ ) : (
+ No versions yet.
+ )}
+
+
+
+ {preview ? (
+
+
+ Preview
+
+
+
+ {preview}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/routes/_layout/templates.tsx b/frontend/src/routes/_layout/templates.tsx
new file mode 100644
index 0000000000..ebbd8a56c0
--- /dev/null
+++ b/frontend/src/routes/_layout/templates.tsx
@@ -0,0 +1,570 @@
+import { useQuery } from "@tanstack/react-query"
+import { createFileRoute, Link } from "@tanstack/react-router"
+import {
+ Archive,
+ CalendarClock,
+ ChevronDown,
+ FilePlus2,
+ Filter,
+ Globe2,
+ Layers3,
+ PencilLine,
+ RefreshCcw,
+ Search,
+ Sparkles,
+ Tags,
+} from "lucide-react"
+import { useState } from "react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import {
+ listTemplates,
+ type TemplateCategory,
+ type TemplateLanguage,
+ type TemplateSummary,
+} from "@/lib/templateMvpApi"
+import { errorToMessage } from "@/lib/templateVariables"
+
+const CATEGORY_OPTIONS: Array<{ label: string; value: "" | TemplateCategory }> =
+ [
+ { label: "All", value: "" },
+ { label: "Cover Letter", value: "cover_letter" },
+ { label: "Email", value: "email" },
+ { label: "Proposal", value: "proposal" },
+ { label: "Other", value: "other" },
+ ]
+
+const LANGUAGE_OPTIONS: Array<{ label: string; value: "" | TemplateLanguage }> =
+ [
+ { label: "All", value: "" },
+ { label: "English", value: "en" },
+ { label: "French", value: "fr" },
+ { label: "Chinese", value: "zh" },
+ { label: "Other", value: "other" },
+ ]
+
+const CATEGORY_LABELS: Record = {
+ cover_letter: "Cover Letter",
+ email: "Email",
+ proposal: "Proposal",
+ other: "Other",
+}
+
+const LANGUAGE_LABELS: Record = {
+ en: "English",
+ fr: "French",
+ zh: "Chinese",
+ other: "Other",
+}
+
+function formatTemplateTime(value: string | null) {
+ if (!value) {
+ return "No activity yet"
+ }
+
+ return new Date(value).toLocaleString()
+}
+
+function selectClassName() {
+ return "border-input bg-[var(--surface-input)] text-foreground h-[var(--control-height)] w-full appearance-none rounded-[var(--radius-control)] border px-3.5 pr-9 text-sm shadow-[var(--shadow-elev-1)] outline-none transition-[color,background-color,border-color,box-shadow] duration-200 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
+}
+
+function FilterSelect({
+ id,
+ value,
+ options,
+ onChange,
+}: {
+ id: string
+ value: string
+ options: Array<{ label: string; value: string }>
+ onChange: (value: string) => void
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function TemplateCard({ template }: { template: TemplateSummary }) {
+ const hasTags = template.tags.length > 0
+
+ return (
+
+
+
+
+
+
+
+
+ {template.name}
+
+ {template.is_archived ? (
+
+
+ Archived
+
+ ) : (
+
+ Active
+
+ )}
+
+
+
+
+
+ {CATEGORY_LABELS[template.category]}
+
+
+
+ {LANGUAGE_LABELS[template.language]}
+
+
+
+ {formatTemplateTime(template.updated_at)}
+
+
+
+
+
+
+
+
+
+
Versions
+
+ {template.versions_count}
+
+
+
+
Latest
+
+ {template.latest_version_number ?? "-"}
+
+
+
+
Tags
+
+ {template.tags.length}
+
+
+
+
+
+
+
+ Labels
+
+ {hasTags ? (
+
+ {template.tags.slice(0, 5).map((tag) => (
+
+ {tag}
+
+ ))}
+ {template.tags.length > 5 ? (
+
+ +{template.tags.length - 5} more
+
+ ) : null}
+
+ ) : (
+
No tags yet
+ )}
+
+
+
+ )
+}
+
+function LoadingTemplateCards() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ )
+}
+
+export const Route = createFileRoute("/_layout/templates")({
+ component: TemplatesPage,
+ head: () => ({
+ meta: [
+ {
+ title: "Templates - TemplateForge AI",
+ },
+ ],
+ }),
+})
+
+function TemplatesPage() {
+ const [category, setCategory] = useState<"" | TemplateCategory>("")
+ const [language, setLanguage] = useState<"" | TemplateLanguage>("")
+ const [search, setSearch] = useState("")
+
+ const templatesQuery = useQuery({
+ queryKey: ["templates", { category, language, search }],
+ queryFn: () =>
+ listTemplates({
+ category: category || undefined,
+ language: language || undefined,
+ search: search.trim() || undefined,
+ }),
+ })
+
+ const templates = templatesQuery.data?.data ?? []
+ const totalTemplates = templatesQuery.data?.count ?? 0
+ const archivedCount = templates.filter(
+ (template) => template.is_archived,
+ ).length
+ const activeCount = totalTemplates - archivedCount
+ const totalVersions = templates.reduce(
+ (sum, template) => sum + template.versions_count,
+ 0,
+ )
+ const activeFilters = [category, language, search.trim()].filter(
+ Boolean,
+ ).length
+ const languageCount = new Set(templates.map((template) => template.language))
+ .size
+
+ const clearFilters = () => {
+ setCategory("")
+ setLanguage("")
+ setSearch("")
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ Template Workspace
+
+
+
+
+ Templates
+
+
+ Build reusable writing systems, organize them by language and
+ category, and jump back into editing without digging through a
+ plain table.
+
+
+
+
+
+
Total templates
+
+ {totalTemplates}
+
+
+
+
Active
+
+ {activeCount}
+
+
+
+
Versions
+
+ {totalVersions}
+
+
+
+
Languages
+
+ {languageCount}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {activeFilters} active filter{activeFilters === 1 ? "" : "s"}
+
+
+
+
+
+
+
+
+
+
+
+
+ Filters
+
+
+ Narrow results by category, language, or template name.
+
+
+ {activeFilters > 0 ? (
+
+ {activeFilters} active
+
+ ) : (
+
+ No active filters
+
+ )}
+
+
+
+
+
+
+
+
+ setCategory(value as "" | TemplateCategory)
+ }
+ />
+
+
+
+
+
+ setLanguage(value as "" | TemplateLanguage)
+ }
+ />
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Template Library
+
+
+ {totalTemplates} template(s) across {Math.max(languageCount, 0)}{" "}
+ language bucket{languageCount === 1 ? "" : "s"}
+
+
+
+
+ {archivedCount} archived
+
+
+
+
+
+ {templatesQuery.isLoading ? (
+
+ ) : templatesQuery.error ? (
+
+
+ Failed to load templates
+
+
+ {errorToMessage(templatesQuery.error)}
+
+
+
+ ) : templates.length > 0 ? (
+
+ {templates.map((template) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
+
+ {activeFilters > 0
+ ? "No templates match these filters"
+ : "No templates yet"}
+
+
+ {activeFilters > 0
+ ? "Try clearing filters or broadening your search to see more results."
+ : "Create your first template to start building reusable prompts and writing workflows."}
+
+
+
+ {activeFilters > 0 ? (
+
+ ) : null}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx
index a1f83d7e5a..4ab0e975a9 100644
--- a/frontend/src/routes/login.tsx
+++ b/frontend/src/routes/login.tsx
@@ -4,6 +4,7 @@ import {
Link as RouterLink,
redirect,
} from "@tanstack/react-router"
+import { useEffect, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
@@ -20,7 +21,9 @@ import {
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input"
+import { Separator } from "@/components/ui/separator"
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
+import { renderGoogleSignInButton } from "@/lib/googleIdentity"
const formSchema = z.object({
username: z.email(),
@@ -44,14 +47,17 @@ export const Route = createFileRoute("/login")({
head: () => ({
meta: [
{
- title: "Log In - FastAPI Template",
+ title: "Log In - TemplateForge AI",
},
],
}),
})
function Login() {
- const { loginMutation } = useAuth()
+ const { loginMutation, googleLoginMutation } = useAuth()
+ const googleButtonRef = useRef(null)
+ const [googleInitError, setGoogleInitError] = useState(null)
+ const googleClientId = (import.meta.env.VITE_GOOGLE_CLIENT_ID || "").trim()
const form = useForm({
resolver: zodResolver(formSchema),
mode: "onBlur",
@@ -67,6 +73,33 @@ function Login() {
loginMutation.mutate(data)
}
+ useEffect(() => {
+ if (!googleClientId || !googleButtonRef.current) return
+
+ let cancelled = false
+ setGoogleInitError(null)
+
+ renderGoogleSignInButton({
+ container: googleButtonRef.current,
+ clientId: googleClientId,
+ onCredential: (idToken) => {
+ if (googleLoginMutation.isPending) return
+ googleLoginMutation.mutate(idToken)
+ },
+ }).catch((error) => {
+ if (cancelled) return
+ setGoogleInitError(
+ error instanceof Error
+ ? error.message
+ : "Failed to initialize Google login",
+ )
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [googleLoginMutation])
+
return (
diff --git a/frontend/src/routes/recover-password.tsx b/frontend/src/routes/recover-password.tsx
index 89ad59faff..5b980097af 100644
--- a/frontend/src/routes/recover-password.tsx
+++ b/frontend/src/routes/recover-password.tsx
@@ -42,7 +42,7 @@ export const Route = createFileRoute("/recover-password")({
head: () => ({
meta: [
{
- title: "Recover Password - FastAPI Template",
+ title: "Recover Password - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/routes/reset-password.tsx b/frontend/src/routes/reset-password.tsx
index b9d5562ad2..f49afdb89d 100644
--- a/frontend/src/routes/reset-password.tsx
+++ b/frontend/src/routes/reset-password.tsx
@@ -60,7 +60,7 @@ export const Route = createFileRoute("/reset-password")({
head: () => ({
meta: [
{
- title: "Reset Password - FastAPI Template",
+ title: "Reset Password - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx
index 88c652c5b5..aeeb8c3e89 100644
--- a/frontend/src/routes/signup.tsx
+++ b/frontend/src/routes/signup.tsx
@@ -51,7 +51,7 @@ export const Route = createFileRoute("/signup")({
head: () => ({
meta: [
{
- title: "Sign Up - FastAPI Template",
+ title: "Sign Up - TemplateForge AI",
},
],
}),
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index b54b4c9828..e3da9ee532 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -2,6 +2,8 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string
+ readonly VITE_GOOGLE_CLIENT_ID?: string
+ readonly VITE_SHOW_DEVTOOLS?: string
}
interface ImportMeta {
diff --git a/img/forge_ai_logo.jpg b/img/forge_ai_logo.jpg
new file mode 100644
index 0000000000..b892623905
Binary files /dev/null and b/img/forge_ai_logo.jpg differ