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:
root 2026-04-08 02:05:42 +02:00
parent 324a91271e
commit c6f7f1da41
5 changed files with 179 additions and 106 deletions

32
frontend/Dockerfile.prod Normal file
View 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"]

View file

@ -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";

View file

@ -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": {

View file

@ -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>;
}

View file

@ -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