Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions frontend/module/components/document-tags/DocumentTagsInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"use client";

import { useEffect, useRef, useState } from "react";

interface Tag {
name: string;
}

interface DocumentTagsInputProps {
documentId: string;
initialTags?: string[];
readOnly?: boolean;
}

export default function DocumentTagsInput({
documentId,
initialTags = [],
readOnly = false,
}: DocumentTagsInputProps) {
const [tags, setTags] = useState<string[]>(initialTags);
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const token = () => localStorage.getItem("access_token") ?? "";
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "";

useEffect(() => {
if (!input.trim()) {
setSuggestions([]);
setShowDropdown(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`${apiBase}/api/module/tags`, {
headers: { Authorization: `Bearer ${token()}` },
});
if (res.ok) {
const data: Tag[] = await res.json();
const matches = data
.map((t) => t.name)
.filter(
(name) =>
name.toLowerCase().includes(input.toLowerCase()) &&
!tags.includes(name)
);
setSuggestions(matches);
setShowDropdown(matches.length > 0);
}
} catch {}
}, 250);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, tags, apiBase]);

async function addTag(name: string) {
const normalized = name.trim().toLowerCase();
if (!normalized || tags.includes(normalized)) return;
setTags((prev) => [...prev, normalized]);
setInput("");
setSuggestions([]);
setShowDropdown(false);
try {
await fetch(`${apiBase}/api/module/documents/${documentId}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token()}`,
},
body: JSON.stringify({ tagName: normalized }),
});
} catch {
setTags((prev) => prev.filter((t) => t !== normalized));
}
}

async function removeTag(name: string) {
setTags((prev) => prev.filter((t) => t !== name));
try {
await fetch(
`${apiBase}/api/module/documents/${documentId}/tags/${encodeURIComponent(name)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token()}` },
}
);
} catch {
setTags((prev) => [...prev, name]);
}
}

function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if ((e.key === "Enter" || e.key === ",") && input.trim()) {
e.preventDefault();
addTag(input);
}
if (e.key === "Backspace" && !input && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
}

return (
<div className="relative">
<div
className={`flex flex-wrap items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-2 ${
readOnly ? "bg-gray-50" : "cursor-text bg-white focus-within:ring-2 focus-within:ring-blue-500"
}`}
onClick={() => !readOnly && inputRef.current?.focus()}
>
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700"
>
{tag}
{!readOnly && (
<button
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
aria-label={`Remove tag ${tag}`}
className="ml-0.5 text-blue-500 hover:text-blue-800"
>
×
</button>
)}
</span>
))}
{!readOnly && (
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
placeholder={tags.length === 0 ? "Add tags…" : ""}
className="min-w-[6rem] flex-1 bg-transparent text-sm outline-none"
/>
)}
</div>

{showDropdown && (
<ul className="absolute z-20 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-md">
{suggestions.map((s) => (
<li key={s}>
<button
onMouseDown={(e) => e.preventDefault()}
onClick={() => addTag(s)}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{s}
</button>
</li>
))}
</ul>
)}
</div>
);
}
107 changes: 107 additions & 0 deletions frontend/module/hooks/useDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { get, post, del } from "../lib/api-client";

interface Document {
id: string;
title: string;
status: string;
riskScore: number | null;
uploadedAt: string;
}

interface DocumentsFilters {
status?: string;
page?: number;
limit?: number;
}

interface UseDocumentsResult {
documents: Document[];
total: number;
isLoading: boolean;
error: string | null;
uploadDocument: (file: File) => Promise<void>;
deleteDocument: (id: string) => Promise<void>;
refetch: () => void;
}

export function useDocuments(filters: DocumentsFilters = {}): UseDocumentsResult {
const [documents, setDocuments] = useState<Document[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);

const refetch = useCallback(() => setTick((t) => t + 1), []);

useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);

const params: Record<string, unknown> = {};
if (filters.status) params.status = filters.status;
if (filters.page) params.page = filters.page;
if (filters.limit) params.limit = filters.limit;

get<{ data: Document[]; total: number } | Document[]>(
"/api/module/documents",
params
)
.then((data) => {
if (cancelled) return;
if (Array.isArray(data)) {
setDocuments(data);
setTotal(data.length);
} else {
setDocuments(data.data);
setTotal(data.total);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err.message ?? "Failed to fetch documents.");
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});

return () => {
cancelled = true;
};
}, [filters.status, filters.page, filters.limit, tick]);

const uploadDocument = useCallback(async (file: File) => {
const formData = new FormData();
formData.append("file", file);

const optimisticDoc: Document = {
id: `temp-${Date.now()}`,
title: file.name,
status: "PENDING",
riskScore: null,
uploadedAt: new Date().toISOString(),
};
setDocuments((prev) => [optimisticDoc, ...prev]);

try {
const uploaded = await post<Document>("/api/documents/upload", formData);
setDocuments((prev) =>
prev.map((d) => (d.id === optimisticDoc.id ? uploaded : d))
);
} catch (err) {
setDocuments((prev) => prev.filter((d) => d.id !== optimisticDoc.id));
throw err;
}
}, []);

const deleteDocument = useCallback(async (id: string) => {
await del(`/api/module/documents/${id}`);
setDocuments((prev) => prev.filter((d) => d.id !== id));
}, []);

return { documents, total, isLoading, error, uploadDocument, deleteDocument, refetch };
}

export default useDocuments;
97 changes: 97 additions & 0 deletions frontend/module/lib/api-client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import axios, { AxiosInstance, InternalAxiosRequestConfig } from "axios";

const apiClient: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "",
});

function getToken(): string {
return typeof localStorage !== "undefined"
? (localStorage.getItem("access_token") ?? "")
: "";
}

function setToken(token: string) {
if (typeof localStorage !== "undefined") {
localStorage.setItem("access_token", token);
}
}

function clearToken() {
if (typeof localStorage !== "undefined") {
localStorage.removeItem("access_token");
}
}

apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

let isRefreshing = false;
let refreshQueue: Array<(token: string) => void> = [];

apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;

if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}

if (isRefreshing) {
return new Promise((resolve) => {
refreshQueue.push((token: string) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
});
});
}

original._retry = true;
isRefreshing = true;

try {
const { data } = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`,
{},
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
const newToken: string = data.access_token ?? data.token;
setToken(newToken);
refreshQueue.forEach((cb) => cb(newToken));
refreshQueue = [];
original.headers.Authorization = `Bearer ${newToken}`;
return apiClient(original);
} catch {
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/auth/login";
}
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
);

export function get<T = unknown>(url: string, params?: Record<string, unknown>) {
return apiClient.get<T>(url, { params }).then((r) => r.data);
}

export function post<T = unknown>(url: string, body?: unknown) {
return apiClient.post<T>(url, body).then((r) => r.data);
}

export function patch<T = unknown>(url: string, body?: unknown) {
return apiClient.patch<T>(url, body).then((r) => r.data);
}

export function del<T = unknown>(url: string) {
return apiClient.delete<T>(url).then((r) => r.data);
}

export default apiClient;
Loading