From 65794be99878e72e289fc03c11680f23a367891b Mon Sep 17 00:00:00 2001 From: JustAnyone Date: Thu, 6 Nov 2025 20:47:08 +0200 Subject: [PATCH] Improve error handling, automatically renew access token --- Client/README.md | 2 - Client/app/api/api.ts | 112 +++++++++++++++--- Client/app/components/Header.tsx | 117 +++++++++++-------- Client/app/context/AuthContext.tsx | 40 ++++++- Client/app/routes/post.tsx | 179 ++++++++++++++++++----------- Client/app/routes/posts.tsx | 10 +- Client/app/routes/tags.tsx | 89 ++++++++------ 7 files changed, 374 insertions(+), 175 deletions(-) diff --git a/Client/README.md b/Client/README.md index 5d2b5ea..5c4780a 100644 --- a/Client/README.md +++ b/Client/README.md @@ -1,5 +1,3 @@ -TODO: automatic refresh based on auth token expiration - # Welcome to React Router! A modern, production-ready template for building full-stack React applications using React Router. diff --git a/Client/app/api/api.ts b/Client/app/api/api.ts index 5fe90a1..7ab7c11 100644 --- a/Client/app/api/api.ts +++ b/Client/app/api/api.ts @@ -6,10 +6,93 @@ import type { Tag } from "~/types/tag"; const ENDPOINT = 'http://localhost:5259/api'; +// This is a simplified session renewal handler. It's outside the class +// to avoid circular dependencies or complex context passing. +// It directly interacts with localStorage, which is where tokens are stored. +const renewSessionAndRetry = async (failedRequest: () => Promise): Promise => { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) return failedRequest(); + + try { + const renewResponse = await fetch(`${ENDPOINT}/auth/renew`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + + if (!renewResponse.ok) { + // If renew fails, logout by clearing storage + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + // Propagate the original failure, the app will react to the user being logged out. + return failedRequest(); + } + + const { accessToken, refreshToken: newRefreshToken } = await renewResponse.json(); + + // Update tokens in localStorage + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + // Update user object in localStorage + const userString = localStorage.getItem('user'); + if (userString) { + const user = JSON.parse(userString); + user.accessToken = accessToken; + user.refreshToken = newRefreshToken; + localStorage.setItem('user', JSON.stringify(user)); + } + + // Retry the original request with the new token. + // We assume the original request function knows how to get the new token from storage. + return await failedRequest(); + + } catch (error) { + console.error("Error during token renewal:", error); + // Logout on any renewal error + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + return failedRequest(); + } +}; + + export class API { + private static async _request(url: string, options: RequestInit): Promise { + // Add Authorization header if an access token is available + const token = localStorage.getItem('accessToken'); + if (token) { + options.headers = { + ...options.headers, + 'Authorization': `Bearer ${token}`, + }; + } + + let response = await fetch(url, options); + + // If response is 401, try to renew the token and retry the request once. + if (response.status === 401) { + console.log("Access token expired. Attempting to renew..."); + // To retry, we need to create a function that can be called again after renewal. + const retryRequest = () => { + const newToken = localStorage.getItem('accessToken'); + const newOptions = { ...options }; + if (newToken) { + newOptions.headers = { ...newOptions.headers, 'Authorization': `Bearer ${newToken}` }; + } + return fetch(url, newOptions); + }; + return await renewSessionAndRetry(retryRequest); + } + + return response; + } + private static async genericRequest(url: string, method: string, body: any = null, headers: Record = {}): Promise { - const localHeaders = { + const localHeaders: Record = { 'Content-Type': 'application/json', ...headers, }; @@ -20,14 +103,14 @@ export class API { if (body) { options.body = JSON.stringify(body); } - const response = await fetch(url, options); - return response; + return API._request(url, options); } private static async get(url: string, headers: Record = {}) { - const response = await fetch(url); + const response = await API._request(url, { headers }); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const errorData = await response.json().catch(() => ({ detail: `HTTP error! status: ${response.status}` })); + throw { ...errorData, status: response.status }; } return response.json(); } @@ -51,15 +134,15 @@ export class API { } public static async createTag(name: string, tagType: string, user: User): Promise { - return await API.post(`${ENDPOINT}/tags`, { name, type: tagType }, { Authorization: `Bearer ${user.accessToken}`}); + return await API.post(`${ENDPOINT}/tags`, { name, type: tagType }); } public static async updateTag(name: string, newType: string, user: User): Promise { - return await API.patch(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, { type: newType }, { Authorization: `Bearer ${user.accessToken}`}); + return await API.patch(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, { type: newType }); } public static async deleteTag(name: string, user: User): Promise { - return await API.delete(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, {}, { Authorization: `Bearer ${user.accessToken}`}); + return await API.delete(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, {}); } @@ -83,10 +166,11 @@ export class API { return await API.post(`${ENDPOINT}/posts`, { title, description, tags, filename, fileMimeType, fileSize - }, { Authorization: `Bearer ${user.accessToken}`}) + }); } public static async patchFileUploadUrl(uploadUrl: string, image: File, user: User): Promise { + // This request is special and doesn't use the generic helper return await fetch(uploadUrl, { method: 'PATCH', headers: { @@ -99,11 +183,11 @@ export class API { } public static async updatePost(postId: number, title: string, description: string, tags: string[], user: User): Promise { - return await API.patch(`${ENDPOINT}/posts/${postId}`, { title, description, tags }, {Authorization: `Bearer ${user.accessToken}`}); + return await API.patch(`${ENDPOINT}/posts/${postId}`, { title, description, tags }); } public static async deletePost(postId: number, user: User): Promise { - return await API.delete(`${ENDPOINT}/posts/${postId}`, {}, {Authorization: `Bearer ${user.accessToken}`}); + return await API.delete(`${ENDPOINT}/posts/${postId}`, {}); } /* Comment APIs */ @@ -113,15 +197,15 @@ export class API { } public static async postComment(postId: number, text: string, user: User): Promise { - return await API.post(`${ENDPOINT}/posts/${postId}/comments`, { text }, {Authorization: `Bearer ${user.accessToken}`}); + return await API.post(`${ENDPOINT}/posts/${postId}/comments`, { text }); } public static async updateComment(postId: number, commentId: number, newText: string, user: User): Promise { - return await API.patch(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, { text: newText }, {Authorization: `Bearer ${user.accessToken}`}); + return await API.patch(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, { text: newText }); } public static async deleteComment(postId: number, commentId: number, user: User): Promise { - return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {}, {Authorization: `Bearer ${user.accessToken}`}); + return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {}); } diff --git a/Client/app/components/Header.tsx b/Client/app/components/Header.tsx index bbd84e3..3931ca5 100644 --- a/Client/app/components/Header.tsx +++ b/Client/app/components/Header.tsx @@ -3,7 +3,7 @@ import Icon, { ICONS } from "./Icon"; import Modal from "./Modal"; import { useLocation, useNavigate } from "react-router"; import { API } from "~/api/api"; -import { useAuth } from "~/context/AuthContext"; +import { getApiErrorMessage, useAuth } from "~/context/AuthContext"; export default function Header() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -72,30 +72,35 @@ export default function Header() { return; } - const response = await API.createPostRequest( - title, description, - enteredTags, imageFile.name, - imageFile.type, imageFile.size, - user - ); - const data = await response.json(); - if (!response.ok) { - setError(data.detail || `Upload failed with status ${response.status}.`); - return; + try { + const response = await API.createPostRequest( + title, description, + enteredTags, imageFile.name, + imageFile.type, imageFile.size, + user + ); + const data = await response.json(); + if (!response.ok) { + setError(getApiErrorMessage(data)); + return; + } + const uploadUrl = data.fileUrl; + if (!uploadUrl) { + setError('Upload URL not provided by server.'); + return; + } + + const uploadResponse = await API.patchFileUploadUrl(uploadUrl, imageFile, user); + if (!uploadResponse.ok) { + const uploadError = await uploadResponse.json(); + setError(getApiErrorMessage(uploadError)); + return; + } + navigate(`/posts/${data.id}`); + onClose(); + } catch (err: any) { + setError(getApiErrorMessage(err)); } - const uploadUrl = data.fileUrl; - if (!uploadUrl) { - setError('Upload URL not provided by server.'); - return; - } - - const uploadResponse = await API.patchFileUploadUrl(uploadUrl, imageFile, user); - if (!uploadResponse.ok) { - setError(`Image upload failed with status ${uploadResponse.status}.`); - return; - } - navigate(`/posts/${data.id}`); - onClose(); }; return ( @@ -145,25 +150,29 @@ export default function Header() { return; } - const response = await API.register(username, email, password); - if (!response.ok) { - const data = await response.json(); - setError(data.detail || "Registration failed. Please try again."); - return; + try { + const response = await API.register(username, email, password); + if (!response.ok) { + const data = await response.json(); + setError(getApiErrorMessage(data)); + return; + } + + // Automatically log in the user after successful registration + const loginResponse = await API.login(email, password); + if (loginResponse.ok) { + const loginData = await loginResponse.json(); + login(loginData.accessToken, loginData.refreshToken); + } + + onClose(); + setUsername(''); + setEmail(''); + setPassword(''); + setConfirmPassword(''); + } catch (err: any) { + setError(getApiErrorMessage(err)); } - - // Automatically log in the user after successful registration - const loginResponse = await API.login(email, password); - if (loginResponse.ok) { - const loginData = await loginResponse.json(); - login(loginData.accessToken, loginData.refreshToken); - } - - onClose(); - setUsername(''); - setEmail(''); - setPassword(''); - setConfirmPassword(''); }; return ( @@ -202,16 +211,22 @@ export default function Header() { const handleSubmit: FormEventHandler = async (e) => { e.preventDefault(); - const response = await API.login(username, password); - if (!response.ok) { - setError("Login failed. Please check your credentials."); - return; + setError(''); + try { + const response = await API.login(username, password); + if (!response.ok) { + const err = await response.json(); + setError(getApiErrorMessage(err)); + return; + } + const data = await response.json(); + login(data.accessToken, data.refreshToken); + onClose(); + setUsername(''); + setPassword(''); + } catch (err: any) { + setError(getApiErrorMessage(err)); } - const data = await response.json(); - login(data.accessToken, data.refreshToken); - onClose(); - setUsername(''); - setPassword(''); }; return ( diff --git a/Client/app/context/AuthContext.tsx b/Client/app/context/AuthContext.tsx index 2c29aa8..74d4397 100644 --- a/Client/app/context/AuthContext.tsx +++ b/Client/app/context/AuthContext.tsx @@ -1,5 +1,16 @@ import { createContext, useContext, useEffect, useState } from "react"; import { jwtDecode } from "jwt-decode"; +import { API } from "~/api/api"; + +export function getApiErrorMessage(error: any): string { + if (error && Array.isArray(error.errors) && error.errors.length > 0) { + return error.errors.map((e: any) => e.description).join('\n'); + } + if (error && error.detail) { + return error.detail; + } + return "An unexpected error occurred."; +} export interface User { id: string; @@ -14,6 +25,7 @@ interface AuthContextType { isLoading: boolean; login: (accessToken: string, refreshToken: string) => void; logout: () => void; + renewSession: () => Promise; } const AuthContext = createContext({ @@ -21,6 +33,7 @@ const AuthContext = createContext({ isLoading: true, login: () => {}, logout: () => {}, + renewSession: async () => null, }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -80,8 +93,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.removeItem("refreshToken"); }; + const renewSession = async (): Promise => { + const stored = localStorage.getItem("user"); + if (!stored) { + logout(); + return null; + } + const currentUser: User = JSON.parse(stored); + + try { + const res = await API.renewToken(currentUser.refreshToken); + if (!res.ok) { + logout(); + return null; + } + const { accessToken, refreshToken: newRefreshToken } = await res.json(); + login(accessToken, newRefreshToken); + // We need to get the user object that login creates + const newStored = localStorage.getItem("user"); + return newStored ? JSON.parse(newStored) : null; + } catch (error) { + logout(); + return null; + } + }; + return ( - + {children} ); diff --git a/Client/app/routes/post.tsx b/Client/app/routes/post.tsx index 5c27128..6cbde40 100644 --- a/Client/app/routes/post.tsx +++ b/Client/app/routes/post.tsx @@ -10,7 +10,7 @@ import type { Post } from "~/types/post"; import Modal from "~/components/Modal"; import type { Comment } from "~/types/comment"; import Loading from "~/components/Loading"; -import { useAuth } from "~/context/AuthContext"; +import { getApiErrorMessage, useAuth } from "~/context/AuthContext"; import { NotFound } from "~/components/NotFound"; import type { PaginatedResponse } from "~/types/PaginatedResponse"; @@ -52,6 +52,7 @@ export default function Post() { } const [post, setPost] = useState(null); + const [notFound, setNotFound] = useState(false); const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false); const [paginatedComments, setPaginatedComments] = useState | null>(null); @@ -68,8 +69,19 @@ export default function Post() { console.log(`Fetching post with id: ${id}`); async function fetchData() { - const post = await API.fetchPostById(idNum); - setPost(post); + try { + const postData = await API.fetchPostById(idNum); + setPost(postData); + } catch (error: any) { + if (error.status === 404) { + setNotFound(true); + } else { + console.error("Failed to fetch post:", error); + // Optionally, you could navigate to a generic error page + // or show a toast notification. + alert(getApiErrorMessage(error)); + } + } } fetchData(); }, [id]); @@ -84,6 +96,12 @@ export default function Post() { const { user } = useAuth(); + if (notFound) { + return + + ; + } + if (!post) { return @@ -151,14 +169,18 @@ export default function Post() { const enteredTags = tags.split(' ').filter(t => t); if (title && description && tags) { - const res = await API.updatePost(post.id, title, description, enteredTags, user); - const data = await res.json(); - if (!res.ok) { - setError(data.detail || "Failed to update post."); - return; + try { + const res = await API.updatePost(post.id, title, description, enteredTags, user); + const data = await res.json(); + if (!res.ok) { + setError(getApiErrorMessage(data)); + return; + } + setPost(data); + setIsEditPostModalOpen(false); + } catch (err: any) { + setError(getApiErrorMessage(err)); } - setPost(data); - setIsEditPostModalOpen(false); } }; @@ -203,51 +225,61 @@ export default function Post() { */ const onPostDelete = async () => { if (!user) return; - const res = await API.deletePost(post.id, user) - if (!res.ok) { - alert("Failed to delete post."); - return; + try { + const res = await API.deletePost(post.id, user) + if (!res.ok) { + const err = await res.json(); + alert(`Failed to delete post: ${getApiErrorMessage(err)}`); + return; + } + await navigate('/') + } catch (err: any) { + alert(`Failed to delete post: ${getApiErrorMessage(err)}`); } - await navigate('/') }; const onAddComment = async (postId: number, commentText: string) => { if (!user) return; - const res = await API.postComment(postId, commentText, user); - if (!res.ok) { - alert("Failed to add comment"); - return; - } - const newComment: Comment = await res.json(); + try { + const res = await API.postComment(postId, commentText, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to add comment: ${getApiErrorMessage(err)}`); + return; + } + const newComment: Comment = await res.json(); - // Refetch comments if this is the first one. - if (!paginatedComments || paginatedComments.totalCount === 0) { - const comments = await API.fetchCommentsByPostId(idNum, 1); - setPaginatedComments(comments); - setCurrentCommentPage(1); - return; - } + // Refetch comments if this is the first one. + if (!paginatedComments || paginatedComments.totalCount === 0) { + const comments = await API.fetchCommentsByPostId(idNum, 1); + setPaginatedComments(comments); + setCurrentCommentPage(1); + return; + } - const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize); - if (currentCommentPage !== totalPages) { - setCurrentCommentPage(totalPages); - } else { - setPaginatedComments(prev => { - if (!prev) return null; - // If current page is not full, just add the comment - if (prev.items.length < prev.pageSize) { - return { - ...prev, - items: [...prev.items, newComment], - totalCount: prev.totalCount + 1 - }; - } else { - // This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch. - setCurrentCommentPage(totalPages); - return prev; - } - }); + const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize); + if (currentCommentPage !== totalPages) { + setCurrentCommentPage(totalPages); + } else { + setPaginatedComments(prev => { + if (!prev) return null; + // If current page is not full, just add the comment + if (prev.items.length < prev.pageSize) { + return { + ...prev, + items: [...prev.items, newComment], + totalCount: prev.totalCount + 1 + }; + } else { + // This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch. + setCurrentCommentPage(totalPages); + return prev; + } + }); + } + } catch (err: any) { + alert(`Failed to add comment: ${getApiErrorMessage(err)}`); } }; @@ -258,23 +290,28 @@ export default function Post() { const onDeleteComment = async (postId: number, commentId: number) => { if (!user || !paginatedComments) return; - const res = await API.deleteComment(postId, commentId, user); - if (!res.ok) { - alert("Failed to delete comment."); - return; - } - - setPaginatedComments(prev => { - if (!prev) return null; - const newItems = prev.items.filter(c => c.id !== commentId); - const newTotalCount = prev.totalCount - 1; - - if (newItems.length === 0 && currentCommentPage > 1) { - setCurrentCommentPage(currentCommentPage - 1); - return { ...prev, items: newItems, totalCount: newTotalCount }; + try { + const res = await API.deleteComment(postId, commentId, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to delete comment: ${getApiErrorMessage(err)}`); + return; } - return { ...prev, items: newItems, totalCount: newTotalCount }; - }); + + setPaginatedComments(prev => { + if (!prev) return null; + const newItems = prev.items.filter(c => c.id !== commentId); + const newTotalCount = prev.totalCount - 1; + + if (newItems.length === 0 && currentCommentPage > 1) { + setCurrentCommentPage(currentCommentPage - 1); + return { ...prev, items: newItems, totalCount: newTotalCount }; + } + return { ...prev, items: newItems, totalCount: newTotalCount }; + }); + } catch (err: any) { + alert(`Failed to delete comment: ${getApiErrorMessage(err)}`); + } }; @@ -311,13 +348,17 @@ export default function Post() { const handleSubmit: FormEventHandler = async (e) => { e.preventDefault(); if (newText && commentToEdit && newText !== commentToEdit.text && user) { - // TODO - const res = await API.updateComment(post.id, commentToEdit.id, newText, user); - if (!res.ok) { - alert("Failed to update comment."); - return; + try { + const res = await API.updateComment(post.id, commentToEdit.id, newText, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to update comment: ${getApiErrorMessage(err)}`); + return; + } + setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null); + } catch (err: any) { + alert(`Failed to update comment: ${getApiErrorMessage(err)}`); } - setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null); } setIsEditCommentModalOpen(false) }; diff --git a/Client/app/routes/posts.tsx b/Client/app/routes/posts.tsx index d4e7211..cde5805 100644 --- a/Client/app/routes/posts.tsx +++ b/Client/app/routes/posts.tsx @@ -1,4 +1,3 @@ - import type { Route } from "../+types/root"; import { useState, useEffect, type FormEventHandler } from "react"; @@ -12,6 +11,7 @@ import { API } from "../api/api"; import { useNavigate } from "react-router"; import type { PaginatedResponse } from "~/types/PaginatedResponse"; import Loading from "~/components/Loading"; +import { getApiErrorMessage } from "~/context/AuthContext"; interface PostCardProps { post: Post; @@ -113,8 +113,12 @@ export default function Posts() { useEffect(() => { const fetchData = async () => { - const data = await API.fetchPosts(currentPage, searchQuery); - setPaginatedData(data); + try { + const data = await API.fetchPosts(currentPage, searchQuery); + setPaginatedData(data); + } catch (err: any) { + alert(`Failed to fetch posts: ${getApiErrorMessage(err)}`); + } } fetchData(); }, [currentPage, searchQuery]); diff --git a/Client/app/routes/tags.tsx b/Client/app/routes/tags.tsx index 399e17d..1a5bdf1 100644 --- a/Client/app/routes/tags.tsx +++ b/Client/app/routes/tags.tsx @@ -6,7 +6,7 @@ import { useEffect, useState, type FormEventHandler } from "react"; import { API } from "~/api/api"; import Icon, { ICONS } from "~/components/Icon"; import Loading from "~/components/Loading"; -import { useAuth } from "~/context/AuthContext"; +import { getApiErrorMessage, useAuth } from "~/context/AuthContext"; import Modal from "~/components/Modal"; export function meta({}: Route.MetaArgs) { @@ -28,8 +28,12 @@ export default function Tags() { useEffect(() => { async function fetchData() { - const tags = await API.fetchTags(currentPage); - setTags(tags); + try { + const tags = await API.fetchTags(currentPage); + setTags(tags); + } catch (err: any) { + alert(`Failed to fetch tags: ${getApiErrorMessage(err)}`); + } } fetchData(); }, [currentPage]); @@ -158,49 +162,64 @@ export default function Tags() { setIsEditTagModalOpen(false); return; }; - const res = await API.updateTag(tag.name, newType, user); - if (!res.ok) { - alert(`Failed to update tag: ${await res.text()}`); - return; + try { + const res = await API.updateTag(tag.name, newType, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to update tag: ${getApiErrorMessage(err)}`); + return; + } + const updatedTag = await res.json(); + setTags(prev => prev ? { ...prev, items: prev.items.map(t => t.name === updatedTag.name ? updatedTag : t) } : null); + setIsEditTagModalOpen(false); + } catch (err: any) { + alert(`Failed to update tag: ${getApiErrorMessage(err)}`); } - const updatedTag = await res.json(); - setTags(prev => prev ? { ...prev, items: prev.items.map(t => t.name === updatedTag.name ? updatedTag : t) } : null); - setIsEditTagModalOpen(false); } async function onCreateTag(name: string, type: TagType) { if (!user) return; - const res = await API.createTag(name, type, user); - if (!res.ok) { - alert(`Failed to create tag: ${await res.text()}`); - return; + try { + const res = await API.createTag(name, type, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to create tag: ${getApiErrorMessage(err)}`); + return; + } + const newTag = await res.json(); + // Reset to first page to see the new tag + if (currentPage !== 1) { + setCurrentPage(1); + } else { + setTags(prev => prev ? { ...prev, items: [newTag, ...prev.items] } : { items: [newTag], totalCount: 1, currentPage: 1, pageSize: 20 }); + } + setIsCreateTagModalOpen(false); + } catch (err: any) { + alert(`Failed to create tag: ${getApiErrorMessage(err)}`); } - const newTag = await res.json(); - // Reset to first page to see the new tag - if (currentPage !== 1) { - setCurrentPage(1); - } else { - setTags(prev => prev ? { ...prev, items: [newTag, ...prev.items] } : { items: [newTag], totalCount: 1, currentPage: 1, pageSize: 20 }); - } - setIsCreateTagModalOpen(false); } async function onDeleteTag(tag: Tag) { if (!user) return - const res = await API.deleteTag(tag.name, user); - if (!res.ok) { - alert("Failed to delete tag"); - return; - } - setTags(prev => { - if (!prev) return null; - const newItems = prev.items.filter(t => t.name !== tag.name); - // If the page is now empty and it's not the first page, go to the previous page - if (newItems.length === 0 && currentPage > 1) { - setCurrentPage(currentPage - 1); + try { + const res = await API.deleteTag(tag.name, user); + if (!res.ok) { + const err = await res.json(); + alert(`Failed to delete tag: ${getApiErrorMessage(err)}`); + return; } - return { ...prev, items: newItems, total: prev.totalCount - 1 }; - }); + setTags(prev => { + if (!prev) return null; + const newItems = prev.items.filter(t => t.name !== tag.name); + // If the page is now empty and it's not the first page, go to the previous page + if (newItems.length === 0 && currentPage > 1) { + setCurrentPage(currentPage - 1); + } + return { ...prev, items: newItems, totalCount: prev.totalCount - 1 }; + }); + } catch (err: any) { + alert(`Failed to delete tag: ${getApiErrorMessage(err)}`); + } } const paginate = (pageNumber: number) => setCurrentPage(pageNumber);