Improve error handling, automatically renew access token
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
TODO: automatic refresh based on auth token expiration
|
|
||||||
|
|
||||||
# Welcome to React Router!
|
# Welcome to React Router!
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|||||||
@@ -6,10 +6,93 @@ import type { Tag } from "~/types/tag";
|
|||||||
|
|
||||||
const ENDPOINT = 'http://localhost:5259/api';
|
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<Response>): Promise<Response> => {
|
||||||
|
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 {
|
export class API {
|
||||||
|
|
||||||
|
private static async _request(url: string, options: RequestInit): Promise<Response> {
|
||||||
|
// 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<string, string> = {}): Promise<Response> {
|
private static async genericRequest(url: string, method: string, body: any = null, headers: Record<string, string> = {}): Promise<Response> {
|
||||||
const localHeaders = {
|
const localHeaders: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...headers,
|
...headers,
|
||||||
};
|
};
|
||||||
@@ -20,14 +103,14 @@ export class API {
|
|||||||
if (body) {
|
if (body) {
|
||||||
options.body = JSON.stringify(body);
|
options.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
const response = await fetch(url, options);
|
return API._request(url, options);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async get(url: string, headers: Record<string, string> = {}) {
|
private static async get(url: string, headers: Record<string, string> = {}) {
|
||||||
const response = await fetch(url);
|
const response = await API._request(url, { headers });
|
||||||
if (!response.ok) {
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -51,15 +134,15 @@ export class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async createTag(name: string, tagType: string, user: User): Promise<Response> {
|
public static async createTag(name: string, tagType: string, user: User): Promise<Response> {
|
||||||
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<Response> {
|
public static async updateTag(name: string, newType: string, user: User): Promise<Response> {
|
||||||
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<Response> {
|
public static async deleteTag(name: string, user: User): Promise<Response> {
|
||||||
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`, {
|
return await API.post(`${ENDPOINT}/posts`, {
|
||||||
title, description, tags, filename,
|
title, description, tags, filename,
|
||||||
fileMimeType, fileSize
|
fileMimeType, fileSize
|
||||||
}, { Authorization: `Bearer ${user.accessToken}`})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async patchFileUploadUrl(uploadUrl: string, image: File, user: User): Promise<Response> {
|
public static async patchFileUploadUrl(uploadUrl: string, image: File, user: User): Promise<Response> {
|
||||||
|
// This request is special and doesn't use the generic helper
|
||||||
return await fetch(uploadUrl, {
|
return await fetch(uploadUrl, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -99,11 +183,11 @@ export class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async updatePost(postId: number, title: string, description: string, tags: string[], user: User): Promise<Response> {
|
public static async updatePost(postId: number, title: string, description: string, tags: string[], user: User): Promise<Response> {
|
||||||
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<Response> {
|
public static async deletePost(postId: number, user: User): Promise<Response> {
|
||||||
return await API.delete(`${ENDPOINT}/posts/${postId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
return await API.delete(`${ENDPOINT}/posts/${postId}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comment APIs */
|
/* Comment APIs */
|
||||||
@@ -113,15 +197,15 @@ export class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async postComment(postId: number, text: string, user: User): Promise<Response> {
|
public static async postComment(postId: number, text: string, user: User): Promise<Response> {
|
||||||
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<Response> {
|
public static async updateComment(postId: number, commentId: number, newText: string, user: User): Promise<Response> {
|
||||||
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<Response> {
|
public static async deleteComment(postId: number, commentId: number, user: User): Promise<Response> {
|
||||||
return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Icon, { ICONS } from "./Icon";
|
|||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { API } from "~/api/api";
|
import { API } from "~/api/api";
|
||||||
import { useAuth } from "~/context/AuthContext";
|
import { getApiErrorMessage, useAuth } from "~/context/AuthContext";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@@ -72,30 +72,35 @@ export default function Header() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await API.createPostRequest(
|
try {
|
||||||
title, description,
|
const response = await API.createPostRequest(
|
||||||
enteredTags, imageFile.name,
|
title, description,
|
||||||
imageFile.type, imageFile.size,
|
enteredTags, imageFile.name,
|
||||||
user
|
imageFile.type, imageFile.size,
|
||||||
);
|
user
|
||||||
const data = await response.json();
|
);
|
||||||
if (!response.ok) {
|
const data = await response.json();
|
||||||
setError(data.detail || `Upload failed with status ${response.status}.`);
|
if (!response.ok) {
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -145,25 +150,29 @@ export default function Header() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await API.register(username, email, password);
|
try {
|
||||||
if (!response.ok) {
|
const response = await API.register(username, email, password);
|
||||||
const data = await response.json();
|
if (!response.ok) {
|
||||||
setError(data.detail || "Registration failed. Please try again.");
|
const data = await response.json();
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -202,16 +211,22 @@ export default function Header() {
|
|||||||
|
|
||||||
const handleSubmit: FormEventHandler = async (e) => {
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const response = await API.login(username, password);
|
setError('');
|
||||||
if (!response.ok) {
|
try {
|
||||||
setError("Login failed. Please check your credentials.");
|
const response = await API.login(username, password);
|
||||||
return;
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { jwtDecode } from "jwt-decode";
|
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +25,7 @@ interface AuthContextType {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (accessToken: string, refreshToken: string) => void;
|
login: (accessToken: string, refreshToken: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
renewSession: () => Promise<User | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -21,6 +33,7 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
login: () => {},
|
login: () => {},
|
||||||
logout: () => {},
|
logout: () => {},
|
||||||
|
renewSession: async () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -80,8 +93,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renewSession = async (): Promise<User | null> => {
|
||||||
|
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 (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
|
<AuthContext.Provider value={{ user, isLoading, login, logout, renewSession }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { Post } from "~/types/post";
|
|||||||
import Modal from "~/components/Modal";
|
import Modal from "~/components/Modal";
|
||||||
import type { Comment } from "~/types/comment";
|
import type { Comment } from "~/types/comment";
|
||||||
import Loading from "~/components/Loading";
|
import Loading from "~/components/Loading";
|
||||||
import { useAuth } from "~/context/AuthContext";
|
import { getApiErrorMessage, useAuth } from "~/context/AuthContext";
|
||||||
import { NotFound } from "~/components/NotFound";
|
import { NotFound } from "~/components/NotFound";
|
||||||
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ export default function Post() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [post, setPost] = useState<Post|null>(null);
|
const [post, setPost] = useState<Post|null>(null);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false);
|
const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false);
|
||||||
|
|
||||||
const [paginatedComments, setPaginatedComments] = useState<PaginatedResponse<Comment> | null>(null);
|
const [paginatedComments, setPaginatedComments] = useState<PaginatedResponse<Comment> | null>(null);
|
||||||
@@ -68,8 +69,19 @@ export default function Post() {
|
|||||||
console.log(`Fetching post with id: ${id}`);
|
console.log(`Fetching post with id: ${id}`);
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const post = await API.fetchPostById(idNum);
|
try {
|
||||||
setPost(post);
|
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();
|
fetchData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -84,6 +96,12 @@ export default function Post() {
|
|||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return <RootLayout>
|
||||||
|
<NotFound />
|
||||||
|
</RootLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return <RootLayout>
|
return <RootLayout>
|
||||||
<Loading />
|
<Loading />
|
||||||
@@ -151,14 +169,18 @@ export default function Post() {
|
|||||||
const enteredTags = tags.split(' ').filter(t => t);
|
const enteredTags = tags.split(' ').filter(t => t);
|
||||||
|
|
||||||
if (title && description && tags) {
|
if (title && description && tags) {
|
||||||
const res = await API.updatePost(post.id, title, description, enteredTags, user);
|
try {
|
||||||
const data = await res.json();
|
const res = await API.updatePost(post.id, title, description, enteredTags, user);
|
||||||
if (!res.ok) {
|
const data = await res.json();
|
||||||
setError(data.detail || "Failed to update post.");
|
if (!res.ok) {
|
||||||
return;
|
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 () => {
|
const onPostDelete = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const res = await API.deletePost(post.id, user)
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.deletePost(post.id, user)
|
||||||
alert("Failed to delete post.");
|
if (!res.ok) {
|
||||||
return;
|
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) => {
|
const onAddComment = async (postId: number, commentText: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const res = await API.postComment(postId, commentText, user);
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.postComment(postId, commentText, user);
|
||||||
alert("Failed to add comment");
|
if (!res.ok) {
|
||||||
return;
|
const err = await res.json();
|
||||||
}
|
alert(`Failed to add comment: ${getApiErrorMessage(err)}`);
|
||||||
const newComment: Comment = await res.json();
|
return;
|
||||||
|
}
|
||||||
|
const newComment: Comment = await res.json();
|
||||||
|
|
||||||
// Refetch comments if this is the first one.
|
// Refetch comments if this is the first one.
|
||||||
if (!paginatedComments || paginatedComments.totalCount === 0) {
|
if (!paginatedComments || paginatedComments.totalCount === 0) {
|
||||||
const comments = await API.fetchCommentsByPostId(idNum, 1);
|
const comments = await API.fetchCommentsByPostId(idNum, 1);
|
||||||
setPaginatedComments(comments);
|
setPaginatedComments(comments);
|
||||||
setCurrentCommentPage(1);
|
setCurrentCommentPage(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize);
|
const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize);
|
||||||
if (currentCommentPage !== totalPages) {
|
if (currentCommentPage !== totalPages) {
|
||||||
setCurrentCommentPage(totalPages);
|
setCurrentCommentPage(totalPages);
|
||||||
} else {
|
} else {
|
||||||
setPaginatedComments(prev => {
|
setPaginatedComments(prev => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
// If current page is not full, just add the comment
|
// If current page is not full, just add the comment
|
||||||
if (prev.items.length < prev.pageSize) {
|
if (prev.items.length < prev.pageSize) {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
items: [...prev.items, newComment],
|
items: [...prev.items, newComment],
|
||||||
totalCount: prev.totalCount + 1
|
totalCount: prev.totalCount + 1
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch.
|
// This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch.
|
||||||
setCurrentCommentPage(totalPages);
|
setCurrentCommentPage(totalPages);
|
||||||
return prev;
|
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) => {
|
const onDeleteComment = async (postId: number, commentId: number) => {
|
||||||
if (!user || !paginatedComments) return;
|
if (!user || !paginatedComments) return;
|
||||||
const res = await API.deleteComment(postId, commentId, user);
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.deleteComment(postId, commentId, user);
|
||||||
alert("Failed to delete comment.");
|
if (!res.ok) {
|
||||||
return;
|
const err = await res.json();
|
||||||
}
|
alert(`Failed to delete comment: ${getApiErrorMessage(err)}`);
|
||||||
|
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 };
|
|
||||||
}
|
}
|
||||||
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) => {
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (newText && commentToEdit && newText !== commentToEdit.text && user) {
|
if (newText && commentToEdit && newText !== commentToEdit.text && user) {
|
||||||
// TODO
|
try {
|
||||||
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
|
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert("Failed to update comment.");
|
const err = await res.json();
|
||||||
return;
|
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)
|
setIsEditCommentModalOpen(false)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import type { Route } from "../+types/root";
|
import type { Route } from "../+types/root";
|
||||||
import { useState, useEffect, type FormEventHandler } from "react";
|
import { useState, useEffect, type FormEventHandler } from "react";
|
||||||
|
|
||||||
@@ -12,6 +11,7 @@ import { API } from "../api/api";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||||
import Loading from "~/components/Loading";
|
import Loading from "~/components/Loading";
|
||||||
|
import { getApiErrorMessage } from "~/context/AuthContext";
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
@@ -113,8 +113,12 @@ export default function Posts() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const data = await API.fetchPosts(currentPage, searchQuery);
|
try {
|
||||||
setPaginatedData(data);
|
const data = await API.fetchPosts(currentPage, searchQuery);
|
||||||
|
setPaginatedData(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Failed to fetch posts: ${getApiErrorMessage(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [currentPage, searchQuery]);
|
}, [currentPage, searchQuery]);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useState, type FormEventHandler } from "react";
|
|||||||
import { API } from "~/api/api";
|
import { API } from "~/api/api";
|
||||||
import Icon, { ICONS } from "~/components/Icon";
|
import Icon, { ICONS } from "~/components/Icon";
|
||||||
import Loading from "~/components/Loading";
|
import Loading from "~/components/Loading";
|
||||||
import { useAuth } from "~/context/AuthContext";
|
import { getApiErrorMessage, useAuth } from "~/context/AuthContext";
|
||||||
import Modal from "~/components/Modal";
|
import Modal from "~/components/Modal";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
@@ -28,8 +28,12 @@ export default function Tags() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const tags = await API.fetchTags(currentPage);
|
try {
|
||||||
setTags(tags);
|
const tags = await API.fetchTags(currentPage);
|
||||||
|
setTags(tags);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Failed to fetch tags: ${getApiErrorMessage(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
@@ -158,49 +162,64 @@ export default function Tags() {
|
|||||||
setIsEditTagModalOpen(false);
|
setIsEditTagModalOpen(false);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
const res = await API.updateTag(tag.name, newType, user);
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.updateTag(tag.name, newType, user);
|
||||||
alert(`Failed to update tag: ${await res.text()}`);
|
if (!res.ok) {
|
||||||
return;
|
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) {
|
async function onCreateTag(name: string, type: TagType) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const res = await API.createTag(name, type, user);
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.createTag(name, type, user);
|
||||||
alert(`Failed to create tag: ${await res.text()}`);
|
if (!res.ok) {
|
||||||
return;
|
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) {
|
async function onDeleteTag(tag: Tag) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
const res = await API.deleteTag(tag.name, user);
|
try {
|
||||||
if (!res.ok) {
|
const res = await API.deleteTag(tag.name, user);
|
||||||
alert("Failed to delete tag");
|
if (!res.ok) {
|
||||||
return;
|
const err = await res.json();
|
||||||
}
|
alert(`Failed to delete tag: ${getApiErrorMessage(err)}`);
|
||||||
setTags(prev => {
|
return;
|
||||||
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, 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);
|
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||||
|
|||||||
Reference in New Issue
Block a user