feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

View file

@ -1,85 +0,0 @@
import { getCsrfToken } from "$lib/auth";
export const API_BASE_URL = "http://localhost:8000/api";
export async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
let csrfToken = await getCsrfToken();
if (!csrfToken) {
console.warn("CSRF token not found, using empty string.");
csrfToken = "";
}
const url = `${API_BASE_URL}${endpoint}`;
options.credentials = "include"; // Include cookies in the request
options.headers = {
...options.headers,
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
};
try {
const response = await fetch(url, options);
if (!response.ok) {
let errorText = await response.json();
if (
errorText &&
typeof errorText === "object" &&
("detail" in errorText || "field_errors" in errorText || "non_field_errors" in errorText)
) {
// Handle structured error responses
if ("detail" in errorText) {
errorText = errorText.detail;
} else if ("field_errors" in errorText) {
errorText = Object.values(errorText.field_errors).join(", ");
} else if ("non_field_errors" in errorText) {
errorText = errorText.non_field_errors.join(", ");
}
} else {
errorText = `Unexpected error: ${response.statusText}`;
}
throw new Error(`${errorText}`);
}
if (response.status === 204) {
// No content response
return {} as T; // Return an empty object for 204 responses
}
return (await response.json()) as T;
} catch (error) {
console.error(`Error fetching ${url}:`, error);
if (error instanceof Error) {
// If the error is an instance of Error, rethrow it
return Promise.reject(new Error(`${error.message}`));
}
return Promise.reject(new Error(`${error}`));
}
}
export function postAPI<T>(endpoint: string, data: any): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
export function getAPI<T>(endpoint: string): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "GET",
});
}
export function putAPI<T>(endpoint: string, data: any): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
export function deleteAPI<T>(endpoint: string): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "DELETE",
});
}

123
src/lib/api/client.ts Normal file
View file

@ -0,0 +1,123 @@
import Cookies from 'js-cookie';
/**
* Thin wrapper around fetch that:
* - prepends the configured API base URL
* - includes Django session cookies
* - attaches the CSRF token from cookies
* - parses structured DRF errors into a single ApiError
*
* The 401 handler is pluggable (see `setUnauthorizedHandler`) so the auth
* layer can react (clear session, route to /login) without this module
* importing from $auth (which would create a cycle).
*
* Default base URL is the relative path `/api` so the app works out of the
* box against either the Vite dev proxy, the mock plugin, or a same-origin
* production deployment. Set VITE_API_BASE_URL to point directly at a
* cross-origin backend if needed.
*/
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
type UnauthorizedHandler = () => void;
let onUnauthorized: UnauthorizedHandler | null = null;
export function setUnauthorizedHandler(fn: UnauthorizedHandler | null) {
onUnauthorized = fn;
}
async function ensureCsrf(): Promise<string> {
const existing = Cookies.get('csrftoken');
if (existing) return existing;
await fetch(`${API_BASE_URL}/csrf/`, { method: 'GET', credentials: 'include' });
return Cookies.get('csrftoken') ?? '';
}
function extractError(body: unknown, fallback: string): string {
if (body && typeof body === 'object') {
const b = body as Record<string, unknown>;
if (typeof b.detail === 'string') return b.detail;
if (b.non_field_errors && Array.isArray(b.non_field_errors)) {
return b.non_field_errors.join(', ');
}
if (b.field_errors && typeof b.field_errors === 'object') {
return Object.values(b.field_errors as Record<string, unknown>)
.flat()
.join(', ');
}
}
return fallback;
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
let url = `${API_BASE_URL}${path}`;
if (!query) return url;
const params = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined) params.set(k, String(v));
}
const qs = params.toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
return url;
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const csrf = await ensureCsrf();
const init: RequestInit = {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf,
...options.headers,
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
};
const res = await fetch(buildUrl(path, options.query), init);
if (res.status === 401) onUnauthorized?.();
if (!res.ok) {
let body: unknown = null;
try {
body = await res.json();
} catch {
// ignore
}
throw new ApiError(res.status, extractError(body, res.statusText), body);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
get: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PATCH', body }),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'DELETE' }),
};

5
src/lib/api/index.ts Normal file
View file

@ -0,0 +1,5 @@
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
export { pointsApi } from './points';
export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';

View file

@ -1,19 +1,11 @@
/* API functions for Saved Points */
import type { SavedPoint } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedPoint } from '$domain';
export function getSavedPoints(): Promise<SavedPoint[]> {
return getAPI<SavedPoint[]>("/saved-points/");
}
const base = '/saved-points/';
export function savePoint(point: SavedPoint): Promise<SavedPoint> {
return postAPI<SavedPoint>("/saved-points/", point);
}
export function updatePoint(point: SavedPoint): Promise<SavedPoint> {
return putAPI<SavedPoint>(`/saved-points/${point.id}/`, point);
}
export function deletePoint(id: number): Promise<void> {
return deleteAPI<void>(`/saved-points/${id}/`);
}
export const pointsApi = {
list: () => api.get<SavedPoint[]>(base),
create: (p: SavedPoint) => api.post<SavedPoint>(base, p),
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};

View file

@ -0,0 +1,34 @@
import { api } from './client';
import type { FlightParameters, RawPrediction } from '$domain';
/**
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
* Round down to the most recent available slot.
*/
export function getLatestDataset(now: Date = new Date()): string {
const rounded = new Date(now);
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
rounded.setUTCHours(rounded.getUTCHours() - 6);
return rounded.toISOString();
}
export function buildLaunchDateTime(date: string, time: string): string {
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
return new Date(`${date}T${fullTime}Z`).toISOString();
}
export interface PredictionResponse {
result: RawPrediction;
}
export const predictionsApi = {
run: (params: FlightParameters, launchDateTime: string) => {
const payload: FlightParameters & { launch_datetime: string } = {
...params,
dataset: params.dataset || getLatestDataset(),
launch_datetime: launchDateTime,
};
if (payload.start_point === -1) delete payload.start_point;
return api.post<PredictionResponse>('/predictions/', payload);
},
};

View file

@ -1,19 +1,11 @@
/* API functions for SavedFlightProfile */
import type {SavedFlightProfile } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedFlightProfile } from '$domain';
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> {
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
}
const base = '/saved-profiles/';
export function saveFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
return postAPI<SavedFlightProfile>("/saved-profiles/", profile);
}
export function updateFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
return putAPI<SavedFlightProfile>(`/saved-profiles/${profile.id}/`, profile);
}
export function deleteFlightProfile(id: number): Promise<void> {
return deleteAPI<void>(`/saved-profiles/${id}/`);
}
export const profilesApi = {
list: () => api.get<SavedFlightProfile[]>(base),
create: (p: SavedFlightProfile) => api.post<SavedFlightProfile>(base, p),
update: (p: SavedFlightProfile) => api.put<SavedFlightProfile>(`${base}${p.id}/`, p),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};

View file

@ -1,19 +1,11 @@
/* API functions for SavedScenario */
import type { SavedScenario } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedScenario } from '$domain';
export function getSavedScenarios(): Promise<SavedScenario[]> {
return getAPI<SavedScenario[]>("/saved-templates/");
}
const base = '/saved-templates/';
export function saveScenario(template: SavedScenario): Promise<SavedScenario> {
return postAPI<SavedScenario>("/saved-templates/", template);
}
export function updateScenario(template: SavedScenario): Promise<SavedScenario> {
return putAPI<SavedScenario>(`/saved-templates/${template.id}/`, template);
}
export function deleteScenario(id: number): Promise<void> {
return deleteAPI<void>(`/saved-templates/${id}/`);
}
export const scenariosApi = {
list: () => api.get<SavedScenario[]>(base),
create: (s: SavedScenario) => api.post<SavedScenario>(base, s),
update: (s: SavedScenario) => api.put<SavedScenario>(`${base}${s.id}/`, s),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};