refactor(all): migrate from React to Next.js

This commit is contained in:
2026-05-20 09:46:17 +03:30
parent dacbd3a328
commit f23108cda3
86 changed files with 2831 additions and 2679 deletions

View File

@@ -1,7 +1,8 @@
import type * as Types from './types';
import { apiBaseUrl } from '@/lib/site';
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
apiBaseUrl;
type ApiErrorBody = {
error?: string;
@@ -18,8 +19,32 @@ class ApiClient {
this.baseUrl = baseUrl;
}
private getStorageValue(key: string) {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(key);
}
private setStorageValue(key: string, value: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, value);
}
private removeStorageValue(key: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(key);
}
private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -27,7 +52,7 @@ class ApiClient {
}
private async refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
const refreshToken = this.getStorageValue('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token available');
}
@@ -39,14 +64,14 @@ class ApiClient {
});
if (!response.ok) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this.removeStorageValue('access_token');
this.removeStorageValue('refresh_token');
throw new Error('Session expired. Please login again.');
}
const data: Types.TokenSchema = await response.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
this.setStorageValue('access_token', data.access_token);
this.setStorageValue('refresh_token', data.refresh_token);
return data.access_token;
}
@@ -75,7 +100,7 @@ class ApiClient {
const response = await fetch(url, config);
// Handle 401 with automatic token refresh
if (response.status === 401 && localStorage.getItem('refresh_token')) {
if (response.status === 401 && this.getStorageValue('refresh_token')) {
if (!this.isRefreshing) {
this.isRefreshing = true;
try {
@@ -184,7 +209,6 @@ class ApiClient {
}
async getProfile() {
const token = localStorage.getItem('access_token');
return this.request<Types.UserProfileSchema>('/api/auth/profile'
);
}
@@ -200,7 +224,7 @@ class ApiClient {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
method: 'POST',
headers: {
@@ -469,7 +493,7 @@ class ApiClient {
}
async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
return this.request<Types.EventSchema>(`/api/events/${eventId}`, {
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -614,7 +638,7 @@ class ApiClient {
if (data.description) formData.append('description', data.description);
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
method: 'POST',
headers: {

9
src/lib/helmet.tsx Normal file
View File

@@ -0,0 +1,9 @@
import * as React from "react";
export function Helmet({ children }: { children?: React.ReactNode }) {
return null;
}
export function HelmetProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

103
src/lib/public-api.ts Normal file
View File

@@ -0,0 +1,103 @@
import type * as Types from "@/lib/types";
import { apiBaseUrl } from "@/lib/site";
const DEFAULT_REVALIDATE_SECONDS = 300;
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
export class PublicApiError extends Error {
status: number;
constructor(path: string, status: number) {
super(`Request failed for ${path}: ${status}`);
this.name = "PublicApiError";
this.status = status;
}
}
const buildUrl = (path: string, params?: Record<string, QueryValue>) => {
const url = new URL(`${apiBaseUrl}${path}`);
if (!params) {
return url.toString();
}
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item === undefined || item === null || item === "") {
return;
}
url.searchParams.append(key, String(item));
});
return;
}
if (value === undefined || value === null || value === "") {
return;
}
url.searchParams.append(key, String(value));
});
return url.toString();
};
async function requestJson<T>(
path: string,
options?: {
params?: Record<string, QueryValue>;
revalidate?: number;
},
) {
const response = await fetch(buildUrl(path, options?.params), {
next: { revalidate: options?.revalidate ?? DEFAULT_REVALIDATE_SECONDS },
});
if (!response.ok) {
throw new PublicApiError(path, response.status);
}
return (await response.json()) as T;
}
export async function getPublicPosts(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();
return requestJson<Types.PostListSchema[]>("/api/blog/posts", {
params: {
limit: options?.limit ?? 50,
...(search ? { search } : {}),
},
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicPost(slug: string) {
return requestJson<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
}
export async function getPublicEvents(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();
return requestJson<Types.EventListItemSchema[]>("/api/events/", {
params: {
status: ["published", "completed"],
limit: options?.limit ?? 50,
...(search ? { search } : {}),
},
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicEventBySlug(slug: string) {
return requestJson<Types.EventDetailSchema>(
`/api/events/slug/${encodeURIComponent(slug)}`,
);
}

143
src/lib/router.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import NextLink from "next/link";
import {
useParams as useNextParams,
usePathname,
useRouter,
} from "next/navigation";
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
replace?: boolean;
prefetch?: boolean;
children: React.ReactNode;
};
type NavigateFunction = (
to: string | number,
options?: {
replace?: boolean;
},
) => void;
type NavLinkProps = Omit<LinkProps, "className"> & {
className?: string | ((state: { isActive: boolean }) => string);
};
export function Link({ to, replace, prefetch, children, ...props }: LinkProps) {
return (
<NextLink href={to} replace={replace} prefetch={prefetch} {...props}>
{children}
</NextLink>
);
}
export function NavLink({
to,
className,
children,
replace,
prefetch,
...props
}: NavLinkProps) {
const pathname = usePathname();
const isActive =
to === "/"
? pathname === "/"
: pathname === to || Boolean(pathname?.startsWith(`${to}/`));
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
return (
<Link
to={to}
replace={replace}
prefetch={prefetch}
className={resolvedClassName}
{...props}
>
{children}
</Link>
);
}
export function useNavigate(): NavigateFunction {
const router = useRouter();
return React.useCallback(
(to: string | number, options?: { replace?: boolean }) => {
if (typeof to === "number") {
if (to === -1) {
router.back();
return;
}
if (typeof window !== "undefined") {
window.history.go(to);
}
return;
}
if (options?.replace) {
router.replace(to);
return;
}
router.push(to);
},
[router],
);
}
export function useParams<T extends Record<string, string | string[] | undefined>>() {
return useNextParams() as T;
}
export function useLocation() {
const pathname = usePathname();
const [search, setSearch] = React.useState("");
React.useEffect(() => {
if (typeof window === "undefined") {
setSearch("");
return;
}
setSearch(window.location.search || "");
}, [pathname]);
return React.useMemo(
() => ({
pathname: pathname ?? "",
search,
hash: "",
}),
[pathname, search],
);
}
export function useSearchParams() {
const location = useLocation();
return React.useMemo(
() => [new URLSearchParams(location.search)] as const,
[location.search],
);
}
export function Navigate({
to,
replace = false,
}: {
to: string;
replace?: boolean;
}) {
const navigate = useNavigate();
React.useEffect(() => {
navigate(to, { replace });
}, [navigate, replace, to]);
return null;
}

17
src/lib/site.ts Normal file
View File

@@ -0,0 +1,17 @@
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
export const siteUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:8080",
);
export const apiBaseUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000",
);
export const toAbsoluteUrl = (value?: string | null, fallbackBase = siteUrl) => {
if (!value) return undefined;
if (/^https?:\/\//i.test(value)) return value;
const normalizedBase = trimTrailingSlash(fallbackBase);
const normalizedPath = value.startsWith("/") ? value : `/${value}`;
return `${normalizedBase}${normalizedPath}`;
};

View File

@@ -49,6 +49,8 @@ export interface UserListSchema {
first_name: string;
last_name: string;
full_name?: string | null;
university?: string | null;
major?: string | null;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
@@ -61,10 +63,10 @@ export interface UserRegistrationSchema {
username: string;
first_name: string;
last_name: string;
student_id: string;
year_of_study: number;
major: string;
university: string;
student_id?: string | null;
year_of_study?: number | null;
major?: string | null;
university?: string | null;
}
export type UserUpdateSchema = {
@@ -74,7 +76,7 @@ export type UserUpdateSchema = {
year_of_study?: number | null;
major?: string | null;
university?: string | null;
student_id?: number | null;
student_id?: string | null;
};
@@ -107,6 +109,7 @@ export interface PostListSchema {
slug: string;
excerpt?: string;
featured_image?: string;
absolute_featured_image_url?: string | null;
author: {
id: number;
username: string;
@@ -134,6 +137,7 @@ export interface PostListSchema {
export interface PostDetailSchema extends PostListSchema {
content: string;
content_html?: string;
updated_at: string;
views_count?: number;
}
@@ -205,6 +209,8 @@ export interface EventListItemSchema {
capacity?: number | null;
price?: number | null;
status: 'draft' | 'published' | 'cancelled' | 'completed';
status_label?: string;
event_type_label?: string;
registration_count: number;
created_at: string;
}

View File

@@ -35,7 +35,7 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
}
}
const DEFAULT_THUMB = '/images/event-placeholder.svg';
const DEFAULT_THUMB = '/placeholder.svg';
export const getThumbUrl = (e: Types.EventListItemSchema) =>
e.absolute_featured_image_url ||
e.featured_image ||