Refactor frontend: replace axios with native fetch, add standalone build
- Remove axios dependency, use native fetch with token refresh logic - Add output: standalone to next.config.ts for Docker production builds - Add Dockerfile.prod for production frontend container - Bump requests>=2.33 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
324a91271e
commit
c6f7f1da41
5 changed files with 179 additions and 106 deletions
32
frontend/Dockerfile.prod
Normal file
32
frontend/Dockerfile.prod
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
trailingSlash: true,
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.API_URL || "http://localhost:8000";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "^15.2.0",
|
||||
"axios": "^1.7.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,64 +1,122 @@
|
|||
import axios from "axios";
|
||||
const BASE_URL = "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const refresh = localStorage.getItem("refresh_token");
|
||||
if (refresh) {
|
||||
try {
|
||||
const { data } = await axios.post("/api/v1/auth/refresh/", {
|
||||
refresh,
|
||||
});
|
||||
localStorage.setItem("access_token", data.access);
|
||||
if (data.refresh) {
|
||||
localStorage.setItem("refresh_token", data.refresh);
|
||||
}
|
||||
original.headers.Authorization = `Bearer ${data.access}`;
|
||||
return api(original);
|
||||
} catch {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
const refresh = localStorage.getItem("refresh_token");
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
localStorage.setItem("access_token", data.access);
|
||||
if (data.refresh) {
|
||||
localStorage.setItem("refresh_token", data.refresh);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
return data.access;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default api;
|
||||
async function request<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retry = true
|
||||
): Promise<T> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const headers = new Headers(options.headers || getAuthHeaders());
|
||||
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
headers.set("Authorization", `Bearer ${newToken}`);
|
||||
return request<T>(path, { ...options, headers }, false);
|
||||
}
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const error = new Error(`HTTP ${res.status}`) as Error & { status: number; data?: unknown };
|
||||
error.status = res.status;
|
||||
try { error.data = await res.json(); } catch { /* empty */ }
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function get<T = unknown>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
const query = params ? `?${new URLSearchParams(params).toString()}` : "";
|
||||
return request<T>(`${path}${query}`);
|
||||
}
|
||||
|
||||
async function post<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function patch<T = unknown>(path: string, body: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function put<T = unknown>(path: string, body: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function del(path: string): Promise<void> {
|
||||
await request(path, { method: "DELETE", headers: getAuthHeaders() });
|
||||
}
|
||||
|
||||
async function postForm<T = unknown>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return request<T>(path, { method: "POST", headers, body: formData });
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
export async function login(username: string, password: string) {
|
||||
const { data } = await api.post("/auth/login/", { username, password });
|
||||
const data = await post<{ access: string; refresh: string }>("/auth/login/", { username, password });
|
||||
localStorage.setItem("access_token", data.access);
|
||||
localStorage.setItem("refresh_token", data.refresh);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
const { data } = await api.get("/auth/me/");
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getMe(): Promise<any> {
|
||||
return get("/auth/me/");
|
||||
}
|
||||
|
||||
export async function updateProfile(body: {
|
||||
|
|
@ -66,8 +124,7 @@ export async function updateProfile(body: {
|
|||
last_name?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
const { data } = await api.patch("/auth/me/", body);
|
||||
return data;
|
||||
return patch("/auth/me/", body);
|
||||
}
|
||||
|
||||
export async function changePassword(body: {
|
||||
|
|
@ -75,8 +132,7 @@ export async function changePassword(body: {
|
|||
new_password: string;
|
||||
new_password_confirm: string;
|
||||
}) {
|
||||
const { data } = await api.post("/auth/change-password/", body);
|
||||
return data;
|
||||
return post("/auth/change-password/", body);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
|
|
@ -86,14 +142,15 @@ export function logout() {
|
|||
}
|
||||
|
||||
// --- Projects ---
|
||||
export async function getProjects() {
|
||||
const { data } = await api.get("/projects/");
|
||||
return data.results ?? data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getProjects(): Promise<any[]> {
|
||||
const data = await get<{ results?: any[] }>("/projects/");
|
||||
return data.results ?? (data as any);
|
||||
}
|
||||
|
||||
export async function getProject(slug: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getProject(slug: string): Promise<any> {
|
||||
return get(`/projects/${slug}/`);
|
||||
}
|
||||
|
||||
export async function createProject(body: {
|
||||
|
|
@ -101,42 +158,38 @@ export async function createProject(body: {
|
|||
description?: string;
|
||||
source_language?: string;
|
||||
}) {
|
||||
const { data } = await api.post("/projects/", body);
|
||||
return data;
|
||||
return post("/projects/", body);
|
||||
}
|
||||
|
||||
export async function deleteProject(slug: string) {
|
||||
await api.delete(`/projects/${slug}/`);
|
||||
await del(`/projects/${slug}/`);
|
||||
}
|
||||
|
||||
// --- Resources ---
|
||||
export async function getResources(slug: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/resources/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getResources(slug: string): Promise<any> {
|
||||
return get(`/projects/${slug}/resources/`);
|
||||
}
|
||||
|
||||
export async function uploadResource(slug: string, file: File, fileFormat?: string) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (fileFormat) form.append("file_format", fileFormat);
|
||||
const { data } = await api.post(`/projects/${slug}/resources/upload/`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data;
|
||||
return postForm(`/projects/${slug}/resources/upload/`, form);
|
||||
}
|
||||
|
||||
// --- Strings ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getStrings(
|
||||
slug: string,
|
||||
params?: Record<string, string>
|
||||
) {
|
||||
const { data } = await api.get(`/projects/${slug}/strings/`, { params });
|
||||
return data;
|
||||
): Promise<any> {
|
||||
return get(`/projects/${slug}/strings/`, params);
|
||||
}
|
||||
|
||||
export async function getStringDetail(slug: string, stringId: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/strings/${stringId}/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getStringDetail(slug: string, stringId: string): Promise<any> {
|
||||
return get(`/projects/${slug}/strings/${stringId}/`);
|
||||
}
|
||||
|
||||
// --- Translations ---
|
||||
|
|
@ -145,11 +198,7 @@ export async function createTranslation(
|
|||
stringId: string,
|
||||
body: { language_code: string; translated_text: string }
|
||||
) {
|
||||
const { data } = await api.post(
|
||||
`/projects/${slug}/strings/${stringId}/translations/`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
return post(`/projects/${slug}/strings/${stringId}/translations/`, body);
|
||||
}
|
||||
|
||||
export async function updateTranslation(
|
||||
|
|
@ -158,23 +207,22 @@ export async function updateTranslation(
|
|||
language: string,
|
||||
body: { translated_text?: string; status?: string }
|
||||
) {
|
||||
const { data } = await api.patch(
|
||||
return patch(
|
||||
`/projects/${slug}/strings/${stringId}/translations/${language}/`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Progress ---
|
||||
export async function getProgress(slug: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/progress/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getProgress(slug: string): Promise<any> {
|
||||
return get(`/projects/${slug}/progress/`);
|
||||
}
|
||||
|
||||
// --- GitHub Integration ---
|
||||
export async function getGitHubRepo(slug: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/github/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getGitHubRepo(slug: string): Promise<any> {
|
||||
return get(`/projects/${slug}/github/`);
|
||||
}
|
||||
|
||||
export async function linkGitHubRepo(
|
||||
|
|
@ -188,35 +236,31 @@ export async function linkGitHubRepo(
|
|||
access_token?: string;
|
||||
}
|
||||
) {
|
||||
const { data } = await api.put(`/projects/${slug}/github/`, body);
|
||||
return data;
|
||||
return put(`/projects/${slug}/github/`, body);
|
||||
}
|
||||
|
||||
export async function unlinkGitHubRepo(slug: string) {
|
||||
await api.delete(`/projects/${slug}/github/`);
|
||||
await del(`/projects/${slug}/github/`);
|
||||
}
|
||||
|
||||
export async function getGitHubFiles(slug: string) {
|
||||
const { data } = await api.get(`/projects/${slug}/github/files/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getGitHubFiles(slug: string): Promise<any> {
|
||||
return get(`/projects/${slug}/github/files/`);
|
||||
}
|
||||
|
||||
export async function syncGitHubRepo(slug: string) {
|
||||
const { data } = await api.post(`/projects/${slug}/github/sync/`);
|
||||
return data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function syncGitHubRepo(slug: string): Promise<any> {
|
||||
return post(`/projects/${slug}/github/sync/`);
|
||||
}
|
||||
|
||||
// --- Suggestions ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getSuggestions(
|
||||
slug: string,
|
||||
stringId: string,
|
||||
language: string
|
||||
) {
|
||||
const { data } = await api.get(
|
||||
`/projects/${slug}/strings/${stringId}/suggestions/`,
|
||||
{ params: { language } }
|
||||
);
|
||||
return data;
|
||||
): Promise<any> {
|
||||
return get(`/projects/${slug}/strings/${stringId}/suggestions/`, { language });
|
||||
}
|
||||
|
||||
// --- Users (admin) ---
|
||||
|
|
@ -239,24 +283,21 @@ export async function createUser(body: {
|
|||
first_name?: string;
|
||||
last_name?: string;
|
||||
}) {
|
||||
const { data } = await api.post("/users/", body);
|
||||
return data as UserData;
|
||||
return post("/users/", body) as Promise<UserData>;
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
const { data } = await api.get("/users/");
|
||||
const data = await get<{ results?: UserData[] }>("/users/");
|
||||
return (data.results ?? data) as UserData[];
|
||||
}
|
||||
|
||||
export async function getUser(id: string) {
|
||||
const { data } = await api.get(`/users/${id}/`);
|
||||
return data as UserData;
|
||||
return get(`/users/${id}/`) as Promise<UserData>;
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
id: string,
|
||||
body: { role?: string; is_active?: boolean; email?: string; first_name?: string; last_name?: string }
|
||||
) {
|
||||
const { data } = await api.patch(`/users/${id}/`, body);
|
||||
return data as UserData;
|
||||
return patch(`/users/${id}/`, body) as Promise<UserData>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ django-filter>=24.0
|
|||
python-dotenv>=1.0
|
||||
drf-spectacular>=0.27
|
||||
djangorestframework-simplejwt>=5.3,<6.0
|
||||
requests>=2.31
|
||||
requests>=2.33
|
||||
pytest>=8.0
|
||||
pytest-django>=4.8
|
||||
factory-boy>=3.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue