Phase 6: PDF & Polish — PDF generation, admin users/settings, AI tool
Backend: - Setting + GeneratedPdf models, Alembic migration with default settings seed - PDF generation service (WeasyPrint + Jinja2 with autoescape) - Health report HTML template with memory entries + document excerpts - Admin user management: list, create, update (role/max_chats/is_active) - Admin settings: self_registration_enabled, default_max_chats - Self-registration check wired into auth register endpoint - default_max_chats applied to new user registrations - AI tool: generate_pdf creates health compilation PDFs - PDF compile/list/download API endpoints - WeasyPrint system deps added to Dockerfile Frontend: - PDF reports page with generate + download - Admin users page with create/edit/activate/deactivate - Admin settings page with self-registration toggle + max chats - Extended sidebar with PDF reports + admin users/settings links - English + Russian translations for all new UI Review fixes applied: - Jinja2 autoescape enabled (XSS prevention in PDFs) - db.refresh after flush (created_at populated correctly) - storage_path removed from API response (no internal path leak) - Role field uses Literal["user", "admin"] validation - React hooks called before conditional returns (rules of hooks) - default_max_chats setting now applied during registration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,8 @@
|
||||
"users": "Users",
|
||||
"context": "Context",
|
||||
"skills": "Skills",
|
||||
"personal_context": "My Context"
|
||||
"personal_context": "My Context",
|
||||
"pdf": "PDF Reports"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome, {{name}}",
|
||||
@@ -152,6 +153,27 @@
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"title": "PDF Reports",
|
||||
"generate": "Generate PDF",
|
||||
"title_placeholder": "Report title...",
|
||||
"no_pdfs": "No PDF reports generated yet."
|
||||
},
|
||||
"admin_users": {
|
||||
"title": "User Management",
|
||||
"create": "Create User",
|
||||
"edit": "Edit User",
|
||||
"max_chats": "Max Chats",
|
||||
"deactivate": "Deactivate",
|
||||
"activate": "Activate"
|
||||
},
|
||||
"admin_settings": {
|
||||
"title": "App Settings",
|
||||
"self_registration": "Self Registration",
|
||||
"self_registration_desc": "Allow users to register accounts themselves",
|
||||
"default_max_chats": "Default Max Chats",
|
||||
"default_max_chats_desc": "Default chat limit for new users"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"users": "Пользователи",
|
||||
"context": "Контекст",
|
||||
"skills": "Навыки",
|
||||
"personal_context": "Мой контекст"
|
||||
"personal_context": "Мой контекст",
|
||||
"pdf": "PDF отчёты"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Добро пожаловать, {{name}}",
|
||||
@@ -152,6 +153,27 @@
|
||||
"low": "Низкая"
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"title": "PDF отчёты",
|
||||
"generate": "Сгенерировать PDF",
|
||||
"title_placeholder": "Название отчёта...",
|
||||
"no_pdfs": "PDF отчёты ещё не создавались."
|
||||
},
|
||||
"admin_users": {
|
||||
"title": "Управление пользователями",
|
||||
"create": "Создать пользователя",
|
||||
"edit": "Редактировать пользователя",
|
||||
"max_chats": "Макс. чатов",
|
||||
"deactivate": "Деактивировать",
|
||||
"activate": "Активировать"
|
||||
},
|
||||
"admin_settings": {
|
||||
"title": "Настройки приложения",
|
||||
"self_registration": "Самостоятельная регистрация",
|
||||
"self_registration_desc": "Разрешить пользователям самим создавать аккаунты",
|
||||
"default_max_chats": "Лимит чатов по умолчанию",
|
||||
"default_max_chats_desc": "Лимит чатов для новых пользователей"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"error": "Произошла ошибка",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import api from "./client";
|
||||
|
||||
// --- Context ---
|
||||
|
||||
export interface ContextFile {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -13,9 +15,68 @@ export async function getPrimaryContext(): Promise<ContextFile | null> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePrimaryContext(
|
||||
content: string
|
||||
): Promise<ContextFile> {
|
||||
export async function updatePrimaryContext(content: string): Promise<ContextFile> {
|
||||
const { data } = await api.put<ContextFile>("/admin/context", { content });
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
max_chats: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function listUsers(limit = 50, offset = 0): Promise<AdminUserListResponse> {
|
||||
const { data } = await api.get<AdminUserListResponse>("/admin/users", { params: { limit, offset } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createUser(user: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
full_name?: string;
|
||||
role?: string;
|
||||
max_chats?: number;
|
||||
}): Promise<AdminUser> {
|
||||
const { data } = await api.post<AdminUser>("/admin/users", user);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
updates: { role?: string; is_active?: boolean; max_chats?: number; full_name?: string }
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await api.patch<AdminUser>(`/admin/users/${userId}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export interface AppSetting {
|
||||
key: string;
|
||||
value: unknown;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<AppSetting[]> {
|
||||
const { data } = await api.get<{ settings: AppSetting[] }>("/admin/settings");
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, value: unknown): Promise<AppSetting> {
|
||||
const { data } = await api.patch<AppSetting>(`/admin/settings/${key}`, { value });
|
||||
return data;
|
||||
}
|
||||
|
||||
39
frontend/src/api/pdf.ts
Normal file
39
frontend/src/api/pdf.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface GeneratedPdf {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
source_document_ids: string[] | null;
|
||||
source_chat_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PdfListResponse {
|
||||
pdfs: GeneratedPdf[];
|
||||
}
|
||||
|
||||
export async function compilePdf(title: string, documentIds?: string[]): Promise<GeneratedPdf> {
|
||||
const { data } = await api.post<GeneratedPdf>("/pdf/compile", {
|
||||
title,
|
||||
document_ids: documentIds || [],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listPdfs(): Promise<GeneratedPdf[]> {
|
||||
const { data } = await api.get<PdfListResponse>("/pdf/");
|
||||
return data.pdfs;
|
||||
}
|
||||
|
||||
export async function downloadPdf(pdfId: string, title: string): Promise<void> {
|
||||
const { data } = await api.get(`/pdf/${pdfId}/download`, { responseType: "blob" });
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${title}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Bell,
|
||||
Shield,
|
||||
BookOpen,
|
||||
FileOutput,
|
||||
} from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -22,11 +23,14 @@ const navItems = [
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: true, end: true },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: true, end: true },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: true, end: true },
|
||||
{ key: "pdf", to: "/pdf", icon: FileOutput, enabled: true, end: true },
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
{ key: "admin_context", to: "/admin/context", label: "layout.context" },
|
||||
{ key: "admin_skills", to: "/admin/skills", label: "layout.skills" },
|
||||
{ key: "admin_users", to: "/admin/users", label: "layout.users" },
|
||||
{ key: "admin_settings", to: "/admin/settings", label: "layout.settings" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
81
frontend/src/pages/admin/settings.tsx
Normal file
81
frontend/src/pages/admin/settings.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getSettings, updateSetting } from "@/api/admin";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
export function AdminSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const queryClient = useQueryClient();
|
||||
const [selfReg, setSelfReg] = useState(true);
|
||||
const [defaultMaxChats, setDefaultMaxChats] = useState(10);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["admin-settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const reg = settings.find((s) => s.key === "self_registration_enabled");
|
||||
const chats = settings.find((s) => s.key === "default_max_chats");
|
||||
if (reg) setSelfReg(Boolean(reg.value));
|
||||
if (chats) setDefaultMaxChats(Number(chats.value));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting("self_registration_enabled", selfReg);
|
||||
await updateSetting("default_max_chats", defaultMaxChats);
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-settings"] }),
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_settings.title")}</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
|
||||
<div>
|
||||
<p className="font-medium">{t("admin_settings.self_registration")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("admin_settings.self_registration_desc")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelfReg(!selfReg)}
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${selfReg ? "bg-primary" : "bg-muted"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${selfReg ? "translate-x-5" : "translate-x-0.5"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<p className="font-medium">{t("admin_settings.default_max_chats")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("admin_settings.default_max_chats_desc")}</p>
|
||||
<input
|
||||
type="number"
|
||||
value={defaultMaxChats}
|
||||
onChange={(e) => setDefaultMaxChats(Number(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="flex h-10 w-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" /> {mutation.isPending ? t("common.loading") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/pages/admin/users.tsx
Normal file
130
frontend/src/pages/admin/users.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { listUsers, createUser, updateUser, type AdminUser } from "@/api/admin";
|
||||
import { Plus, Pencil, UserCheck, UserX } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
||||
|
||||
// Form state
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [role, setRole] = useState("user");
|
||||
const [maxChats, setMaxChats] = useState(10);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin-users"],
|
||||
queryFn: () => listUsers(200),
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createUser({ email, username, password, full_name: fullName || undefined, role, max_chats: maxChats }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); resetForm(); },
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (data: { id: string; role?: string; is_active?: boolean; max_chats?: number }) => {
|
||||
const { id, ...updates } = data;
|
||||
return updateUser(id, updates);
|
||||
},
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); setEditing(null); },
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
setCreating(false); setEditing(null);
|
||||
setEmail(""); setUsername(""); setPassword(""); setFullName(""); setRole("user"); setMaxChats(10);
|
||||
}
|
||||
|
||||
function startEdit(u: AdminUser) {
|
||||
setEditing(u); setRole(u.role); setMaxChats(u.max_chats); setFullName(u.full_name || "");
|
||||
}
|
||||
|
||||
if (user?.role !== "admin") return <Navigate to="/" replace />;
|
||||
|
||||
if (creating) {
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_users.create")}</h1>
|
||||
<form onSubmit={(e) => { e.preventDefault(); createMut.mutate(); }} className="space-y-3">
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} required type="email" placeholder={t("auth.email")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required placeholder={t("auth.username")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} required type="password" placeholder={t("auth.password")} minLength={8} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
<input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder={t("auth.fullName")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button type="submit" disabled={createMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.create")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_users.edit")}: {editing.username}</h1>
|
||||
<form onSubmit={(e) => { e.preventDefault(); updateMut.mutate({ id: editing.id, role, max_chats: maxChats }); }} className="space-y-3">
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">{t("admin_users.max_chats")}</label>
|
||||
<input type="number" value={maxChats} onChange={(e) => setMaxChats(Number(e.target.value))} min={1} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button type="submit" disabled={updateMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.save")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_users.title")}</h1>
|
||||
<button onClick={() => setCreating(true)} className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
|
||||
<Plus className="h-4 w-4" /> {t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(data?.users || []).map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
|
||||
<div className={cn("h-2 w-2 rounded-full", u.is_active ? "bg-green-500" : "bg-red-500")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{u.username} <span className="text-xs text-muted-foreground">({u.email})</span></p>
|
||||
<p className="text-xs text-muted-foreground">{u.role} · {t("admin_users.max_chats")}: {u.max_chats}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startEdit(u)} className="rounded p-2 hover:bg-accent"><Pencil className="h-4 w-4" /></button>
|
||||
<button
|
||||
onClick={() => updateMut.mutate({ id: u.id, is_active: !u.is_active })}
|
||||
className="rounded p-2 hover:bg-accent"
|
||||
title={u.is_active ? t("admin_users.deactivate") : t("admin_users.activate")}
|
||||
>
|
||||
{u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/pdf.tsx
Normal file
83
frontend/src/pages/pdf.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { listPdfs, compilePdf, downloadPdf } from "@/api/pdf";
|
||||
import { FileText, Plus, Download } from "lucide-react";
|
||||
|
||||
export function PdfPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const { data: pdfs = [] } = useQuery({
|
||||
queryKey: ["pdfs"],
|
||||
queryFn: listPdfs,
|
||||
});
|
||||
|
||||
const compileMut = useMutation({
|
||||
mutationFn: () => compilePdf(title),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pdfs"] });
|
||||
setCreating(false);
|
||||
setTitle("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t("pdf.title")}</h1>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("pdf.generate")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("pdf.title_placeholder")}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setCreating(false)} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button
|
||||
onClick={() => compileMut.mutate()}
|
||||
disabled={!title.trim() || compileMut.isPending}
|
||||
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{compileMut.isPending ? t("common.loading") : t("pdf.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfs.length === 0 && !creating && (
|
||||
<p className="text-center text-muted-foreground py-8">{t("pdf.no_pdfs")}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{pdfs.map((pdf) => (
|
||||
<div key={pdf.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
|
||||
<FileText className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{pdf.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(pdf.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => downloadPdf(pdf.id, pdf.title)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border px-3 text-sm hover:bg-accent"
|
||||
>
|
||||
<Download className="h-4 w-4" /> {t("documents.download")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { AdminSkillsPage } from "@/pages/admin/skills";
|
||||
import { DocumentsPage } from "@/pages/documents";
|
||||
import { MemoryPage } from "@/pages/memory";
|
||||
import { NotificationsPage } from "@/pages/notifications";
|
||||
import { PdfPage } from "@/pages/pdf";
|
||||
import { AdminUsersPage } from "@/pages/admin/users";
|
||||
import { AdminSettingsPage } from "@/pages/admin/settings";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -37,8 +40,11 @@ export const router = createBrowserRouter([
|
||||
{ path: "notifications", element: <NotificationsPage /> },
|
||||
{ path: "skills", element: <SkillsPage /> },
|
||||
{ path: "profile/context", element: <PersonalContextPage /> },
|
||||
{ path: "pdf", element: <PdfPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
{ path: "admin/skills", element: <AdminSkillsPage /> },
|
||||
{ path: "admin/users", element: <AdminUsersPage /> },
|
||||
{ path: "admin/settings", element: <AdminSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user