refactor(all): migrate from React to Next.js
This commit is contained in:
@@ -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
9
src/lib/helmet.tsx
Normal 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
103
src/lib/public-api.ts
Normal 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
143
src/lib/router.tsx
Normal 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
17
src/lib/site.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user